diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index f09865de0194..bb1cd71c9518 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -32,7 +32,9 @@ jobs: - name: Exit failed workflow if: ${{ needs.typecheck.result == 'failure' || needs.lint.result == 'failure' || needs.test.result == 'failure' }} - run: exit 1 + run: | + echo "Checks failed, exiting ~ typecheck: ${{ needs.typecheck.result }}, lint: ${{ needs.lint.result }}, test: ${{ needs.test.result }}" + exit 1 chooseDeployActions: runs-on: ubuntu-latest diff --git a/android/app/build.gradle b/android/app/build.gradle index 4b84a066e6e1..2a9454bf87fe 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 1009000901 - versionName "9.0.9-1" + versionCode 1009000902 + versionName "9.0.9-2" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md index 05366a91d9fa..f94e692f5e56 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md +++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md @@ -8,6 +8,15 @@ Whether you're encountering issues related to company cards, require assistance ## How to add company cards to Expensify You can add company credit cards under the Domain settings in your Expensify account by navigating to *Settings* > *Domain* > _Domain Name_ > *Company Cards* and clicking *Import Card/Bank* and following the prompts. +## To Locate Missing Card Transactions in Expensify +1. **Wait for Posting**: Bank transactions may take up to 24 hours to import into Expensify after they have "posted" at your bank. Ensure sufficient time has passed for transactions to appear. +2. **Update Company Cards**: Go to Settings > Domains > Company Cards. Click on the card in question and click "Update" to refresh the card feed. +3. **Reconcile Cards**: Navigate to the Reconciliation section under Settings > Domains > Company Cards. Refer to the detailed guide on how to use the [Reconciliation Dashboard](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Reconciliation#identifying-outstanding-unapproved-expenses-using-the-reconciliation-dashboard). +4. **Review Transactions**: Use the Reconciliation Dashboard to view all transactions within a specific timeframe. Transactions will display on the Expenses page based on their "Posted Date". If needed, uncheck the "use posted date" checkbox near the filters to view transactions based on their "Transaction Date" instead. +5. **Address Gaps**: If there is a significant gap in transactions or if transactions are still missing, contact Expensify's Concierge or your Account Manager. They can initiate a historical data update on your card feed to ensure all transactions are properly imported. + +Following these steps should help you identify and resolve any issues with missing card transactions in Expensify. + ## Known issues importing transactions The first step should always be to "Update" your card, either from Settings > Your Account > Credit Card Import or Settings > Domain > [Domain Name] > Company Cards for centrally managed cards. If a "Fix" or "Fix card" option appears, follow the steps to fix the connection. If this fails to import your missing transactions, there is a known issue whereby some transactions will not import for certain API-based company card connections. So far this has been reported on American Express, Chase and Wells Fargo. This can be temporarily resolved by creating the expenses manually instead: @@ -63,6 +72,24 @@ If you've answered "yes" to any of these questions, a Domain Admins need to upda Make sure you're importing your card in the correct spot in Expensify and selecting the right bank connection. For company cards, use the master administrative credentials to import your set of cards at *Settings* > *Domains* > _Domain Name_ > *Company Cards* > *Import Card*. Please note there are some things that cannot be bypassed within Expensify, including two-factor authentication being enabled within your bank account. This will prevent the connection from remaining stable and will need to be turned off on the bank side. +## Why Can’t I See the Transactions Before a Certain Date? +When importing a card into Expensify, the platform typically retrieves 30-90 days of historical transactions, depending on the card or account type. For commercial feeds, transactions cannot be imported before the bank starts sending data. If needed, banks can send backdated files, and Expensify can run a historical update upon request. + +Additionally, Expensify does not import transactions dated before the "start date" you specify when assigning the card. Unless transitioning from an old card to a new one to avoid duplicates, it's advisable to set the start date to "earliest possible" or leave it blank. + +For historical expenses that cannot be imported automatically, consider using Expensify's [company card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import) or [personal card](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards#importing-expenses-via-a-spreadsheet) spreadsheet import method. This allows you to manually input missing transactions into the system. + +## Why Am I / Why Is My Employee Seeing Duplicates? +If an employee is seeing duplicate expenses, they may have accidentally imported the card as a personal credit card as well as having the Domain Admin assign them a company card. + +To troubleshoot: +- Have the employee navigate to their Settings > Your Account > Credit Card Import and confirm that their card is only listed once. +- If the card is listed twice, delete the entry without the "padlock" icon. + +**Important:** Deleting a duplicate card will delete all unapproved expenses from that transaction feed. Transactions associated with the remaining card will not be affected. If receipts were attached to those transactions, they will still be on the Expenses page, and the employee can click to SmartScan them again. + +Duplicate expenses might also occur if you recently unassigned and reassigned a company card with an overlapping start date. If this is the case and expenses on the “new” copy have not been submitted, you can unassign the card again and reassign it with a more appropriate start date. This action will delete all unsubmitted expenses from the new card feed. + ## What are the most reliable bank connections in Expensify?* All bank connections listed below are extremely reliable, but we recommend transacting with the Expensify Visa® Commercial Card. It also offers daily and monthly settlement, unapproved expense limits, realtime compliance for secure and efficient spending, and cash back on all US purchases. [Click here to learn more about the Expensify Card](https://use.expensify.com/company-credit-card). diff --git a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md index e14a4cab21d6..0650cf5b516f 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md +++ b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md @@ -1,6 +1,470 @@ --- title: Configure Netsuite -description: Configure Netsuite +description: Configure NetSuite's export, coding, and advanced settings. --- -# Coming soon +By correctly configuring your NetSuite settings in Expensify, you can leverage the connection's settings to automate most of the tasks, making your workflow more efficient. + +# Configure Export Settings + +There are numerous options for exporting Expensify reports to NetSuite. Let's explore how to configure these settings to align with your business needs. + +To access these settings, head to **Settings > Workspace > Group > Connections** and select the **Configure** button. + +## Export Options + +### Subsidiary + +The subsidiary selection will only appear if you use NetSuite OneWorld and have multiple subsidiaries active. If you add a new subsidiary to NetSuite, sync the workspace connection, and the new subsidiary should appear in the dropdown list under **Settings > Workspaces > _[Workspace Name]_ > Connections**. + +### Preferred Exporter + +This option allows any admin to export, but the preferred exporter will receive notifications in Expensify regarding the status of exports. + +### Date + +The three options for the date your report will export with are: +- Date of last expense: This will use the date of the previous expense on the report +- Submitted date: The date the employee submitted the report +- Exported date: The date you export the report to NetSuite + +## Reimbursable Expenses + +### Expense Reports + +Expensify transactions will export reimbursable expenses as expense reports by default, which will be posted to the payables account designated in NetSuite. + +### Vendor Bills + +Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding policy. Each report will be posted as payable to the vendor associated with the employee who submitted the report. +You can also set an approval level in NetSuite for vendor bills. + +### Journal Entries + +Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this policy. All the transactions will be posted to the payable account specified in the policy. + +You can also set an approval level in NetSuite for the journal entries. + +**Important Notes:** +- Journal entry forms by default do not contain a customer column, so it is not possible to export customers or projects with this export option +- The credit line and header level classifications are pulled from the employee record + +## Non-Reimbursable Expenses + +### Vendor Bills + +Non-reimbursable expenses will be posted as a vendor bill payable to the default vendor specified in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific vendor in NetSuite. You can also set an approval level in NetSuite for the bills. + +### Journal Entries + +Non-reimbursable expenses will be posted to the Journal Entries posting account selected in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in NetSuite. + +**Important Notes:** +- Expensify Card expenses will always export as Journal Entries, even if you have Expense Reports or Vendor Bills configured for non-reimbursable expenses on the Export tab +- Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option +- The credit line and header level classifications are pulled from the employee record + +### Expense Reports + +To use the expense report option for your corporate card expenses, you will need to set up your default corporate cards in NetSuite. + +To use a default corporate card for non-reimbursable expenses, you must select the correct card on the employee records (for individual accounts) or the subsidiary record (If you use a non-one world account, the default is found in your accounting preferences). + +Add the corporate card option and corporate card main field to your expense report transaction form in NetSuite by: +1. Heading to _Customization > Forms > Transaction Forms > Preferred expense report form > Screen Fields_ +2. Under the Main tab, check “Show” for Account for Corporate Card Expenses +3. On the Expenses tab, check “Show” for Corporate Card + +You can select the default account on your employee record to use individual corporate cards for each employee. Make sure you add this field to your employee entity form in NetSuite. +If you have multiple cards assigned to a single employee, you cannot export to each account. You can only have a single default per employee record. + +### Export Invoices + +Select the Accounts Receivable account you want your Invoice Reports to export. In NetSuite, the Invoices are linked to the customer, corresponding to the email address where the Invoice was sent. + +### Default Vendor Bills + +When selecting the option to export non-reimbursable expenses as vendor bills, the list of vendors will be available in the dropdown menu. + +# Configure Coding Settings + +The Coding tab is where NetSuite information is configured in Expensify, which allows employees to code expenses and reports accurately. There are several coding options in NetSuite. Let’s go over each of those below. + +## Expense Categories + +Expensify's integration with NetSuite automatically imports NetSuite Expense Categories as Categories in Expensify. + +Please note that each expense must have a Category selected to export to NetSuite. The category chosen must be imported from NetSuite and cannot be manually created in Expensify. + +If you want to delete Categories, you must do this in NetSuite. Categories are added and modified on the integration’s side and then synced with Expensify. +Once imported, you can turn specific Categories on or off under **Settings > Workspaces > _[Workspace Name]_ > Categories**. + +## Tags + +The NetSuite integration allows you to configure Customers, Projects, Departments, Classes, and Locations as line-item expense classifications. These are called Tags in Expensify. + +Suppose a default Customer, Project, Department, Class, or Location ties to the employee record in NetSuite. In that case, Expensify will create a rule that automatically applies that tag to all expenses made by that employee (the Tag is still editable if necessary). + +If you want to delete Tags, you must do this in NetSuite. Tags are added and modified on the integration’s side and then synced with Expensify. + +Once imported, you can turn specific Tags on or off under **Settings > Workspaces > _[Workspace Name]_ > Tags**. + +## Report Fields + +The NetSuite integration allows you to configure Customers, Projects, Departments, Classes, and Locations as report-level classifications. These are called Report Fields in Expensify. + +## NetSuite Employee Default + +The NetSuite integration allows you to set Departments, Classes, and Locations according to the NetSuite Employee Default for expenses exported as both Expense Reports and Journal Entries. + +These fields must be set in NetSuite's employee(s) record(s) to be successfully applied to expenses upon export. + +You cannot use the employee default setting with a vendor bill export if you have both a vendor and an employee set up for the user under the same email address and subsidiary. + +## Tax + +The NetSuite integration allows users to apply a tax rate and amount to each expense. To do this, import Tax Groups from NetSuite: +1. In NetSuite, head to _Setup > Accounting > Tax Groups_ +2. Once imported, go to the NetSuite connection configuration page in Expensify (under **Settings > Workspaces > Group > _[Workspace Name]_ > Connection > NetSuite > Coding**), refresh the subsidiary list, and the Tax option will appear +3. From there, enable Tax +4. Click **Save** +5. Sync the connection +6. All Tax Groups for the connected NetSuite subsidiary will be imported to Expensify as taxes. +7. After syncing, go to **Settings > Workspace > Group > _[Workspace Name]_ > Tax** to see the tax groups imported from NetSuite +8. Use the turn on/off button to choose which taxes to make available to your employees +9. Select a default tax to apply to the workspace (that tax rate will automatically apply to all new expenses) + +## Custom Segments + +To add a Custom Segment to your workspace, you’ll need to locate three fields in NetSuite: +- Segment Name +- Internal ID +- Script/Field ID + +**To find the Segment Name:** +1. Log in as an administrator in NetSuite +2. Head to _Customization > Lists, Records, & Fields > Custom Segments_ +3. You’ll see the Segment Name on the Custom Segments page + +**To find the Internal ID:** +1. Ensure you have internal IDs enabled in NetSuite under _Home > Set Preferences_ +2. Navigate back to the Custom Segment page +3. Click the **Custom Record Type** hyperlink +4. You’ll see the Internal ID on the Custom Record Type page + +**To find the Script/Field ID:** + +Note that as of 2019.1, any new custom segments that you create automatically use the unified ID, and the Use as Field ID box is not visible. If you are editing a custom segment definition that was created before 2019.1, the Use as Field ID box is available. +To use a unified ID for the entire custom segment definition, check the Use as Field ID box. When the box is checked, no field ID fields or columns are shown on the Application & Sourcing subtabs because one ID is used for all fields. + +- If configuring Custom Segments as Report Fields, use the Field ID on the Transactions tab (under _Custom Segments > Transactions_), or if no Field ID is shown, use the unified ID (just called "ID" right below the "Label"). +- If configuring Custom Segments as Tags, use the Field ID on the Transaction Columns tab (under _Custom Segments > Transaction Columns_), or if no Field ID is shown, use the unified ID (just called "ID" right below the "Label"). + +Lastly, head over to Expensify and do the following: +1. Navigate to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configure > Coding tab** +2. Choose how to import Custom Segments (Report Fields or Tags) +3. Fill out the three fields (Segment Name, Internal ID, Script ID) +4. Click **Submit** + +From there, you should see the values for the Custom Segment under the Tag or Report Field settings in Expensify. + +Don’t use the "Filtered by" feature available for Custom Segments. Expensify can’t make these dependent on other fields. If you do have a filter selected, we suggest switching that filter in NetSuite to "Subsidiary" and enabling all subsidiaries to ensure you don't receive any errors upon exporting reports. + +### Custom Records + +Custom Records are added through the Custom Segments feature. + +To add a Custom Record to your workspace, you’ll need to locate three fields in NetSuite: +- The name of the record +- Internal ID +- Transaction Column ID + +**To find the Internal ID:** +1. Make sure you have Internal IDs enabled in NetSuite under Home > Set Preferences +2. Navigate back to the Custom Segment page +3. Click the Custom Record Type hyperlink +4. You’ll see the Internal ID on the Custom Record Type page + +**To find the Transaction Column ID:** +If configuring Custom Segments as Report Fields, use the Field ID on the Transactions tab (under _Custom Segments > Transactions_). + +If configuring Custom Segments as Tags, use the Field ID on the Transaction Columns tab (under _Custom Segments > Transaction Columns_). + +Lastly, head over to Expensify and do the following: +1. Navigate to **Settings > Workspace > Group > [Workspace Name] > Connections > Configure > Coding tab** +2. Choose how to import Custom Records (Report Fields or Tags) +3. Fill out the three fields (the name or label of the record, Internal ID, Transaction Column ID) +4. Click **Submit** + +From there, you should see the values for the Custom Records under the Tag or Report Field settings in Expensify. + +### Custom Lists + +To add Custom Lists to your workspace, you’ll need to locate two fields in NetSuite: +- The name of the record +- The ID of the Transaction Line Field that holds the record + +**To find the record:** +1. Log into Expensify +2. Head to **Settings > Workspace > Group > [Workspace Name] > Connections > Configure > Coding tab** +3. The name of the record will be populated in a dropdown list + +The name of the record will populate in a dropdown list. If you don't see the one you are looking for, click **Refresh Custom List Options**. + +**To find the Transaction Line Field ID:** +1. Log into NetSuite +2. Search "Transaction Line Fields" in the global search +3. Open the option that is holding the record to get the ID + +Lastly, head over to Expensify, and do the following: +1. Navigate to **Settings > Workspaces > Group > [Workspace Name] > Connections > Configure > Coding tab** +2. Choose how to import Custom Lists (Report Fields or Tags) +3. Enter the ID in Expensify in the configuration screen +4. Click **Submit** + +From there, you should see the values for the Custom Lists under the Tag or Report Field settings in Expensify. + +# Configure Advanced Settings + +The NetSuite integration’s advanced configuration settings are accessed under **Settings > Workspaces > Group > _[Workspace Name]_ > Connections > NetSuite > Configure > Advanced tab**. + +Let’s review the different advanced settings and how they interact with the integration. + +## Auto Sync + +Enabling Auto Sync ensures that the information in NetSuite and Expensify is always in sync through automating exports, tracking direct deposits, and communicating export errors. + +**Automatic Export:** +- When you turn on the Auto Sync feature in Expensify, any final report you approve will automatically be sent to NetSuite. +- This happens every day at approximately the same time. + +**Direct Deposit Alert:** +- If you use Expensify's Direct Deposit ACH and have Auto Sync, getting reimbursed for an Expensify report will automatically create a Bill Payment in NetSuite. + +**Tracking Exports and Errors:** +- In the comments section of an Expensify report, you can find extra details about the report. +- The comments section will tell you when the report was sent to NetSuite, and if there were any problems during the export, it will show the error. + +## Newly Imported Categories + +With this enabled, all submitters can add any newly imported Categories to an Expense. + +## Invite Employees & Set Approval Workflow + +### Invite Employees + +Use this option in Expensify to bring your employees from a specific NetSuite subsidiary into Expensify. +Once imported, Expensify will send them an email letting them know they've been added to a workspace. + +### Set Approval Workflow + +Besides inviting employees, you can also establish an approval process in NetSuite. + +By doing this, the Approval Workflow in Expensify will automatically follow the same rules as NetSuite, typically starting with Manager Approval. + +- **Basic Approval:** This is a single level of approval, where all users submit directly to a Final Approver. The Final Approver defaults to the workspace owner but can be edited on the people page. +- **Manager Approval (default):** Two levels of approval route reports first to an employee's NetSuite expense approver or supervisor, and second to a workspace-wide Final Approver. By NetSuite convention, Expensify will map to the supervisor if no expense approver exists. The Final Approver defaults to the workspace owner but can be edited on the people page. +- **Configure Manually:** Employees will be imported, but all levels of approval must be manually configured on the workspace's People settings page. If you enable this setting, it’s recommended you review the newly imported employees and managers on the **Settings > Workspaces > Group > _[Workspace Name]_ > People page**. You can set a user role for each new employee and enforce an approval workflow. + +## Automatically Create Employees/Vendors + +With this feature enabled, Expensify will automatically create a new employee or vendor (if one doesn’t already exist) from the report submitter's email in NetSuite. + +## Export Foreign Currency Amount + +Using this feature allows you to send the original amount of the expense rather than the converted total when exporting to NetSuite. This option is available if you are exporting reimbursable expenses as Expense Reports. + +## Cross-Subsidiary Customers/Projects + +This allows you to import Customers and Projects across all subsidiaries to a single group workspace. For this functionality, you must enable "Intercompany Time and Expense" in NetSuite. + +That feature is found in NetSuite under _Setup > Company > Setup Tasks: Enable Features > Advanced Features_. + +## Sync Reimbursed Reports + +If you're using Expensify's Direct Deposit ACH feature and you want to export reimbursable expenses as either Expense Reports or Vendor Bills in NetSuite, here's what to do: +1. In Expensify, go to the Advanced Settings tab +2. Look for a toggle or switch related to this feature +3. Turn it on by clicking the toggle +4. Select the correct account for the Bill Payment in NetSuite +5. Ensure the account you choose matches the default account for Bill Payments in NetSuite + +That's it! When Expensify reimburses an expense report, it will automatically create a corresponding Bill Payment in NetSuite. + +Alternatively, if reimbursing outside of Expensify, this feature will automatically update the expense report status in Expensify from Approved to Reimbursed when the respective report is paid in NetSuite and the corresponding workspace syncs via Auto-Sync or when the integration connection is manually synced. + +## Setting Approval Levels + +With this setting enabled, you can set approval levels based on your export type. + +- **Expense Reports:** These options correspond to the default preferences in NetSuite – “Supervisor approval only,” “Accounting approval only,” or “Supervisor and Accounting approved.” +- **Vendor Bills or Journal Entries:** These options correspond to the default preferences in NetSuite – “Pending Approval” or “Approved for Posting.” + +If you have Approval Routing selected in your accounting preference, this will override the selections in Expensify. + +If you do not wish to use Approval Routing in NetSuite, go to _Setup > Accounting > Accounting Preferences > Approval Routing_ and ensure Vendor Bills and Journal Entries are not selected. + +### Collection Account + +When exporting invoices, once marked as Paid, the payment is marked against the account selected after enabling the Collection Account setting. + +# Additional Settings +## Categories + +You can use the Auto-Categorization feature so that expenses are automatically categorized. + +To set Category Rules (e.g., receipt requirements or comments), go to the categories page in the workspace under **Settings > Workspaces > [Workspace Name] > Categories**. + +With this setting enabled, when an Expense Category updates in NetSuite, it will update in Expensify automatically. + +## Company Cards + +NetSuite's company card feature simplifies exporting reimbursable and non-reimbursable transactions to your General Ledger (GL). This approach is recommended for several reasons: + +1. **Separate Employees from Vendors:** NetSuite allows you to maintain separate employee and vendor records. This feature proves especially valuable when integrating with Expensify. By utilizing employee defaults for classifications, your employees won't need to apply tags to all their expenses manually. +2. **Default Accounts Payable (A/P) Account:** Expense reports enable you to set a default A/P account for export on your subsidiary record. Unlike vendor bills, where the A/P account defaults to the last selected account, the expense report export option allows you to establish a default A/P account. +3. **Mix Reimbursable and Non-Reimbursable Expenses:** You can freely mix reimbursable and non-reimbursable expenses without categorizing them in NetSuite after export. NetSuite's corporate card feature automatically categorizes expenses into the correct GL accounts, ensuring a neat and organized GL impact. + +**Let’s go over an example!** + +Consider an expense report with one reimbursable and one non-reimbursable expense. Each needs to be exported to different accounts and expense categories. + +In NetSuite, you can quickly identify the non-reimbursable expense marked as a corporate card expense. Reviewing the GL impact, you'll notice that the reimbursable expense is posted to the default A/P account set on the subsidiary record. On the other hand, the company card expense is assigned to the Credit Card account, which can either be set as a default on the subsidiary record (for a single account) or the employee record (for individual credit card accounts in NetSuite). + +Furthermore, each expense is categorized according to your selected expense category. + +To use the expense report option for your corporate card expenses, you'll need to set up default corporate cards in NetSuite. + +For non-reimbursable expenses, choose the appropriate card on the subsidiary record. You can find the default in your accounting preferences if you're not using a OneWorld account. + +Add the corporate card option and the corporate card main field to configure your expense report transaction form in NetSuite: +1. Go to _Customization > Forms > Transaction Forms > Preferred expense report form > Screen Fields_ +2. Under the Main tab, check "Show for Account for Corporate Card Expenses" +3. On the Expenses tab, check "Show for Corporate Card" + +If you prefer individual corporate cards for each employee, you can select the default account on the employee record. Add this field to your employee entity form in NetSuite (under _Customize > Customize Form_ from any employee record). Note that each employee can have only one corporate card account default. + +### Exporting Company Cards to GL Accounts in NetSuite + +If you need to export company card transactions to individual GL accounts, you can set that up at the domain level. + +Let’s go over how to do that: +1. Go to **Settings > Domain > _[Domain name]_ > Company Cards** +2. Click the Export Settings cog on the right-hand side of the card and select the GL account where you want the expenses to export + +After setting the account, exported expenses will be mapped to that designated account. + +## Tax + +You’ll want to set up Tax Groups in Expensify if you're keeping track of taxes. + +Expensify can import "NetSuite Tax Groups" (not Tax Codes) from NetSuite. Tax Groups can contain one or more Tax Codes. If you have subsidiaries in the UK or Ireland, ensure your Tax Groups have only one Tax Code. + +You can locate these in NetSuite by setting up> Accounting > Tax Groups. + +You’ll want to name Tax Groups something that makes sense to your employees since both the name and the tax rate will appear in Expensify. + +To bring NetSuite Tax Groups into Expensify, here's what you need to do: +1. Create your Tax Groups in NetSuite by going to _Setup > Accounting > Tax Groups_ +2. Click **New** +3. Pick the country for your Tax Group +4. Enter the Tax Name (this will be visible to your employees in Expensify) +5. Next, select the subsidiary for this Tax Group +6. Finally, from the table, choose the Tax Code you want to include in this Tax Group +7. Click **Add**, then click **Save** + +Repeat those steps for each tax rate you want to use in Expensify. + +Next, ensure that Tax Groups can be applied to expenses: +1. In NetSuite, head to _Setup > Accounting > Set Up Taxes_ +2. Set the preference for "Tax Code Lists Include" to either "Tax Groups And Tax Codes" or "Tax Groups Only." If you don't see this field, don't worry; it means you don't need to set it for that specific country + +NetSuite has a pre-made list of tax groups for specific locations, but you can also create your own. We'll import both your custom tax groups and the default ones. It's important not to deactivate the default NetSuite tax groups because we rely on them for exporting specific types of expenses. + +For example, there's a default Canadian tax group called CA-Zero, which we use when exporting mileage and per diem expenses that don't have any taxes applied in + +Expensify. If you deactivate this group in NetSuite, it will lead to export errors. + +Additionally, some tax nexuses in NetSuite have specific settings that need to be configured in a certain way to work seamlessly with the Expensify integration: +- ​​In the Tax Code Lists Include field, choose "Tax Groups" or "Tax Groups and Tax Codes." This setting determines how tax information is handled. +- In the Tax Rounding Method field, select "Round Off." Although it won't cause connection errors, not using this setting can result in exported amounts differing from what NetSuite expects. + +If your tax groups are importing into Expensify but not exporting to NetSuite, check that each tax group has the right subsidiaries enabled. That is crucial for proper data exchange. + +## Multi-Currency + +When using multi-currency features with NetSuite, remember these points: + +**Matching Currencies:** The currency set for a vendor or employee record must match the currency chosen for the subsidiary in your Expensify configuration. This alignment is crucial for proper handling. + +**Foreign Currency Conversion:** If you create expenses in one currency and then convert them to another currency within Expensify before exporting, you can include both the original and converted amounts in the exported expense reports. This option, called "Export foreign currency amount," can be found in the Advanced tab of your configuration. Note that Expensify sends only the amounts; the actual currency conversion is performed in NetSuite. + +**Bank Account Currency:** When synchronizing bill payments, make sure your bank account's currency matches the subsidiary's currency. Failure to do so will result in an "Invalid Account" error. This alignment is necessary to prevent issues during payment processing. + +## Exporting Invoices + +When you mark an invoice as paid in Expensify, the paid status syncs with NetSuite and vice versa! + +Let's dive right in: +1. Access Configuration Settings: Go to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configuration** +2. Choose Your Accounts Receivable Account: Scroll down to "Export Expenses to" and select the appropriate Accounts Receivable account from the dropdown list. If you don't see any options, try syncing your NetSuite connection by returning to the Connections page and clicking **Sync Now** + +### Exporting an Invoice to NetSuite + +Invoices will be automatically sent to NetSuite when they are in the "Processing" or "Paid" status. This ensures you always have an up-to-date record of unpaid and paid invoices. + +If you have Auto Sync disabled, you'll need to export your invoices, along with your expense reports, manually. Follow these three simple steps: +1. Filter Invoices: From your Reports page, use filters to find the invoices you want to export. +2. Select Invoices: Pick the invoices ready for export. +3. Export to NetSuite: Click **Export to NetSuite** in the top right-hand corner. + +When exporting to NetSuite, we match the recipient's email address on the invoice to a customer record in NetSuite, meaning each customer in NetSuite must have an email address in their profile. If we can't find a match, we'll create a new customer in NetSuite. + +Once exported, the invoice will appear in the Accounts Receivable account you selected during your NetSuite Export configuration. + +### Updating the status of an invoice to "paid" + +When you mark an invoice as "Paid" in Expensify, this status will automatically update in NetSuite. Similarly, if the invoice is marked as "Paid" in NetSuite, it will sync with Expensify. The payment will be reflected in the Collection account specified in your Advanced Settings Configuration. + +## Download NetSuite Logs + +Sometimes, we might need more details from you to troubleshoot issues with your NetSuite connection. Providing the NetSuite web services usage logs is incredibly useful. + +Here's how you can send them to us: +1. **Generate the Logs:** Start by trying to export a report from your system. This action will create the most recent logs that we require. +2. **Access Web Services Usage Logs:** You can locate these logs in your NetSuite account. Just use the global search bar at the top of the page and type in "Web Services Usage Log." +3. **Identify the Logs:** Look for the most recent log entry. It should have "FAILED" under the STATUS column. Click on the two blue "view" links under the REQUEST and RESPONSE columns. These are the two .xml files we need to examine. + +Send these two files to your Account Manager or Concierge so we can continue troubleshooting! + +{% include faq-begin.md %} + +## How does Auto Sync work with reimbursed reports? + +If a report is reimbursed via ACH or marked as reimbursed in Expensify and then exported to NetSuite, the report is automatically marked as paid in NetSuite during the next sync. + +If a report is exported to NetSuite and then marked as paid in NetSuite, the report is automatically marked as reimbursed in Expensify during the next sync. + +## If I enable Auto Sync, what happens to existing approved and reimbursed reports? + +If you previously had Auto Sync disabled but want to allow that feature to be used going forward, you can safely turn it on without affecting existing reports. Auto Sync will only take effect for reports created after enabling that feature. + +## Why are some of my customers not importing from NetSuite? + +If only part of your customer list is importing from NetSuite to Expensify, ensure your page size is set to 1000 for importing customers and vendors: +1. Navigate to **Setup > Integration > Web Services Preferences > Search Page Size** +2. Adjust this setting to 1000 +3. Sync your connection again under **Settings > Workspaces > Group > Workspace Name > Connections** + +Additionally, ensure the "Company Name" field is completed for each customer profile; otherwise, they won't import into the Group Workspace. + +## Why aren't all my Categories pulling into Expensify from NetSuite? + +If you're having trouble importing your Categories, you'll want to start by checking that they are set up in NetSuite as actual Expense Categories, not General Ledger accounts: +- Log into NetSuite as an administrator and go to **Setup > Accounting > Expense Categories** +- A list of Expense Categories should be available +- If no Expense Categories are visible click on "New" to create new Expense Categories + +If you have confirmed that your categories are set as Expense Categories in NetSuite and they still aren't importing to Expensify, make sure that the subsidiary of the Expense Category matches the subsidiary selected in your connection settings. + +{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md index 3c0e228c923f..1f96d9b8a633 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md +++ b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md @@ -1,21 +1,21 @@ --- title: NetSuite -description: Connect and configure NetSuite directly to Expensify. +description: Set up the direct connection from Expensify to NetSuite. order: 1 --- # Overview -Expensify's seamless integration with NetSuite enables you to streamline your expense reporting process. This integration allows you to automate the export of reports, tailor your coding preferences, and tap into NetSuite's array of advanced features. By correctly configuring your NetSuite settings in Expensify, you can leverage the connection's settings to automate most of the tasks, making your workflow more efficient. +Expensify's integration with NetSuite allows you to automate report exports, tailor your coding preferences, and tap into NetSuite's array of advanced features. By correctly configuring your NetSuite settings in Expensify, you can leverage the connection's settings to automate most of the tasks, making your workflow more efficient. -Before getting started with connecting NetSuite to Expensify, there are a few things to note: +**Before connecting NetSuite to Expensify, a few things to note:** - Token-based authentication works by ensuring that each request to NetSuite is accompanied by a signed token which is verified for authenticity - You must be able to login to NetSuite as an administrator to initiate the connection - You must have a Control Plan in Expensify to integrate with NetSuite - Employees don’t need NetSuite access or a NetSuite license to submit expense reports since the connection is managed by the Workspace Admin - Each NetSuite subsidiary will need its own Expensify Group Workspace - Ensure that your workspace's report output currency setting matches the NetSuite Subsidiary default currency -- Make sure your page size is set to 1000 for importing your customers and vendors. Go to Setup > Integration > Web Services Preferences > 'Search Page Size' +- Make sure your page size is set to 1000 for importing your customers and vendors. You can check this in NetSuite under **Setup > Integration > Web Services Preferences > 'Search Page Size'** -# How to Connect to NetSuite +# Connect to NetSuite ## Step 1: Install the Expensify Bundle in NetSuite @@ -58,7 +58,7 @@ Enabling Expense Reports is required as part of Expensify's integration with Net ## Step 6: Confirm Expense Categories are set up in NetSuite. -Once Expense Reports are enabled, Expense Categories can be set up in NetSuite. Expense Categories are an alias for General Ledger accounts for coding expenses. +Once Expense Reports are enabled, Expense Categories can be set up in NetSuite. Expense Categories are an alias for General Ledger accounts used to code expenses. 1. Logged into NetSuite as an administrator, go to Setup > Accounting > Expense Categories (a list of Expense Categories should show) 2. If no Expense Categories are visible, click **New** to create new ones @@ -129,452 +129,36 @@ Please note that you must create the connection using a NetSuite account with th Once connected, all reports exported from Expensify will be generated in NetSuite using SOAP Web Services (the term NetSuite employs when records are created through the integration). -# How to Configure Export Settings - -There are numerous options for exporting Expensify reports to NetSuite. Let's explore how to configure these settings to align with your business needs. -To access these settings, head to **Settings > Workspace > Group > Connections** and select the **Configure** button. - -## Export Options - -### Subsidiary - -The subsidiary selection will only appear if you use NetSuite OneWorld and have multiple subsidiaries active. If you add a new subsidiary to NetSuite, sync the workspace connection, and the new subsidiary should appear in the dropdown list under **Settings > Workspaces > _[Workspace Name]_ > Connections**. - -### Preferred Exporter - -This option allows any admin to export, but the preferred exporter will receive notifications in Expensify regarding the status of exports. - -### Date - -The three options for the date your report will export with are: -- Date of last expense: This will use the date of the previous expense on the report -- Submitted date: The date the employee submitted the report -- Exported date: The date you export the report to NetSuite - -## Reimbursable Expenses - -### Expense Reports - -Expensify transactions will export reimbursable expenses as expense reports by default, which will be posted to the payables account designated in NetSuite. - -### Vendor Bills - -Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding policy. Each report will be posted as payable to the vendor associated with the employee who submitted the report. -You can also set an approval level in NetSuite for vendor bills. - -### Journal Entries - -Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this policy. All the transactions will be posted to the payable account specified in the policy. - -You can also set an approval level in NetSuite for the journal entries. - -**Important Notes:** -- Journal entry forms by default do not contain a customer column, so it is not possible to export customers or projects with this export option -- The credit line and header level classifications are pulled from the employee record - -## Non-Reimbursable Expenses - -### Vendor Bills - -Non-reimbursable expenses will be posted as a vendor bill payable to the default vendor specified in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific vendor in NetSuite. You can also set an approval level in NetSuite for the bills. - -### Journal Entries - -Non-reimbursable expenses will be posted to the Journal Entries posting account selected in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in NetSuite. - -**Important Notes:** -- Expensify Card expenses will always export as Journal Entries, even if you have Expense Reports or Vendor Bills configured for non-reimbursable expenses on the Export tab -- Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option -- The credit line and header level classifications are pulled from the employee record - -### Expense Reports - -To use the expense report option for your corporate card expenses, you will need to set up your default corporate cards in NetSuite. - -To use a default corporate card for non-reimbursable expenses, you must select the correct card on the employee records (for individual accounts) or the subsidiary record (If you use a non-one world account, the default is found in your accounting preferences). - -Add the corporate card option and corporate card main field to your expense report transaction form in NetSuite by: -1. Heading to _Customization > Forms > Transaction Forms > Preferred expense report form > Screen Fields_ -2. Under the Main tab, check “Show” for Account for Corporate Card Expenses -3. On the Expenses tab, check “Show” for Corporate Card - -You can select the default account on your employee record to use individual corporate cards for each employee. Make sure you add this field to your employee entity form in NetSuite. -If you have multiple cards assigned to a single employee, you cannot export to each account. You can only have a single default per employee record. - -### Export Invoices - -Select the Accounts Receivable account you want your Invoice Reports to export. In NetSuite, the Invoices are linked to the customer, corresponding to the email address where the Invoice was sent. - -### Default Vendor Bills - -The list of vendors will be available in the dropdown when selecting the option to export non-reimbursable expenses as vendor bills. - -# How to Configure Coding Settings - -The Coding tab is where NetSuite information is configured in Expensify, which allows employees to code expenses and reports accurately. There are several coding options in NetSuite. Let’s go over each of those below. - -## Expense Categories - -Expensify's integration with NetSuite automatically imports NetSuite Expense Categories as Categories in Expensify. - -Please note that each expense must have a Category selected to export to NetSuite. The category chosen must be imported from NetSuite and cannot be manually created in Expensify. - -If you want to delete Categories, you must do this in NetSuite. Categories are added and modified on the integration’s side and then synced with Expensify. -Once imported, you can turn specific Categories on or off under **Settings > Workspaces > _[Workspace Name]_ > Categories**. - -## Tags - -The NetSuite integration allows you to configure Customers, Projects, Departments, Classes, and Locations as line-item expense classifications. These are called Tags in Expensify. - -Suppose a default Customer, Project, Department, Class, or Location ties to the employee record in NetSuite. In that case, Expensify will create a rule that automatically applies that tag to all expenses made by that employee (the Tag is still editable if necessary). - -If you want to delete Tags, you must do this in NetSuite. Tags are added and modified on the integration’s side and then synced with Expensify. - -Once imported, you can turn specific Tags on or off under **Settings > Workspaces > _[Workspace Name]_ > Tags**. - -## Report Fields - -The NetSuite integration allows you to configure Customers, Projects, Departments, Classes, and Locations as report-level classifications. These are called Report Fields in Expensify. - -## NetSuite Employee Default - -The NetSuite integration allows you to set Departments, Classes, and Locations according to the NetSuite Employee Default for expenses exported as both Expense Reports and Journal Entries. - -These fields must be set in NetSuite's employee(s) record(s) to be successfully applied to expenses upon export. - -You cannot use the employee default setting with a vendor bill export if you have both a vendor and an employee set up for the user under the same email address and subsidiary. - -## Tax - -The NetSuite integration allows users to apply a tax rate and amount to each expense. To do this, import Tax Groups from NetSuite: -1. In NetSuite, head to _Setup > Accounting > Tax Groups_ -2. Once imported, go to the NetSuite connection configuration page in Expensify (under **Settings > Workspaces > Group > _[Workspace Name]_ > Connection > NetSuite > Coding**), refresh the subsidiary list, and the Tax option will appear -3. From there, enable Tax -4. Click **Save** -5. Sync the connection -6. All Tax Groups for the connected NetSuite subsidiary will be imported to Expensify as taxes. -7. After syncing, go to **Settings > Workspace > Group > _[Workspace Name]_ > Tax** to see the tax groups imported from NetSuite -8. Use the turn on/off button to choose which taxes to make available to your employees -9. Select a default tax to apply to the workspace (that tax rate will automatically apply to all new expenses) - -## Custom Segments - -To add a Custom Segment to your workspace, you’ll need to locate three fields in NetSuite: -- Segment Name -- Internal ID -- Script/Field ID - -**To find the Segment Name:** -1. Log in as an administrator in NetSuite -2. Head to _Customization > Lists, Records, & Fields > Custom Segments_ -3. You’ll see the Segment Name on the Custom Segments page - -**To find the Internal ID:** -1. Ensure you have internal IDs enabled in NetSuite under _Home > Set Preferences_ -2. Navigate back to the Custom Segment page -3. Click the **Custom Record Type** hyperlink -4. You’ll see the Internal ID on the Custom Record Type page - -**To find the Script/Field ID:** - -Note that as of 2019.1, any new custom segments that you create automatically use the unified ID, and the Use as Field ID box is not visible. If you are editing a custom segment definition that was created before 2019.1, the Use as Field ID box is available. -To use a unified ID for the entire custom segment definition, check the Use as Field ID box. When the box is checked, no field ID fields or columns are shown on the Application & Sourcing subtabs because one ID is used for all fields. - -- If configuring Custom Segments as Report Fields, use the Field ID on the Transactions tab (under _Custom Segments > Transactions_), or if no Field ID is shown, use the unified ID (just called "ID" right below the "Label"). -- If configuring Custom Segments as Tags, use the Field ID on the Transaction Columns tab (under _Custom Segments > Transaction Columns_), or if no Field ID is shown, use the unified ID (just called "ID" right below the "Label"). - -Lastly, head over to Expensify and do the following: -1. Navigate to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configure > Coding tab** -2. Choose how to import Custom Segments (Report Fields or Tags) -3. Fill out the three fields (Segment Name, Internal ID, Script ID) -4. Click **Submit** - -From there, you should see the values for the Custom Segment under the Tag or Report Field settings in Expensify. - -Don’t use the "Filtered by" feature available for Custom Segments. Expensify can’t make these dependent on other fields. If you do have a filter selected, we suggest switching that filter in NetSuite to "Subsidiary" and enabling all subsidiaries to ensure you don't receive any errors upon exporting reports. - -### Custom Records - -Custom Records are added through the Custom Segments feature. - -To add a Custom Record to your workspace, you’ll need to locate three fields in NetSuite: -- The name of the record -- Internal ID -- Transaction Column ID - -**To find the Internal ID:** -1. Make sure you have Internal IDs enabled in NetSuite under Home > Set Preferences -2. Navigate back to the Custom Segment page -3. Click the Custom Record Type hyperlink -4. You’ll see the Internal ID on the Custom Record Type page - -**To find the Transaction Column ID:** -If configuring Custom Segments as Report Fields, use the Field ID on the Transactions tab (under _Custom Segments > Transactions_). - -If configuring Custom Segments as Tags, use the Field ID on the Transaction Columns tab (under _Custom Segments > Transaction Columns_). - -Lastly, head over to Expensify and do the following: -1. Navigate to **Settings > Workspace > Group > [Workspace Name]_ > Connections > Configure > Coding tab** -2. Choose how to import Custom Records (Report Fields or Tags) -3. Fill out the three fields (the name or label of the record, Internal ID, Transaction Column ID) -4. Click **Submit** - -From there, you should see the values for the Custom Records under the Tag or Report Field settings in Expensify. - -### Custom Lists - -To add Custom Lists to your workspace, you’ll need to locate two fields in NetSuite: -- The name of the record -- The ID of the Transaction Line Field that holds the record - -**To find the record:** -1. Log into Expensify -2. Head to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configure > Coding tab** -3. The name of the record will be populated in a dropdown list - -The name of the record will populate in a dropdown list. If you don't see the one you are looking for, click **Refresh Custom List Options**. - -**To find the Transaction Line Field ID:** -1. Log into NetSuite -2. Search "Transaction Line Fields" in the global search -3. Open the option that is holding the record to get the ID - -Lastly, head over to Expensify, and do the following: -1. Navigate to **Settings > Workspaces > Group > _[Workspace Name]_ > Connections > Configure > Coding tab** -2. Choose how to import Custom Lists (Report Fields or Tags) -3. Enter the ID in Expensify in the configuration screen -4. Click **Submit** - -From there, you should see the values for the Custom Lists under the Tag or Report Field settings in Expensify. - -# How to Configure Advanced Settings - -The NetSuite integration’s advanced configuration settings are accessed under **Settings > Workspaces > Group > _[Workspace Name]_ > Connections > NetSuite > Configure > Advanced tab**. - -Let’s review the different advanced settings and how they interact with the integration. - -## Auto Sync - -Enabling Auto Sync ensures that the information in NetSuite and Expensify is always in sync through automating exports, tracking direct deposits, and communicating export errors. - -**Automatic Export:** -- When you turn on the Auto Sync feature in Expensify, any final report you approve will automatically be sent to NetSuite. -- This happens every day at approximately the same time. - -**Direct Deposit Alert:** -- If you use Expensify's Direct Deposit ACH and have Auto Sync, getting reimbursed for an Expensify report will automatically create a Bill Payment in NetSuite. - -**Tracking Exports and Errors:** -- In the comments section of an Expensify report, you can find extra details about the report. -- The comments section will tell you when the report was sent to NetSuite, and if there were any problems during the export, it will show the error. - -## Newly Imported Categories - -With this enabled, all submitters can add any newly imported Categories to an Expense. - -## Invite Employees & Set Approval Workflow - -### Invite Employees - -Use this option in Expensify to bring your employees from a specific NetSuite subsidiary into Expensify. -Once imported, Expensify will send them an email letting them know they've been added to a workspace. - -### Set Approval Workflow - -Besides inviting employees, you can also establish an approval process in NetSuite. - -By doing this, the Approval Workflow in Expensify will automatically follow the same rules as NetSuite, typically starting with Manager Approval. - -- **Basic Approval:** A single level of approval, where all users submit directly to a Final Approver. The Final Approver defaults to the workspace owner but can be edited on the people page. -- **Manager Approval (default):** Two levels of approval route reports first to an employee's NetSuite expense approver or supervisor, and second to a workspace-wide Final Approver. By NetSuite convention, Expensify will map to the supervisor if no expense approver exists. The Final Approver defaults to the workspace owner but can be edited on the people page. -- **Configure Manually:** Employees will be imported, but all levels of approval must be manually configured on the workspace's People settings page. If you enable this setting, it’s recommended you review the newly imported employees and managers on the **Settings > Workspaces > Group > _[Workspace Name]_ > People page**. You can set a user role for each new employee and enforce an approval workflow. - -## Automatically Create Employees/Vendors - -With this feature enabled, Expensify will automatically create a new employee or vendor (if one doesn’t already exist) from the email of the report submitter in NetSuite. - -## Export Foreign Currency Amount - -Using this feature allows you to send the original amount of the expense rather than the converted total when exporting to NetSuite. This option is available if you are exporting reimbursable expenses as Expense Reports. - -## Cross-Subsidiary Customers/Projects - -This allows you to import Customers and Projects across all subsidiaries to a single group workspace. For this functionality, you must enable "Intercompany Time and Expense" in NetSuite. - -That feature is found in NetSuite under _Setup > Company > Setup Tasks: Enable Features > Advanced Features_. - -## Sync Reimbursed Reports - -If you're using Expensify's Direct Deposit ACH feature and you want to export reimbursable expenses as either Expense Reports or Vendor Bills in NetSuite, here's what to do: -1. In Expensify, go to the Advanced Settings tab -2. Look for a toggle or switch related to this feature -3. Turn it on by clicking the toggle -4. Select the correct account for the Bill Payment in NetSuite -5. Ensure the account you choose matches the default account for Bill Payments in NetSuite - -That's it! When Expensify reimburses an expense report, it will automatically create a corresponding Bill Payment in NetSuite. - -Alternatively, if reimbursing outside of Expensify, this feature will automatically update the expense report status in Expensify from Approved to Reimbursed when the respective report is paid in NetSuite and the corresponding workspace syncs via Auto-Sync or when the integration connection is manually synced. - -## Setting Approval Levels - -With this setting enabled, you can set approval levels based on your export type. - -- **Expense Reports:** These options correspond to the default preferences in NetSuite – “Supervisor approval only,” “Accounting approval only,” or “Supervisor and Accounting approved.” -- **Vendor Bills or Journal Entries:** These options correspond to the default preferences in NetSuite – “Pending Approval” or “Approved for Posting.” - -If you have Approval Routing selected in your accounting preference, this will override the selections in Expensify. - -If you do not wish to use Approval Routing in NetSuite, go to _Setup > Accounting > Accounting Preferences > Approval Routing_ and ensure Vendor Bills and Journal Entries are not selected. - -### Collection Account - -When exporting invoices, once marked as Paid, the payment is marked against the account selected after enabling the Collection Account setting. - -# Deep Dive - -## Categories - -You can use the Auto-Categorization feature so that expenses are automatically categorized. - -To set Category Rules (e.g., receipt requirements or comments), go to the categories page in the workspace under **Settings > Workspaces > _[Workspace Name]_ > Categories**. - -With this setting enabled, when an Expense Category updates in NetSuite, it will update in Expensify automatically. - -## Company Cards - -NetSuite's company card feature simplifies exporting reimbursable and non-reimbursable transactions to your General Ledger (GL). This approach is recommended for several reasons: - -1. **Separate Employees from Vendors:** NetSuite allows you to maintain separate employee and vendor records. This feature proves especially valuable when integrating with Expensify. By utilizing employee defaults for classifications, your employees won't need to apply tags to all their expenses manually. -2. **Default Accounts Payable (A/P) Account:** Expense reports enable you to set a default A/P account for export on your subsidiary record. Unlike vendor bills, where the A/P account defaults to the last selected account, the expense report export option allows you to establish a default A/P account. -3. **Mix Reimbursable and Non-Reimbursable Expenses:** You can freely mix reimbursable and non-reimbursable expenses without categorizing them in NetSuite after export. NetSuite's corporate card feature automatically categorizes expenses into the correct GL accounts, ensuring a neat and organized GL impact. - -#### Let’s go over an example! - -Consider an expense report with one reimbursable and one non-reimbursable expense. Each needs to be exported to different accounts and expense categories. - -In NetSuite, you can quickly identify the non-reimbursable expense marked as a corporate card expense. Reviewing the GL impact, you'll notice that the reimbursable expense is posted to the default A/P account set on the subsidiary record. On the other hand, the company card expense is assigned to the Credit Card account, which can either be set as a default on the subsidiary record (for a single account) or the employee record (for individual credit card accounts in NetSuite). - -Furthermore, each expense is categorized according to your selected expense category. - -You'll need to set up default corporate cards in NetSuite to use the expense report option for your corporate card expenses. - -For non-reimbursable expenses, choose the appropriate card on the subsidiary record. You can find the default in your accounting preferences if you're not using a OneWorld account. - -Add the corporate card option and the corporate card main field to configure your expense report transaction form in NetSuite: -1. Go to _Customization > Forms > Transaction Forms > Preferred expense report form > Screen Fields_ -2. Under the Main tab, check "Show for Account for Corporate Card Expenses" -3. On the Expenses tab, check "Show for Corporate Card" - -If you prefer individual corporate cards for each employee, you can select the default account on the employee record. Add this field to your employee entity form in NetSuite (under _Customize > Customize Form_ from any employee record). Note that each employee can have only one corporate card account default. - -### Exporting Company Cards to GL Accounts in NetSuite - -If you need to export company card transactions to individual GL accounts, you can set that up at the domain level. - -Let’s go over how to do that: -1. Go to **Settings > Domain > _[Domain name]_ > Company Cards** -2. Click the Export Settings cog on the right-hand side of the card and select the GL account where you want the expenses to export - -After setting the account, exported expenses will be mapped to that designated account. - -## Tax - -You’ll want to set up Tax Groups in Expensify if you're keeping track of taxes. - -Expensify can import "NetSuite Tax Groups" (not Tax Codes) from NetSuite. Tax Groups can contain one or more Tax Codes. If you have subsidiaries in the UK or Ireland, ensure your Tax Groups have only one Tax Code. - -You can locate these in NetSuite by setting up> Accounting > Tax Groups. - -You’ll want to name Tax Groups something that makes sense to your employees since both the name and the tax rate will appear in Expensify. - -To bring NetSuite Tax Groups into Expensify, here's what you need to do: -1. Create your Tax Groups in NetSuite by going to _Setup > Accounting > Tax Groups_ -2. Click **New** -3. Pick the country for your Tax Group -4. Enter the Tax Name (this will be visible to your employees in Expensify) -5. Next, select the subsidiary for this Tax Group -6. Finally, from the table, choose the Tax Code you want to include in this Tax Group -7. Click **Add**, then click **Save** - -Repeat those steps for each tax rate you want to use in Expensify. - -Next, ensure that Tax Groups can be applied to expenses: -1. In NetSuite, head to _Setup > Accounting > Set Up Taxes_ -2. Set the preference for "Tax Code Lists Include" to either "Tax Groups And Tax Codes" or "Tax Groups Only." If you don't see this field, don't worry; it means you don't need to set it for that specific country - -NetSuite has a pre-made list of tax groups for specific locations, but you can also create your own. We'll import both your custom tax groups and the default ones. It's important not to deactivate the default NetSuite tax groups because we rely on them for exporting specific types of expenses. - -For example, there's a default Canadian tax group called CA-Zero, which we use when exporting mileage and per diem expenses that don't have any taxes applied in - -Expensify. If you deactivate this group in NetSuite, it will lead to export errors. - -Additionally, some tax nexuses in NetSuite have specific settings that need to be configured in a certain way to work seamlessly with the Expensify integration: -- ​​In the Tax Code Lists Include field, choose "Tax Groups" or "Tax Groups and Tax Codes." This setting determines how tax information is handled. -- In the Tax Rounding Method field, select "Round Off." Although it won't cause connection errors, not using this setting can result in exported amounts differing from what NetSuite expects. - -If your tax groups are importing into Expensify but not exporting to NetSuite, check that each tax group has the right subsidiaries enabled. That is crucial for proper data exchange. - -## Multi-Currency - -When using multi-currency features with NetSuite, remember these points: - -**Matching Currencies:** The currency set for a vendor or employee record must match the currency chosen for the subsidiary in your Expensify configuration. This alignment is crucial for proper handling. - -**Foreign Currency Conversion:** If you create expenses in one currency and then convert them to another currency within Expensify before exporting, you can include both the original and converted amounts in the exported expense reports. This option, called "Export foreign currency amount," can be found in the Advanced tab of your configuration. Note that Expensify sends only the amounts; the actual currency conversion is performed in NetSuite. - -**Bank Account Currency:** When synchronizing bill payments, make sure your bank account's currency matches the subsidiary's currency. Failure to do so will result in an "Invalid Account" error. This alignment is necessary to prevent issues during payment processing. - -## Exporting Invoices - -When you mark an invoice as paid in Expensify, the paid status syncs with NetSuite and vice versa! - -Let's dive right in: -1. Access Configuration Settings: Go to **Settings > Workspace > Group > _[Workspace Name]_ > Connections > Configuration** -2. Choose Your Accounts Receivable Account: Scroll down to "Export Expenses to" and select the appropriate Accounts Receivable account from the dropdown list. If you don't see any options, try syncing your NetSuite connection by returning to the Connections page and clicking **Sync Now** - -### Exporting an Invoice to NetSuite - -Invoices will be automatically sent to NetSuite when they are in the "Processing" or "Paid" status. This ensures you always have an up-to-date record of unpaid and paid invoices. - -If you have Auto Sync disabled, you'll need to export your invoices, along with your expense reports, manually. Follow these three simple steps: -1. Filter Invoices: From your Reports page, use filters to find the invoices you want to export. -2. Select Invoices: Pick the invoices ready for export. -3. Export to NetSuite: Click **Export to NetSuite** in the top right-hand corner. - -When exporting to NetSuite, we match the recipient's email address on the invoice to a customer record in NetSuite, meaning each customer in NetSuite must have an email address in their profile. If we can't find a match, we'll create a new customer in NetSuite. - -Once exported, the invoice will appear in the Accounts Receivable account you selected during your NetSuite Export configuration. - -### Updating an Invoice to paid - -When you mark an invoice as "Paid" in Expensify, this status will automatically update in NetSuite. Similarly, if the invoice is marked as "Paid" in NetSuite, it will sync with Expensify. The payment will be reflected in the Collection account specified in your Advanced Settings Configuration. - -## Download NetSuite Logs - -Sometimes, we might need more details from you to troubleshoot issues with your NetSuite connection. Providing the NetSuite web services usage logs is incredibly useful. - -Here's how you can send them to us: -1. **Generate the Logs:** Start by trying to export a report from your system. This action will create the most recent logs that we require. -2. **Access Web Services Usage Logs:** You can locate these logs in your NetSuite account. Just use the global search bar at the top of the page and type in "Web Services Usage Log." -3. **Identify the Logs:** Look for the most recent log entry. It should have "FAILED" under the STATUS column. Click on the two blue "view" links under the REQUEST and RESPONSE columns. These are the two .xml files we need to examine. - -Send these two files to your Account Manager or Concierge so we can continue troubleshooting! - {% include faq-begin.md %} -## What type of Expensify plan is required for connecting to NetSuite? - -You need a group workspace on a Control Plan to integrate with NetSuite. - -## How does Auto Sync work with reimbursed reports? - -If a report is reimbursed via ACH or marked as reimbursed in Expensify and then exported to NetSuite, the report is automatically marked as paid in NetSuite during the next sync. - -If a report is exported to NetSuite and then marked as paid in NetSuite, the report is automatically marked as reimbursed in Expensify during the next sync. - -## If I enable Auto Sync, what happens to existing approved and reimbursed reports? - -If you previously had Auto Sync disabled but want to allow that feature to be used going forward, you can safely turn on Auto Sync without affecting existing reports. Auto Sync will only take effect for reports created after enabling that feature. +## Can negative expenses be exported to NetSuite? +You can export reports with a negative total to NetSuite by selecting “Vendor Bill” as your export option. When a report total is negative, we’ll create a Vendor Credit in NetSuite instead of a bill. + +**Important**: Only enable this if you pay your employees/vendors outside of Expensify. A Vendor Credit reduces the total amount payable in NetSuite, but not in Expensify. + +To use this feature, make sure you have configured your Vendor Credit transaction form in NetSuite and are using the latest version of the Expensify bundle (version 1.4). If you need to update, go to **Customization > SuiteBundler > Search & Install Bundles > List** and click **Update** next to **Expensify Connect**. + +## How do you switch the owner of the connection between NetSuite and Expensify? + +Follow the steps below to transfer ownership of the NetSuite connection to someone else: +1. Head to **Settings > Workspaces > Workspace Name > Connections > NetSuite** +2. Click **Configure** to review and save the settings for future reference +3. Select **Do not connect to NetSuite** +4. Select **Connect to NetSuite** +5. Enter the email address of the new admin who will take over as the NetSuite User ID +6. Enter the NetSuite Account ID (found in NetSuite under **Setup > Integration > Web Services Preferences**) +7. Click **Create a new NetSuite Connection** +8. Confirm completion of prerequisites and proceed by clicking Continue +9. You will be redirected to the NetSuite SSO page, where you will enter the email address of the new connection owner and the NetSuite password for that account +10. Once redirected to the NetSuite page, click **View all roles** and ensure you are logged in under the Administrator role +11. After confirmation, sign out +12. Return to Expensify to reconfigure the sync and export settings on the updated connection +13. Click **Save** + +**If you run into any issues updating the connection, follow these additional troubleshooting steps:** +- In NetSuite, access the role of the current connection owner +- Click Edit > Access > Choose any role other than Administrator > Save +- Click Edit > Access > Select Administrator role > Save +- Repeat the steps outlined above {% include faq-end.md %} diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 0589648b2301..1f5e147806d0 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.9.1 + 9.0.9.2 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index ccf45c0cf8d4..acb52a9762b7 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.9.1 + 9.0.9.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index fe746670e837..3cda28856899 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.9 CFBundleVersion - 9.0.9.1 + 9.0.9.2 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 2ced5738cca8..71df37f4105e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.9-1", + "version": "9.0.9-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.9-1", + "version": "9.0.9-2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 81e963e12671..1fdb3251c793 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.9-1", + "version": "9.0.9-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/scripts/applyPatches.sh b/scripts/applyPatches.sh index 9145629015ee..4ce023755258 100755 --- a/scripts/applyPatches.sh +++ b/scripts/applyPatches.sh @@ -11,7 +11,7 @@ source "$SCRIPTS_DIR/shellUtils.sh" function patchPackage { OS="$(uname)" if [[ "$OS" == "Darwin" || "$OS" == "Linux" ]]; then - npx patch-package --error-on-fail + npx patch-package --error-on-fail --color=always else error "Unsupported OS: $OS" exit 1 diff --git a/src/CONST.ts b/src/CONST.ts index dcef6d01f36c..f6cc32c5259e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -842,6 +842,8 @@ const CONST = { IOU: 'iou', TASK: 'task', INVOICE: 'invoice', + }, + UNSUPPORTED_TYPE: { PAYCHECK: 'paycheck', BILL: 'bill', }, @@ -5188,12 +5190,16 @@ const CONST = { REPORT: 'report', }, ACTION_TYPES: { - DONE: 'done', - PAID: 'paid', VIEW: 'view', REVIEW: 'review', + DONE: 'done', + PAID: 'paid', + }, + BULK_ACTION_TYPES: { + EXPORT: 'export', HOLD: 'hold', UNHOLD: 'unhold', + DELETE: 'delete', }, TRANSACTION_TYPE: { CASH: 'cash', @@ -5224,15 +5230,6 @@ const CONST = { ACTION: 'action', TAX_AMOUNT: 'taxAmount', }, - BULK_ACTION_TYPES: { - DELETE: 'delete', - HOLD: 'hold', - UNHOLD: 'unhold', - SUBMIT: 'submit', - APPROVE: 'approve', - PAY: 'pay', - EXPORT: 'export', - }, SYNTAX_OPERATORS: { AND: 'and', OR: 'or', @@ -5365,6 +5362,10 @@ const CONST = { DATE: 'date', LIST: 'dropdown', }, + + NAVIGATION_ACTIONS: { + RESET: 'RESET', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 119477e2848e..091a6eceae9b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -320,6 +320,9 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected', + /** Onboarding error message to be displayed to the user */ + ONBOARDING_ERROR_MESSAGE: 'onboardingErrorMessage', + /** Onboarding policyID selected by the user during Onboarding flow */ ONBOARDING_POLICY_ID: 'onboardingPolicyID', @@ -451,6 +454,9 @@ const ONYXKEYS = { /** The bank account that Expensify Card payments will be reconciled against */ SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION: 'sharedNVP_expensifyCard_continuousReconciliationConnection_', + + /** If continuous reconciliation is enabled */ + SHARED_NVP_EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION: 'sharedNVP_expensifyCard_useContinuousReconciliation_', }, /** List of Form ids */ @@ -569,6 +575,10 @@ const ONYXKEYS = { SUBSCRIPTION_SIZE_FORM_DRAFT: 'subscriptionSizeFormDraft', ISSUE_NEW_EXPENSIFY_CARD_FORM: 'issueNewExpensifyCard', ISSUE_NEW_EXPENSIFY_CARD_FORM_DRAFT: 'issueNewExpensifyCardDraft', + EDIT_EXPENSIFY_CARD_NAME_FORM: 'editExpensifyCardName', + EDIT_EXPENSIFY_CARD_NAME_DRAFT_FORM: 'editExpensifyCardNameDraft', + EDIT_EXPENSIFY_CARD_LIMIT_FORM: 'editExpensifyCardLimit', + EDIT_EXPENSIFY_CARD_LIMIT_DRAFT_FORM: 'editExpensifyCardLimitDraft', SAGE_INTACCT_CREDENTIALS_FORM: 'sageIntacctCredentialsForm', SAGE_INTACCT_CREDENTIALS_FORM_DRAFT: 'sageIntacctCredentialsFormDraft', NETSUITE_CUSTOM_FIELD_FORM: 'netSuiteCustomFieldForm', @@ -646,6 +656,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.NEW_CHAT_NAME_FORM]: FormTypes.NewChatNameForm; [ONYXKEYS.FORMS.SUBSCRIPTION_SIZE_FORM]: FormTypes.SubscriptionSizeForm; [ONYXKEYS.FORMS.ISSUE_NEW_EXPENSIFY_CARD_FORM]: FormTypes.IssueNewExpensifyCardForm; + [ONYXKEYS.FORMS.EDIT_EXPENSIFY_CARD_NAME_FORM]: FormTypes.EditExpensifyCardNameForm; + [ONYXKEYS.FORMS.EDIT_EXPENSIFY_CARD_LIMIT_FORM]: FormTypes.EditExpensifyCardLimitForm; [ONYXKEYS.FORMS.SAGE_INTACCT_CREDENTIALS_FORM]: FormTypes.SageIntactCredentialsForm; [ONYXKEYS.FORMS.NETSUITE_CUSTOM_FIELD_FORM]: FormTypes.NetSuiteCustomFieldForm; [ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM]: FormTypes.NetSuiteCustomFieldForm; @@ -702,6 +714,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_EXPENSIFY_CARD_SETTINGS]: OnyxTypes.ExpensifyCardSettings; [ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST]: OnyxTypes.WorkspaceCardsList; [ONYXKEYS.COLLECTION.SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION]: OnyxTypes.BankAccount; + [ONYXKEYS.COLLECTION.SHARED_NVP_EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION]: boolean; }; type OnyxValuesMapping = { @@ -808,6 +821,7 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: string; + [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e93634e5fe80..001fba86e934 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -36,7 +36,7 @@ const ROUTES = { ALL_SETTINGS: 'all-settings', - SEARCH: { + SEARCH_CENTRAL_PANE: { route: '/search/:query', getRoute: (searchQuery: SearchQuery, queryParams?: AuthScreensParamList['Search_Central_Pane']) => { const {sortBy, sortOrder} = queryParams ?? {}; @@ -61,8 +61,8 @@ const ROUTES = { }, TRANSACTION_HOLD_REASON_RHP: { - route: 'search/:query/hold/:transactionID', - getRoute: (query: string, transactionID: string) => `search/${query}/hold/${transactionID}` as const, + route: 'search/:query/hold', + getRoute: (query: string) => `search/${query}/hold` as const, }, // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated @@ -866,6 +866,14 @@ const ROUTES = { route: 'settings/workspaces/:policyID/expensify-card/:cardID', getRoute: (policyID: string, cardID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/expensify-card/${cardID}`, backTo), }, + WORKSPACE_EXPENSIFY_CARD_NAME: { + route: 'settings/workspaces/:policyID/expensify-card/:cardID/edit/name', + getRoute: (policyID: string, cardID: string) => `settings/workspaces/${policyID}/expensify-card/${cardID}/edit/name` as const, + }, + WORKSPACE_EXPENSIFY_CARD_LIMIT: { + route: 'settings/workspaces/:policyID/expensify-card/:cardID/edit/limit', + getRoute: (policyID: string, cardID: string) => `settings/workspaces/${policyID}/expensify-card/${cardID}/edit/limit` as const, + }, WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: { route: 'settings/workspaces/:policyID/expensify-card/issue-new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/issue-new` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 3cd064089606..eddf11d95e4f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -351,7 +351,9 @@ const SCREENS = { RATE_AND_UNIT_UNIT: 'Workspace_RateAndUnit_Unit', EXPENSIFY_CARD: 'Workspace_ExpensifyCard', EXPENSIFY_CARD_DETAILS: 'Workspace_ExpensifyCard_Details', + EXPENSIFY_CARD_LIMIT: 'Workspace_ExpensifyCard_Limit', EXPENSIFY_CARD_ISSUE_NEW: 'Workspace_ExpensifyCard_New', + EXPENSIFY_CARD_NAME: 'Workspace_ExpensifyCard_Name', EXPENSIFY_CARD_BANK_ACCOUNT: 'Workspace_ExpensifyCard_BankAccount', BILLS: 'Workspace_Bills', INVOICES: 'Workspace_Invoices', diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index 15a004ba9b87..919a4a67ebc6 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; @@ -195,15 +195,15 @@ function AvatarWithImagePicker({ /** * Check if the attachment extension is allowed. */ - const isValidExtension = (image: FileObject): boolean => { + const isValidExtension = useCallback((image: FileObject): boolean => { const {fileExtension} = FileUtils.splitExtensionFromFileName(image?.name ?? ''); return CONST.AVATAR_ALLOWED_EXTENSIONS.some((extension) => extension === fileExtension.toLowerCase()); - }; + }, []); /** * Check if the attachment size is less than allowed size. */ - const isValidSize = (image: FileObject): boolean => (image?.size ?? 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE; + const isValidSize = useCallback((image: FileObject): boolean => (image?.size ?? 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE, []); /** * Check if the attachment resolution matches constraints. @@ -216,37 +216,40 @@ function AvatarWithImagePicker({ /** * Validates if an image has a valid resolution and opens an avatar crop modal */ - const showAvatarCropModal = (image: FileObject) => { - if (!isValidExtension(image)) { - setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS}); - return; - } - if (!isValidSize(image)) { - setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)}); - return; - } - - isValidResolution(image).then((isValid) => { - if (!isValid) { - setError('avatarWithImagePicker.resolutionConstraints', { - minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX, - minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX, - maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX, - maxWidthInPx: CONST.AVATAR_MAX_WIDTH_PX, - }); + const showAvatarCropModal = useCallback( + (image: FileObject) => { + if (!isValidExtension(image)) { + setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS}); + return; + } + if (!isValidSize(image)) { + setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)}); return; } - setIsAvatarCropModalOpen(true); - setError(null, {}); - setIsMenuVisible(false); - setImageData({ - uri: image.uri ?? '', - name: image.name ?? '', - type: image.type ?? '', + isValidResolution(image).then((isValid) => { + if (!isValid) { + setError('avatarWithImagePicker.resolutionConstraints', { + minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX, + minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX, + maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX, + maxWidthInPx: CONST.AVATAR_MAX_WIDTH_PX, + }); + return; + } + + setIsAvatarCropModalOpen(true); + setError(null, {}); + setIsMenuVisible(false); + setImageData({ + uri: image.uri ?? '', + name: image.name ?? '', + type: image.type ?? '', + }); }); - }); - }; + }, + [isValidExtension, isValidSize], + ); const hideAvatarCropModal = () => { setIsAvatarCropModalOpen(false); @@ -302,61 +305,26 @@ function AvatarWithImagePicker({ }); }, [isMenuVisible, windowWidth]); + const onPressAvatar = useCallback( + (openPicker: OpenPicker) => { + if (isUsingDefaultAvatar) { + openPicker({ + onPicked: showAvatarCropModal, + }); + return; + } + if (disabled && enablePreview && onViewPhotoPress) { + onViewPhotoPress(); + return; + } + setIsMenuVisible((prev) => !prev); + }, + [disabled, enablePreview, isUsingDefaultAvatar, onViewPhotoPress, showAvatarCropModal], + ); + return ( - - - { - if (disabled && enablePreview && onViewPhotoPress) { - onViewPhotoPress(); - return; - } - setIsMenuVisible((prev) => !prev); - }} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} - accessibilityLabel={translate('avatarWithImagePicker.editImage')} - disabled={isAvatarCropModalOpen || (disabled && !enablePreview)} - disabledStyle={disabledStyle} - style={[styles.pRelative, avatarStyle, type === CONST.ICON_TYPE_AVATAR && styles.alignSelfCenter]} - ref={anchorRef} - > - - {source ? ( - - ) : ( - - )} - - {!disabled && ( - - - - )} - - - setIsMenuVisible(false)} - onItemSelected={(item, index) => { - setIsMenuVisible(false); - // In order for the file picker to open dynamically, the click - // function must be called from within an event handler that was initiated - // by the user on Safari. - if (index === 0 && Browser.isSafari()) { - openPicker({ - onPicked: showAvatarCropModal, - }); - } - }} - menuItems={menuItems} - anchorPosition={shouldUseStyleUtilityForAnchorPosition ? styles.popoverMenuOffset(windowWidth) : popoverPosition} - anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}} - withoutOverlay - anchorRef={anchorRef} - /> + <> + + + onPressAvatar(openPicker)} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + accessibilityLabel={translate('avatarWithImagePicker.editImage')} + disabled={isAvatarCropModalOpen || (disabled && !enablePreview)} + disabledStyle={disabledStyle} + style={[styles.pRelative, avatarStyle, type === CONST.ICON_TYPE_AVATAR && styles.alignSelfCenter]} + ref={anchorRef} + > + + {source ? ( + + ) : ( + + )} + + {!disabled && ( + + + + )} + + + + setIsMenuVisible(false)} + onItemSelected={(item, index) => { + setIsMenuVisible(false); + // In order for the file picker to open dynamically, the click + // function must be called from within an event handler that was initiated + // by the user on Safari. + if (index === 0 && Browser.isSafari()) { + openPicker({ + onPicked: showAvatarCropModal, + }); + } + }} + menuItems={menuItems} + anchorPosition={shouldUseStyleUtilityForAnchorPosition ? styles.popoverMenuOffset(windowWidth) : popoverPosition} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}} + withoutOverlay + anchorRef={anchorRef} + /> + ); }} diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx index 5445816f067b..6da170da3a67 100644 --- a/src/components/Checkbox.tsx +++ b/src/components/Checkbox.tsx @@ -44,6 +44,9 @@ type CheckboxProps = Partial & { /** An accessibility label for the checkbox */ accessibilityLabel: string; + + /** stop propagation of the mouse down event */ + shouldStopMouseDownPropagation?: boolean; }; function Checkbox( @@ -60,6 +63,7 @@ function Checkbox( caretSize = 14, onPress, accessibilityLabel, + shouldStopMouseDownPropagation, }: CheckboxProps, ref: ForwardedRef, ) { @@ -89,7 +93,12 @@ function Checkbox( { + if (shouldStopMouseDownPropagation) { + e.stopPropagation(); + } + onMouseDown?.(e); + }} ref={ref} style={[StyleUtils.getCheckboxPressableStyle(containerBorderRadius + 2), style]} // to align outline on focus, border-radius of pressable should be 2px more than Checkbox onKeyDown={handleSpaceKey} diff --git a/src/components/Onfido/BaseOnfidoWeb.tsx b/src/components/Onfido/BaseOnfidoWeb.tsx index 703bb5a5b14e..d1c784c078bf 100644 --- a/src/components/Onfido/BaseOnfidoWeb.tsx +++ b/src/components/Onfido/BaseOnfidoWeb.tsx @@ -27,9 +27,11 @@ function initializeOnfido({sdkToken, onSuccess, onError, onUserExit, preferredLo token: sdkToken, containerId: CONST.ONFIDO.CONTAINER_ID, customUI: { - fontFamilyTitle: `${FontUtils.fontFamily.platform.EXP_NEUE}, -apple-system, serif`, - fontFamilySubtitle: `${FontUtils.fontFamily.platform.EXP_NEUE}, -apple-system, serif`, - fontFamilyBody: `${FontUtils.fontFamily.platform.EXP_NEUE}, -apple-system, serif`, + // Font styles are commented out until Onfido fixes it on their side, more info here - https://github.com/Expensify/App/issues/44570 + // For now we will use Onfido default font which is better than random serif font which it started defaulting to + // fontFamilyTitle: `${FontUtils.fontFamily.platform.EXP_NEUE}, -apple-system, serif`, + // fontFamilySubtitle: `${FontUtils.fontFamily.platform.EXP_NEUE}, -apple-system, serif`, + // fontFamilyBody: `${FontUtils.fontFamily.platform.EXP_NEUE}, -apple-system, serif`, fontSizeTitle: `${variables.fontSizeLarge}px`, fontWeightTitle: Number(FontUtils.fontWeight.bold), fontWeightSubtitle: 400, diff --git a/src/components/QRShare/index.tsx b/src/components/QRShare/index.tsx index 8468b6dde1d8..9023773e8635 100644 --- a/src/components/QRShare/index.tsx +++ b/src/components/QRShare/index.tsx @@ -7,16 +7,21 @@ import ExpensifyWordmark from '@assets/images/expensify-wordmark.svg'; import ImageSVG from '@components/ImageSVG'; import QRCode from '@components/QRCode'; import Text from '@components/Text'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import variables from '@styles/variables'; import type {QRShareHandle, QRShareProps} from './types'; function QRShare({url, title, subtitle, logo, svgLogo, svgLogoFillColor, logoBackgroundColor, logoRatio, logoMarginRatio}: QRShareProps, ref: ForwardedRef) { const styles = useThemeStyles(); const theme = useTheme(); + const {isSmallScreenWidth} = useResponsiveLayout(); + const {windowWidth} = useWindowDimensions(); + const qrCodeContainerWidth = isSmallScreenWidth ? windowWidth : variables.sideBarWidth; - const [qrCodeSize, setQrCodeSize] = useState(); + const [qrCodeSize, setQrCodeSize] = useState(qrCodeContainerWidth - styles.ph5.paddingHorizontal * 2 - variables.qrShareHorizontalPadding * 2); const svgRef = useRef(); useImperativeHandle( diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 3911780d3965..367a03e081cd 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -6,7 +6,7 @@ const defaultSearchContext = { currentSearchHash: -1, selectedTransactionIDs: [], setCurrentSearchHash: () => {}, - setSelectedTransactionIds: () => {}, + setSelectedTransactionIDs: () => {}, }; const Context = React.createContext(defaultSearchContext); @@ -27,7 +27,7 @@ function SearchContextProvider({children}: ChildrenProps) { [searchContextData], ); - const setSelectedTransactionIds = useCallback( + const setSelectedTransactionIDs = useCallback( (selectedTransactionIDs: string[]) => { setSearchContextData({ ...searchContextData, @@ -41,9 +41,9 @@ function SearchContextProvider({children}: ChildrenProps) { () => ({ ...searchContextData, setCurrentSearchHash, - setSelectedTransactionIds, + setSelectedTransactionIDs, }), - [searchContextData, setCurrentSearchHash, setSelectedTransactionIds], + [searchContextData, setCurrentSearchHash, setSelectedTransactionIDs], ); return {children}; diff --git a/src/components/Search/SearchListWithHeader.tsx b/src/components/Search/SearchListWithHeader.tsx index 87a39fdd504b..bd4b843bbd60 100644 --- a/src/components/Search/SearchListWithHeader.tsx +++ b/src/components/Search/SearchListWithHeader.tsx @@ -26,7 +26,7 @@ type SearchListWithHeaderProps = Omit selectedTransactions[id].canDelete); + const shouldShowHoldOption = !isOffline && selectedTransactionsKeys.every((id) => selectedTransactions[id].canHold); - if (itemsToDelete.length > 0) { + if (shouldShowHoldOption) { options.push({ - icon: Expensicons.Trashcan, - text: translate('search.bulkActions.delete'), - value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE, + icon: Expensicons.Stopwatch, + text: translate('search.bulkActions.hold'), + value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD, shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { @@ -99,18 +104,23 @@ function SearchPageHeader({ return; } - onSelectDeleteOption?.(itemsToDelete); + clearSelectedItems?.(); + if (isMobileSelectionModeActive) { + setIsMobileSelectionModeActive?.(false); + } + setSelectedTransactionIDs(selectedTransactionsKeys); + Navigation.navigate(ROUTES.TRANSACTION_HOLD_REASON_RHP.getRoute(query)); }, }); } - const itemsToHold = selectedTransactionsKeys.filter((id) => selectedTransactions[id].action === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + const shouldShowUnholdOption = !isOffline && selectedTransactionsKeys.every((id) => selectedTransactions[id].canUnhold); - if (itemsToHold.length > 0) { + if (shouldShowUnholdOption) { options.push({ icon: Expensicons.Stopwatch, - text: translate('search.bulkActions.hold'), - value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD, + text: translate('search.bulkActions.unhold'), + value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD, shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { @@ -122,18 +132,18 @@ function SearchPageHeader({ if (isMobileSelectionModeActive) { setIsMobileSelectionModeActive?.(false); } - SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, ''); + SearchActions.unholdMoneyRequestOnSearch(hash, selectedTransactionsKeys); }, }); } - const itemsToUnhold = selectedTransactionsKeys.filter((id) => selectedTransactions[id].action === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD); + const shouldShowDeleteOption = !isOffline && selectedTransactionsKeys.every((id) => selectedTransactions[id].canDelete); - if (itemsToUnhold.length > 0) { + if (shouldShowDeleteOption) { options.push({ - icon: Expensicons.Stopwatch, - text: translate('search.bulkActions.unhold'), - value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD, + icon: Expensicons.Trashcan, + text: translate('search.bulkActions.delete'), + value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE, shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { @@ -141,11 +151,7 @@ function SearchPageHeader({ return; } - clearSelectedItems?.(); - if (isMobileSelectionModeActive) { - setIsMobileSelectionModeActive?.(false); - } - SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold); + onSelectDeleteOption?.(selectedTransactionsKeys); }, }); } @@ -188,6 +194,7 @@ function SearchPageHeader({ activeWorkspaceID, selectedReports, styles.textWrap, + setSelectedTransactionIDs, ]); if (isSmallScreenWidth) { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 2238b0f49855..232ae841d4ff 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -9,6 +9,12 @@ type SelectedTransactionInfo = { /** If the transaction can be deleted */ canDelete: boolean; + /** If the transaction can be put on hold */ + canHold: boolean; + + /** If the transaction can be removed from hold */ + canUnhold: boolean; + /** The action that can be performed for the transaction */ action: string; }; @@ -23,7 +29,7 @@ type SearchContext = { currentSearchHash: number; selectedTransactionIDs: string[]; setCurrentSearchHash: (hash: number) => void; - setSelectedTransactionIds: (selectedTransactionIds: string[]) => void; + setSelectedTransactionIDs: (selectedTransactionIds: string[]) => void; }; type ASTNode = { diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index 7888a8b26114..f535c0342e55 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -1,19 +1,15 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Badge from '@components/Badge'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; -import {useSearchContext} from '@components/Search/SearchContext'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; -import * as SearchActions from '@userActions/Search'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ROUTES from '@src/ROUTES'; import type {SearchTransactionAction} from '@src/types/onyx/SearchResults'; const actionTranslationsMap: Record = { @@ -21,13 +17,10 @@ const actionTranslationsMap: Record = review: 'common.review', done: 'common.done', paid: 'iou.settledExpensify', - hold: 'iou.hold', - unhold: 'iou.unhold', }; type ActionCellProps = { action?: SearchTransactionAction; - transactionID?: string; isLargeScreenWidth?: boolean; isSelected?: boolean; goToItem: () => void; @@ -35,34 +28,12 @@ type ActionCellProps = { parentAction?: string; }; -function ActionCell({ - action = CONST.SEARCH.ACTION_TYPES.VIEW, - transactionID, - isLargeScreenWidth = true, - isSelected = false, - goToItem, - isChildListItem = false, - parentAction = '', -}: ActionCellProps) { +function ActionCell({action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true, isSelected = false, goToItem, isChildListItem = false, parentAction = ''}: ActionCellProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {currentSearchHash} = useSearchContext(); - - const onButtonPress = useCallback(() => { - if (!transactionID) { - return; - } - - if (action === CONST.SEARCH.ACTION_TYPES.HOLD) { - Navigation.navigate(ROUTES.TRANSACTION_HOLD_REASON_RHP.getRoute(CONST.SEARCH.TAB.ALL, transactionID)); - } else if (action === CONST.SEARCH.ACTION_TYPES.UNHOLD) { - SearchActions.unholdMoneyRequestOnSearch(currentSearchHash, [transactionID]); - } - }, [action, currentSearchHash, transactionID]); - const text = translate(actionTranslationsMap[action]); const shouldUseViewAction = action === CONST.SEARCH.ACTION_TYPES.VIEW || (parentAction === CONST.SEARCH.ACTION_TYPES.PAID && action === CONST.SEARCH.ACTION_TYPES.PAID); @@ -119,16 +90,6 @@ function ActionCell({ /> ); } - return ( - - ); } ActionCell.displayName = 'ActionCell'; diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx index cfffa2629f43..60f278998f0d 100644 --- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx +++ b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx @@ -19,7 +19,6 @@ type ExpenseItemHeaderNarrowProps = { participantFromDisplayName: string; participantToDisplayName: string; action?: SearchTransactionAction; - transactionID?: string; onButtonPress: () => void; canSelectMultiple?: boolean; isSelected?: boolean; @@ -41,7 +40,6 @@ function ExpenseItemHeaderNarrow({ isDisabled, handleCheckboxPress, text, - transactionID, }: ExpenseItemHeaderNarrowProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -92,7 +90,6 @@ function ExpenseItemHeaderNarrow({ diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index e6358698d414..fb503960cd5e 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -156,6 +156,7 @@ function ReportListItem({ containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!item.isSelected, !!item.isDisabled)]} disabled={!!isDisabled || item.isDisabledCheckbox} accessibilityLabel={item.text ?? ''} + shouldStopMouseDownPropagation style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, !isLargeScreenWidth && styles.mr3]} /> )} diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index e4087666c8b0..4f83814374ba 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -268,7 +268,6 @@ function TransactionListItemRow({ isDisabled={item.isDisabled} isDisabledCheckbox={item.isDisabledCheckbox} handleCheckboxPress={onCheckboxPress} - transactionID={item.transactionID} /> )} @@ -430,7 +429,6 @@ function TransactionListItemRow({ `If you change this card’s limit to ${limit}, new transactions will be declined until you approve more expenses on the card.`, + monthlyLimitWarning: (limit: string) => `If you change this card’s limit to ${limit}, new transactions will be declined until next month.`, + fixedLimitWarning: (limit: string) => `If you change this card’s limit to ${limit}, new transactions will be declined.`, }, categories: { deleteCategories: 'Delete categories', @@ -2773,6 +2779,7 @@ export default { subtitle: "Report fields apply to all spend and can be helpful when you'd like to prompt for extra information.", disableReportFields: 'Disable report fields', disableReportFieldsConfirmation: 'Are you sure? Text and date fields will be deleted, and lists will be disabled.', + importedFromAccountingSoftware: 'The report fields below are imported from your', textType: 'Text', dateType: 'Date', dropdownType: 'List', @@ -3161,6 +3168,10 @@ export default { reimbursedReports: 'Sync reimbursed reports', cardReconciliation: 'Card reconciliation', reconciliationAccount: 'Reconciliation account', + continuousReconciliation: 'Continuous Reconciliation', + saveHoursOnReconciliation: + 'Save hours on reconciliation each accounting period by having Expensify continuously reconcile Expensify Card statements and settlements on your behalf.', + enableContinuousReconciliation: 'In order to enable Continuous Reconciliation, please enable ', chooseReconciliationAccount: { chooseBankAccount: 'Choose the bank account that your Expensify Card payments will be reconciled against.', accountMatches: 'Make sure this account matches your ', diff --git a/src/languages/es.ts b/src/languages/es.ts index 693a15300890..df149124594e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1487,6 +1487,7 @@ export default { title: '¿Qué quieres hacer hoy?', errorSelection: 'Por favor selecciona una opción para continuar.', errorContinue: 'Por favor, haz click en continuar para configurar tu cuenta.', + errorBackButton: 'Por favor, finaliza las preguntas de configuración para empezar a utilizar la aplicación.', [CONST.ONBOARDING_CHOICES.EMPLOYER]: 'Cobrar de mi empresa', [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: 'Gestionar los gastos de mi equipo', [CONST.ONBOARDING_CHOICES.PERSONAL_SPEND]: 'Controlar y presupuestar gastos', @@ -2711,6 +2712,12 @@ export default { virtual: 'Virtual', physical: 'Física', deactivate: 'Desactivar tarjeta', + changeCardLimit: 'Modificar el límite de la tarjeta', + changeLimit: 'Modificar límite', + smartLimitWarning: (limit: string) => + `Si cambias el límite de esta tarjeta a ${limit}, las nuevas transacciones serán rechazadas hasta que apruebes antiguos gastos de la tarjeta.`, + monthlyLimitWarning: (limit: string) => `Si cambias el límite de esta tarjeta a ${limit}, las nuevas transacciones serán rechazadas hasta el próximo mes.`, + fixedLimitWarning: (limit: string) => `Si cambias el límite de esta tarjeta a ${limit}, se rechazarán las nuevas transacciones.`, }, categories: { deleteCategories: 'Eliminar categorías', @@ -2822,6 +2829,7 @@ export default { subtitle: 'Los campos de informe se aplican a todos los gastos y pueden ser útiles cuando quieras solicitar información adicional.', disableReportFields: 'Desactivar campos de informe', disableReportFieldsConfirmation: 'Estás seguro? Se eliminarán los campos de texto y fecha y se desactivarán las listas.', + importedFromAccountingSoftware: 'Campos de informes importadas desde', textType: 'Texto', dateType: 'Fecha', dropdownType: 'Lista', @@ -3150,6 +3158,10 @@ export default { reimbursedReports: 'Sincronizar informes reembolsados', cardReconciliation: 'Conciliación de tarjetas', reconciliationAccount: 'Cuenta de conciliación', + continuousReconciliation: 'Conciliación continua', + saveHoursOnReconciliation: + 'Ahorra horas de conciliación en cada período contable haciendo que Expensify concilie continuamente los extractos y liquidaciones de la Tarjeta Expensify en tu nombre.', + enableContinuousReconciliation: 'Para activar la Conciliación Continua, activa la ', chooseReconciliationAccount: { chooseBankAccount: 'Elige la cuenta bancaria con la que se conciliarán los pagos de tu Tarjeta Expensify.', accountMatches: 'Asegúrate de que esta cuenta coincide con ', diff --git a/src/libs/API/parameters/OpenPolicyReportFieldsPageParams.ts b/src/libs/API/parameters/OpenPolicyReportFieldsPageParams.ts new file mode 100644 index 000000000000..80d5d1f1aa07 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyReportFieldsPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyReportFieldsPageParams = { + policyID: string; +}; + +export default OpenPolicyReportFieldsPageParams; diff --git a/src/libs/API/parameters/UpdateExpensifyCardLimitParams.ts b/src/libs/API/parameters/UpdateExpensifyCardLimitParams.ts new file mode 100644 index 000000000000..d64094185a79 --- /dev/null +++ b/src/libs/API/parameters/UpdateExpensifyCardLimitParams.ts @@ -0,0 +1,7 @@ +type UpdateExpensifyCardLimitParams = { + authToken: string; + cardID: number; + limit: number; +}; + +export default UpdateExpensifyCardLimitParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 5564853f1866..53705e1f502d 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -196,6 +196,7 @@ export type {default as CreatePolicyTaxParams} from './CreatePolicyTaxParams'; export type {default as OpenPolicyWorkflowsPageParams} from './OpenPolicyWorkflowsPageParams'; export type {default as OpenPolicyDistanceRatesPageParams} from './OpenPolicyDistanceRatesPageParams'; export type {default as OpenPolicyTaxesPageParams} from './OpenPolicyTaxesPageParams'; +export type {default as OpenPolicyReportFieldsPageParams} from './OpenPolicyReportFieldsPageParams'; export type {default as EnablePolicyTaxesParams} from './EnablePolicyTaxesParams'; export type {default as OpenPolicyMoreFeaturesPageParams} from './OpenPolicyMoreFeaturesPageParams'; export type {default as CreatePolicyDistanceRateParams} from './CreatePolicyDistanceRateParams'; @@ -263,3 +264,4 @@ export type {default as UpdateSageIntacctGenericTypeParams} from './UpdateSageIn export type {default as UpdateNetSuiteCustomersJobsParams} from './UpdateNetSuiteCustomersJobsParams'; export type {default as CopyExistingPolicyConnectionParams} from './CopyExistingPolicyConnectionParams'; export type {default as ExportSearchItemsToCSVParams} from './ExportSearchItemsToCSVParams'; +export type {default as UpdateExpensifyCardLimitParams} from './UpdateExpensifyCardLimitParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 0578ba632589..d94a1f3f9292 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -31,6 +31,7 @@ const WRITE_COMMANDS = { REPORT_VIRTUAL_EXPENSIFY_CARD_FRAUD: 'ReportVirtualExpensifyCardFraud', REQUEST_REPLACEMENT_EXPENSIFY_CARD: 'RequestReplacementExpensifyCard', ACTIVATE_PHYSICAL_EXPENSIFY_CARD: 'ActivatePhysicalExpensifyCard', + UPDATE_EXPENSIFY_CARD_LIMIT: 'UpdateExpensifyCardLimit', CHRONOS_REMOVE_OOO_EVENT: 'Chronos_RemoveOOOEvent', MAKE_DEFAULT_PAYMENT_METHOD: 'MakeDefaultPaymentMethod', ADD_PAYMENT_CARD: 'AddPaymentCard', @@ -338,6 +339,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.REPORT_VIRTUAL_EXPENSIFY_CARD_FRAUD]: Parameters.ReportVirtualExpensifyCardFraudParams; [WRITE_COMMANDS.REQUEST_REPLACEMENT_EXPENSIFY_CARD]: Parameters.RequestReplacementExpensifyCardParams; [WRITE_COMMANDS.ACTIVATE_PHYSICAL_EXPENSIFY_CARD]: Parameters.ActivatePhysicalExpensifyCardParams; + [WRITE_COMMANDS.UPDATE_EXPENSIFY_CARD_LIMIT]: Parameters.UpdateExpensifyCardLimitParams; [WRITE_COMMANDS.MAKE_DEFAULT_PAYMENT_METHOD]: Parameters.MakeDefaultPaymentMethodParams; [WRITE_COMMANDS.ADD_PAYMENT_CARD]: Parameters.AddPaymentCardParams; [WRITE_COMMANDS.DELETE_PAYMENT_CARD]: Parameters.DeletePaymentCardParams; @@ -678,6 +680,7 @@ const READ_COMMANDS = { OPEN_POLICY_CATEGORIES_PAGE: 'OpenPolicyCategoriesPage', OPEN_POLICY_TAGS_PAGE: 'OpenPolicyTagsPage', OPEN_POLICY_TAXES_PAGE: 'OpenPolicyTaxesPage', + OPEN_POLICY_REPORT_FIELDS_PAGE: 'OpenPolicyReportFieldsPage', OPEN_POLICY_EXPENSIFY_CARDS_PAGE: 'OpenPolicyExpensifyCardsPage', OPEN_WORKSPACE_INVITE_PAGE: 'OpenWorkspaceInvitePage', OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', @@ -732,6 +735,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_CATEGORIES_PAGE]: Parameters.OpenPolicyCategoriesPageParams; [READ_COMMANDS.OPEN_POLICY_TAGS_PAGE]: Parameters.OpenPolicyTagsPageParams; [READ_COMMANDS.OPEN_POLICY_TAXES_PAGE]: Parameters.OpenPolicyTaxesPageParams; + [READ_COMMANDS.OPEN_POLICY_REPORT_FIELDS_PAGE]: Parameters.OpenPolicyReportFieldsPageParams; [READ_COMMANDS.OPEN_WORKSPACE_INVITE_PAGE]: Parameters.OpenWorkspaceInvitePageParams; [READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST]: Parameters.OpenDraftWorkspaceRequestParams; [READ_COMMANDS.OPEN_POLICY_WORKFLOWS_PAGE]: Parameters.OpenPolicyWorkflowsPageParams; diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index a826c668be12..a8ef8a90dffe 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -174,7 +174,7 @@ function xhr(command: string, data: Record, type: RequestType = } function cancelPendingRequests(command: AbortCommand = ABORT_COMMANDS.All) { - const controller = abortControllerMap.get(command) ?? abortControllerMap.get(ABORT_COMMANDS.All); + const controller = abortControllerMap.get(command); controller?.abort(); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 5489f6b7149d..514e93962333 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -416,6 +416,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/card/issueNew/IssueNewCardPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_BANK_ACCOUNT]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_DETAILS]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage').default, + [SCREENS.WORKSPACE.EXPENSIFY_CARD_NAME]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceEditCardNamePage').default, + [SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceEditCardLimitPage').default, [SCREENS.SETTINGS.SAVE_THE_WORLD]: () => require('../../../../pages/TeachersUnite/SaveTheWorldPage').default, [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_PAYMENT_CURRENCY]: () => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default, [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY]: () => require('../../../../pages/settings/Subscription/PaymentCard/ChangeBillingCurrency').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx index 29a2205b2e37..61adcd77da76 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx @@ -4,9 +4,11 @@ import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen'; +import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector'; import OnboardingModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/OnboardingModalNavigatorScreenOptions'; import Navigation from '@libs/Navigation/Navigation'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; @@ -26,15 +28,11 @@ function OnboardingModalNavigator() { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useOnboardingLayout(); const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: (onboarding) => { - // onboarding is an array for old accounts and accounts created from olddot - if (Array.isArray(onboarding)) { - return true; - } - return onboarding?.hasCompletedGuidedSetupFlow; - }, + selector: hasCompletedGuidedSetupFlowSelector, }); + useDisableModalDismissOnEscape(); + useEffect(() => { if (!hasCompletedGuidedSetupFlow) { return; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index 1ee25b45d331..b09529fceaa2 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -12,7 +12,9 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@libs/actions/Session'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import Navigation from '@libs/Navigation/Navigation'; +import linkingConfig from '@libs/Navigation/linkingConfig'; +import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import type {RootStackParamList, State} from '@libs/Navigation/types'; import {isCentralPaneName} from '@libs/NavigationUtils'; import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; @@ -54,7 +56,13 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { return; } - Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)}); + Welcome.isOnboardingFlowCompleted({ + onNotCompleted: () => { + const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config); + navigationRef.resetRoot(adaptedState); + }, + }); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isLoadingApp]); @@ -96,7 +104,7 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { if (selectedTab === SCREENS.SEARCH.BOTTOM_TAB) { return; } - interceptAnonymousUser(() => Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL))); + interceptAnonymousUser(() => Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute(CONST.SEARCH.TAB.ALL))); }} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.search')} diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts index a1768df5e0d6..5b3cefb63a2d 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts @@ -1,13 +1,16 @@ -import type {RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; -import {getPathFromState, StackRouter} from '@react-navigation/native'; +import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; +import {findFocusedRoute, getPathFromState, StackRouter} from '@react-navigation/native'; import type {ParamListBase} from '@react-navigation/routers'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import * as Localize from '@libs/Localize'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import linkingConfig from '@libs/Navigation/linkingConfig'; import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; -import {isCentralPaneName} from '@libs/NavigationUtils'; +import {isCentralPaneName, isOnboardingFlowName} from '@libs/NavigationUtils'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import type {ResponsiveStackNavigatorRouterOptions} from './types'; @@ -97,6 +100,23 @@ function compareAndAdaptState(state: StackNavigationState) { } } +function shouldPreventReset(state: StackNavigationState, action: CommonActions.Action | StackActionType) { + if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) { + return false; + } + const currentFocusedRoute = findFocusedRoute(state); + const targetFocusedRoute = findFocusedRoute(action?.payload); + + // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen + if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) { + Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton')); + // We reset the URL as the browser sets it in a way that doesn't match the navigation state + // eslint-disable-next-line no-restricted-globals + history.replaceState({}, '', getPathFromState(state, linkingConfig.config)); + return true; + } +} + function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) { const stackRouter = StackRouter(options); @@ -107,6 +127,12 @@ function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) { const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList}); return state; }, + getStateForAction(state: StackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) { + if (shouldPreventReset(state, action)) { + return state; + } + return stackRouter.getStateForAction(state, action, configOptions); + }, }; } diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 6f803ae1e497..10301e0a99b3 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -144,10 +144,6 @@ function getActiveRoute(): string { return ''; } - if (currentRoute?.path) { - return currentRoute.path; - } - const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config); if (routeFromState) { diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 63792be4f79f..e6da17cf42a5 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -1,6 +1,7 @@ import type {NavigationState} from '@react-navigation/native'; import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native'; import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import {useOnyx} from 'react-native-onyx'; import HybridAppMiddleware from '@components/HybridAppMiddleware'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; @@ -8,11 +9,14 @@ import useCurrentReportID from '@hooks/useCurrentReportID'; import useTheme from '@hooks/useTheme'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {FSPage} from '@libs/Fullstory'; +import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector'; import Log from '@libs/Log'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; import AppNavigator from './AppNavigator'; import getPolicyIDFromState from './getPolicyIDFromState'; import linkingConfig from './linkingConfig'; @@ -76,26 +80,44 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N const currentReportIDValue = useCurrentReportID(); const {isSmallScreenWidth} = useWindowDimensions(); const {setActiveWorkspaceID} = useActiveWorkspace(); + const [user] = useOnyx(ONYXKEYS.USER); - const initialState = useMemo( - () => { - if (!lastVisitedPath) { - return undefined; - } + const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + selector: hasCompletedGuidedSetupFlowSelector, + }); - const path = initialUrl ? getPathFromURL(initialUrl) : null; - - // For non-nullable paths we don't want to set initial state - if (path) { - return; - } + const initialState = useMemo(() => { + if (!user || user.isFromPublicDomain) { + return; + } - const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); + // If the user haven't completed the flow, we want to always redirect them to the onboarding flow. + // We also make sure that the user is authenticated. + if (!hasCompletedGuidedSetupFlow && authenticated) { + const {adaptedState} = getAdaptedStateFromPath(ROUTES.ONBOARDING_ROOT, linkingConfig.config); return adaptedState; - }, + } + + // If there is no lastVisitedPath, we can do early return. We won't modify the default behavior. + if (!lastVisitedPath) { + return undefined; + } + + const path = initialUrl ? getPathFromURL(initialUrl) : null; + + // If the user opens the root of app "/" it will be parsed to empty string "". + // If the path is defined and different that empty string we don't want to modify the default behavior. + if (path) { + return; + } + + // Otherwise we want to redirect the user to the last visited path. + const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); + return adaptedState; + + // The initialState value is relevant only on the first render. // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [], - ); + }, []); // https://reactnavigation.org/docs/themes const navigationTheme = useMemo( diff --git a/src/libs/Navigation/getTopmostFullScreenRoute.ts b/src/libs/Navigation/getTopmostFullScreenRoute.ts index 25c74ea0ce6b..fcc28ce76926 100644 --- a/src/libs/Navigation/getTopmostFullScreenRoute.ts +++ b/src/libs/Navigation/getTopmostFullScreenRoute.ts @@ -13,18 +13,16 @@ function getTopmostFullScreenRoute(state: State): Navigation return; } - if (!!topmostFullScreenRoute.params && 'screen' in topmostFullScreenRoute.params) { - return {name: topmostFullScreenRoute.params.screen as FullScreenName, params: topmostFullScreenRoute.params.params}; - } + if (topmostFullScreenRoute.state) { + // There will be at least one route in the fullscreen navigator. + const {name, params} = topmostFullScreenRoute.state.routes.at(-1) as NavigationPartialRoute; - if (!topmostFullScreenRoute.state) { - return; + return {name, params}; } - // There will be at least one route in the fullscreen navigator. - const {name, params} = topmostFullScreenRoute.state.routes.at(-1) as NavigationPartialRoute; - - return {name, params}; + if (!!topmostFullScreenRoute.params && 'screen' in topmostFullScreenRoute.params) { + return {name: topmostFullScreenRoute.params.screen as FullScreenName, params: topmostFullScreenRoute.params.params}; + } } export default getTopmostFullScreenRoute; diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index fdecf83c6c9d..f6145d3ddfa6 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -156,7 +156,13 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE, SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE, ], - [SCREENS.WORKSPACE.EXPENSIFY_CARD]: [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW, SCREENS.WORKSPACE.EXPENSIFY_CARD_BANK_ACCOUNT, SCREENS.WORKSPACE.EXPENSIFY_CARD_DETAILS], + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: [ + SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW, + SCREENS.WORKSPACE.EXPENSIFY_CARD_BANK_ACCOUNT, + SCREENS.WORKSPACE.EXPENSIFY_CARD_DETAILS, + SCREENS.WORKSPACE.EXPENSIFY_CARD_NAME, + SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT, + ], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index b838c09da90e..1e532e91b894 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1,9 +1,11 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import type {LinkingOptions} from '@react-navigation/native'; import type {RootStackParamList} from '@navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; +import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; +import type {RouteConfig} from './createNormalizedConfigs'; +import createNormalizedConfigs from './createNormalizedConfigs'; // Moved to a separate file to avoid cyclic dependencies. const config: LinkingOptions['config'] = { @@ -51,7 +53,7 @@ const config: LinkingOptions['config'] = { exact: true, }, [SCREENS.SETTINGS.WORKSPACES]: ROUTES.SETTINGS_WORKSPACES, - [SCREENS.SEARCH.CENTRAL_PANE]: ROUTES.SEARCH.route, + [SCREENS.SEARCH.CENTRAL_PANE]: ROUTES.SEARCH_CENTRAL_PANE.route, [SCREENS.SETTINGS.SAVE_THE_WORLD]: ROUTES.SETTINGS_SAVE_THE_WORLD, [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: ROUTES.SETTINGS_SUBSCRIPTION, @@ -463,9 +465,15 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.SHARE]: { path: ROUTES.WORKSPACE_PROFILE_SHARE.route, }, + [SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT]: { + path: ROUTES.WORKSPACE_EXPENSIFY_CARD_LIMIT.route, + }, [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW]: { path: ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.route, }, + [SCREENS.WORKSPACE.EXPENSIFY_CARD_NAME]: { + path: ROUTES.WORKSPACE_EXPENSIFY_CARD_NAME.route, + }, [SCREENS.WORKSPACE.EXPENSIFY_CARD_BANK_ACCOUNT]: { path: ROUTES.WORKSPACE_EXPENSIFY_CARD_BANK_ACCOUNT.route, }, @@ -1043,4 +1051,28 @@ const config: LinkingOptions['config'] = { }, }; +const normalizedConfigs = Object.keys(config.screens) + .map((key) => + createNormalizedConfigs( + key, + config.screens, + [], + config.initialRouteName + ? [ + { + initialRouteName: config.initialRouteName, + parentScreens: [], + }, + ] + : [], + [], + ), + ) + .flat() + .reduce((acc, route) => { + acc[route.screen as Screen] = route; + return acc; + }, {} as Record); + +export {normalizedConfigs}; export default config; diff --git a/src/libs/Navigation/linkingConfig/createNormalizedConfigs.ts b/src/libs/Navigation/linkingConfig/createNormalizedConfigs.ts new file mode 100644 index 000000000000..9e21c7d073cb --- /dev/null +++ b/src/libs/Navigation/linkingConfig/createNormalizedConfigs.ts @@ -0,0 +1,135 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +/* eslint-disable @typescript-eslint/default-param-last */ + +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + +/* eslint-disable no-param-reassign */ + +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* eslint-disable @typescript-eslint/ban-types */ +// THOSE FUNCTIONS ARE COPIED FROM react-navigation/core IN ORDER TO AVOID PATCHING +// THAT'S THE REASON WHY ESLINT IS DISABLED +import type {PathConfigMap} from '@react-navigation/native'; + +type ParseConfig = Record any>; + +type RouteConfig = { + screen: string; + regex?: RegExp; + path: string; + pattern: string; + routeNames: string[]; + parse?: ParseConfig; +}; + +type InitialRouteConfig = { + initialRouteName: string; + parentScreens: string[]; +}; + +const joinPaths = (...paths: string[]): string => + ([] as string[]) + .concat(...paths.map((p) => p.split('/'))) + .filter(Boolean) + .join('/'); + +const createConfigItem = (screen: string, routeNames: string[], pattern: string, path: string, parse?: ParseConfig): RouteConfig => { + // Normalize pattern to remove any leading, trailing slashes, duplicate slashes etc. + pattern = pattern.split('/').filter(Boolean).join('/'); + + const regex = pattern + ? new RegExp( + `^(${pattern + .split('/') + .map((it) => { + if (it.startsWith(':')) { + return `(([^/]+\\/)${it.endsWith('?') ? '?' : ''})`; + } + + return `${it === '*' ? '.*' : escape(it)}\\/`; + }) + .join('')})`, + ) + : undefined; + + return { + screen, + regex, + pattern, + path, + // The routeNames array is mutated, so copy it to keep the current state + routeNames: [...routeNames], + parse, + }; +}; + +const createNormalizedConfigs = ( + screen: string, + routeConfig: PathConfigMap, + routeNames: string[] = [], + initials: InitialRouteConfig[], + parentScreens: string[], + parentPattern?: string, +): RouteConfig[] => { + const configs: RouteConfig[] = []; + + routeNames.push(screen); + + parentScreens.push(screen); + + // @ts-expect-error: we can't strongly typecheck this for now + const config = routeConfig[screen]; + + if (typeof config === 'string') { + // If a string is specified as the value of the key(e.g. Foo: '/path'), use it as the pattern + const pattern = parentPattern ? joinPaths(parentPattern, config) : config; + + configs.push(createConfigItem(screen, routeNames, pattern, config)); + } else if (typeof config === 'object') { + let pattern: string | undefined; + + // if an object is specified as the value (e.g. Foo: { ... }), + // it can have `path` property and + // it could have `screens` prop which has nested configs + if (typeof config.path === 'string') { + if (config.exact && config.path === undefined) { + throw new Error("A 'path' needs to be specified when specifying 'exact: true'. If you don't want this screen in the URL, specify it as empty string, e.g. `path: ''`."); + } + + pattern = config.exact !== true ? joinPaths(parentPattern || '', config.path || '') : config.path || ''; + + configs.push(createConfigItem(screen, routeNames, pattern!, config.path, config.parse)); + } + + if (config.screens) { + // property `initialRouteName` without `screens` has no purpose + if (config.initialRouteName) { + initials.push({ + initialRouteName: config.initialRouteName, + parentScreens, + }); + } + + Object.keys(config.screens).forEach((nestedConfig) => { + const result = createNormalizedConfigs(nestedConfig, config.screens as PathConfigMap, routeNames, initials, [...parentScreens], pattern ?? parentPattern); + + configs.push(...result); + }); + } + } + + routeNames.pop(); + + return configs; +}; + +export type {RouteConfig}; +export default createNormalizedConfigs; diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 547b766e1ce5..b6822be4ffaa 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -1,5 +1,6 @@ import type {NavigationState, PartialState, Route} from '@react-navigation/native'; import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; +import pick from 'lodash/pick'; import type {TupleToUnion} from 'type-fest'; import {isAnonymousUser} from '@libs/actions/Session'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; @@ -10,9 +11,10 @@ import * as ReportConnection from '@libs/ReportConnection'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; import CENTRAL_PANE_TO_RHP_MAPPING from './CENTRAL_PANE_TO_RHP_MAPPING'; -import config from './config'; +import config, {normalizedConfigs} from './config'; import extractPolicyIDsFromState from './extractPolicyIDsFromState'; import FULL_SCREEN_TO_RHP_MAPPING from './FULL_SCREEN_TO_RHP_MAPPING'; import getMatchingBottomTabRouteForState from './getMatchingBottomTabRouteForState'; @@ -94,6 +96,14 @@ function createFullScreenNavigator(route?: NavigationPartialRoute | undefined { // Check for backTo param. One screen with different backTo value may need diferent screens visible under the overlay. @@ -127,18 +137,18 @@ function getMatchingRootRouteForRHPRoute(route: NavigationPartialRoute): Navigat // Check for CentralPaneNavigator for (const [centralPaneName, RHPNames] of Object.entries(CENTRAL_PANE_TO_RHP_MAPPING)) { if (RHPNames.includes(route.name)) { - const params = {...route.params}; - if (centralPaneName === SCREENS.SEARCH.CENTRAL_PANE) { - delete (params as Record)?.reportID; - } - return {name: centralPaneName as CentralPaneName, params}; + const paramsFromRoute = getParamsFromRoute(centralPaneName); + + return {name: centralPaneName as CentralPaneName, params: pick(route.params, paramsFromRoute)}; } } // Check for FullScreenNavigator for (const [fullScreenName, RHPNames] of Object.entries(FULL_SCREEN_TO_RHP_MAPPING)) { if (RHPNames.includes(route.name)) { - return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: route.params}); + const paramsFromRoute = getParamsFromRoute(fullScreenName); + + return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: pick(route.params, paramsFromRoute)}); } } diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts index 19626a400b9d..78b550f36303 100644 --- a/src/libs/Navigation/switchPolicyID.ts +++ b/src/libs/Navigation/switchPolicyID.ts @@ -84,7 +84,7 @@ export default function switchPolicyID(navigation: NavigationContainerRef>; const action: StackNavigationAction = getActionFromState(stateFromPath, linkingConfig.config); diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 24af6e52b9f5..77f3c9aed7a3 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -668,6 +668,14 @@ type SettingsNavigatorParamList = { cardID: string; backTo?: Routes; }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD_NAME]: { + policyID: string; + cardID: string; + }; + [SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT]: { + policyID: string; + cardID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { @@ -1262,6 +1270,8 @@ type FullScreenName = keyof FullScreenNavigatorParamList; type CentralPaneName = keyof CentralPaneScreensParamList; +type OnboardingFlowName = keyof OnboardingModalNavigatorParamList; + type SwitchPolicyIDParams = { policyID?: string; route?: Routes; @@ -1292,6 +1302,7 @@ export type { NewChatNavigatorParamList, NewTaskNavigatorParamList, OnboardingModalNavigatorParamList, + OnboardingFlowName, ParticipantsNavigatorParamList, PrivateNotesNavigatorParamList, ProfileNavigatorParamList, diff --git a/src/libs/NavigationUtils.ts b/src/libs/NavigationUtils.ts index 572310e8218b..ea1710b9931c 100644 --- a/src/libs/NavigationUtils.ts +++ b/src/libs/NavigationUtils.ts @@ -1,7 +1,7 @@ import cloneDeep from 'lodash/cloneDeep'; import SCREENS from '@src/SCREENS'; import getTopmostBottomTabRoute from './Navigation/getTopmostBottomTabRoute'; -import type {CentralPaneName, RootStackParamList, State} from './Navigation/types'; +import type {CentralPaneName, OnboardingFlowName, RootStackParamList, State} from './Navigation/types'; const CENTRAL_PANE_SCREEN_NAMES = new Set([ SCREENS.SETTINGS.WORKSPACES, @@ -17,6 +17,8 @@ const CENTRAL_PANE_SCREEN_NAMES = new Set([ SCREENS.REPORT, ]); +const ONBOARDING_SCREEN_NAMES = new Set([SCREENS.ONBOARDING.PERSONAL_DETAILS, SCREENS.ONBOARDING.PURPOSE, SCREENS.ONBOARDING.WORK, SCREENS.ONBOARDING_MODAL.ONBOARDING]); + const removePolicyIDParamFromState = (state: State) => { const stateCopy = cloneDeep(state); const bottomTabRoute = getTopmostBottomTabRoute(stateCopy); @@ -33,4 +35,12 @@ function isCentralPaneName(screen: string | undefined): screen is CentralPaneNam return CENTRAL_PANE_SCREEN_NAMES.has(screen as CentralPaneName); } -export {isCentralPaneName, removePolicyIDParamFromState}; +function isOnboardingFlowName(screen: string | undefined): screen is OnboardingFlowName { + if (!screen) { + return false; + } + + return ONBOARDING_SCREEN_NAMES.has(screen as OnboardingFlowName); +} + +export {isCentralPaneName, removePolicyIDParamFromState, isOnboardingFlowName}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index c552c5521219..d7614139927f 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -223,7 +223,7 @@ type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: bo type FilterOptionsConfig = Pick< GetOptionsConfig, 'sortByReportTypeInSearch' | 'canInviteUser' | 'betas' | 'selectedOptions' | 'excludeUnknownUsers' | 'excludeLogins' | 'maxRecentReportsToShow' -> & {preferChatroomsOverThreads?: boolean}; +> & {preferChatroomsOverThreads?: boolean; includeChatRoomsByParticipants?: boolean}; type HasText = { text?: string; @@ -2473,7 +2473,15 @@ function getFirstKeyForList(data?: Option[] | null) { * Filters options based on the search input value */ function filterOptions(options: Options, searchInputValue: string, config?: FilterOptionsConfig): Options { - const {sortByReportTypeInSearch = false, canInviteUser = true, betas = [], maxRecentReportsToShow = 0, excludeLogins = [], preferChatroomsOverThreads = false} = config ?? {}; + const { + sortByReportTypeInSearch = false, + canInviteUser = true, + betas = [], + maxRecentReportsToShow = 0, + excludeLogins = [], + preferChatroomsOverThreads = false, + includeChatRoomsByParticipants = false, + } = config ?? {}; if (searchInputValue.trim() === '' && maxRecentReportsToShow > 0) { return {...options, recentReports: options.recentReports.slice(0, maxRecentReportsToShow)}; } @@ -2533,6 +2541,10 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt if (item.subtitle) { values.push(item.subtitle); } + + if (includeChatRoomsByParticipants) { + values = values.concat(getParticipantsLoginsArray(item)); + } } if (!item.isChatRoom) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5509b8f700b8..1922d304a0c5 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -48,7 +48,7 @@ import type {Status} from '@src/types/onyx/PersonalDetails'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report'; import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; -import type {Comment, Receipt, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; +import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; import AccountUtils from './AccountUtils'; @@ -152,6 +152,7 @@ type OptimisticExpenseReport = Pick< | 'notificationPreference' | 'parentReportID' | 'lastVisibleActionCreated' + | 'parentReportActionID' >; type OptimisticIOUReportAction = Pick< @@ -1287,12 +1288,9 @@ function isClosedExpenseReportWithNoExpenses(report: OnyxEntry): boolean /** * Whether the provided report is an archived room */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars function isArchivedRoom(report: OnyxInputOrEntry, reportNameValuePairs?: OnyxInputOrEntry): boolean { - if (reportNameValuePairs) { - return reportNameValuePairs.private_isArchived; - } - - return report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED; + return !!report?.private_isArchived; } /** @@ -4118,7 +4116,6 @@ function buildOptimisticIOUReportAction( iouReportID = '', isSettlingUp = false, isSendMoneyFlow = false, - receipt: Receipt = {}, isOwnPolicyExpenseChat = false, created = DateUtils.getDBTime(), linkedExpenseReportAction?: OnyxEntry, @@ -4132,7 +4129,6 @@ function buildOptimisticIOUReportAction( IOUTransactionID: transactionID, IOUReportID, type, - whisperedTo: [CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING].some((value) => value === receipt?.state) ? [currentUserAccountID ?? -1] : [], }; if (type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { @@ -4343,7 +4339,6 @@ function buildOptimisticReportPreview( childReportID?: string, ): ReportAction { const hasReceipt = TransactionUtils.hasReceipt(transaction); - const isReceiptBeingScanned = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); const message = getReportPreviewMessage(iouReport); const created = DateUtils.getDBTime(); return { @@ -4353,7 +4348,6 @@ function buildOptimisticReportPreview( pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, originalMessage: { linkedReportID: iouReport?.reportID, - whisperedTo: isReceiptBeingScanned ? [currentUserAccountID ?? -1] : [], }, message: [ { @@ -5203,7 +5197,6 @@ function buildOptimisticMoneyRequestEntities( paymentType?: PaymentMethodType, isSettlingUp = false, isSendMoneyFlow = false, - receipt: Receipt = {}, isOwnPolicyExpenseChat = false, isPersonalTrackingExpense?: boolean, existingTransactionThreadReportID?: string, @@ -5226,7 +5219,6 @@ function buildOptimisticMoneyRequestEntities( isPersonalTrackingExpense ? '0' : iouReport.reportID, isSettlingUp, isSendMoneyFlow, - receipt, isOwnPolicyExpenseChat, iouActionCreationTime, linkedTrackedExpenseReportAction, @@ -5468,7 +5460,7 @@ function shouldReportBeInOptionList({ return false; } - if (report?.type === CONST.REPORT.TYPE.PAYCHECK || report?.type === CONST.REPORT.TYPE.BILL) { + if ((Object.values(CONST.REPORT.UNSUPPORTED_TYPE) as string[]).includes(report?.type ?? '')) { return false; } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 37b9f3b881d9..2d609b7279a4 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -105,6 +105,9 @@ function getOrderedReportIDs( if (!report) { return; } + if ((Object.values(CONST.REPORT.UNSUPPORTED_TYPE) as string[]).includes(report?.type ?? '')) { + return; + } const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {}; const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, betas ?? [], transactionViolations); diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 2869363c3ad3..752fadc7b2c1 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -574,8 +574,8 @@ function hasPendingUI(transaction: OnyxEntry, transactionViolations /** * Check if the transaction has a defined route */ -function hasRoute(transaction: OnyxEntry, isDistanceRequestType: boolean): boolean { - return !!transaction?.routes?.route0?.geometry?.coordinates || (isDistanceRequestType && !!transaction?.comment?.customUnit?.quantity); +function hasRoute(transaction: OnyxEntry, isDistanceRequestType?: boolean): boolean { + return !!transaction?.routes?.route0?.geometry?.coordinates || (!!isDistanceRequestType && !!transaction?.comment?.customUnit?.quantity); } function getAllReportTransactions(reportID?: string, transactions?: OnyxCollection): Transaction[] { diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index b23493c08e8e..6ba0bfc0729a 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -1,8 +1,16 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; -import type {ActivatePhysicalExpensifyCardParams, ReportVirtualExpensifyCardFraudParams, RequestReplacementExpensifyCardParams, RevealExpensifyCardDetailsParams} from '@libs/API/parameters'; +import type { + ActivatePhysicalExpensifyCardParams, + ReportVirtualExpensifyCardFraudParams, + RequestReplacementExpensifyCardParams, + RevealExpensifyCardDetailsParams, + UpdateExpensifyCardLimitParams, +} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as NetworkStore from '@libs/Network/NetworkStore'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ExpensifyCardDetails, IssueNewCardData, IssueNewCardStep} from '@src/types/onyx/Card'; @@ -207,6 +215,66 @@ function clearIssueNewCardFlow() { }); } +function updateExpensifyCardLimit(policyID: string, cardID: number, newLimit: number, oldLimit?: number) { + const authToken = NetworkStore.getAuthToken(); + + if (!authToken) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${policyID}_${CONST.EXPENSIFY_CARD.BANK}`, + value: { + [cardID]: { + nameValuePairs: { + limit: newLimit, + }, + isLoading: true, + errors: null, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${policyID}_${CONST.EXPENSIFY_CARD.BANK}`, + value: { + [cardID]: { + isLoading: false, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${policyID}_${CONST.EXPENSIFY_CARD.BANK}`, + value: { + [cardID]: { + nameValuePairs: { + limit: oldLimit, + }, + isLoading: false, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const parameters: UpdateExpensifyCardLimitParams = { + authToken, + cardID, + limit: newLimit, + }; + + API.write(WRITE_COMMANDS.UPDATE_EXPENSIFY_CARD_LIMIT, parameters, {optimisticData, successData, failureData}); +} + export { requestReplacementExpensifyCard, activatePhysicalExpensifyCard, @@ -215,5 +283,6 @@ export { revealVirtualCardDetails, setIssueNewCardStepAndData, clearIssueNewCardFlow, + updateExpensifyCardLimit, }; export type {ReplacementReason}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 5ae6cd2c14b2..6c8f88649e5e 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1841,6 +1841,9 @@ function getSendInvoiceInformation( } // STEP 5: Build optimistic reportActions. + const reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, optimisticInvoiceReport, trimmedComment, optimisticTransaction); + optimisticInvoiceReport.parentReportActionID = reportPreviewAction.reportActionID; + chatReport.lastVisibleActionCreated = reportPreviewAction.created; const [optimisticCreatedActionForChat, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = ReportUtils.buildOptimisticMoneyRequestEntities( optimisticInvoiceReport, @@ -1854,10 +1857,8 @@ function getSendInvoiceInformation( undefined, false, false, - receiptObject, false, ); - const reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, optimisticInvoiceReport, trimmedComment, optimisticTransaction); // STEP 6: Build Onyx Data const [optimisticData, successData, failureData] = buildOnyxDataForInvoice( @@ -2034,7 +2035,6 @@ function getMoneyRequestInformation( undefined, false, false, - receiptObject, false, undefined, linkedTrackedExpenseReportAction?.childReportID, @@ -2260,7 +2260,6 @@ function getTrackExpenseInformation( undefined, false, false, - receiptObject, false, !shouldUseMoneyReport, linkedTrackedExpenseReportAction?.childReportID, @@ -3924,7 +3923,6 @@ function createSplitsAndOnyxData( '', false, false, - {}, isOwnPolicyExpenseChat, ); @@ -4505,7 +4503,6 @@ function startSplitBill({ '', false, false, - receiptObject, isOwnPolicyExpenseChat, ); diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index e6d2e35dd8c1..c5be4b6074a7 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -260,6 +260,7 @@ function deleteWorkspace(policyID: string, policyName: string) { (report) => report?.policyID === policyID && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report)), ); const finallyData: OnyxUpdate[] = []; + const currentTime = DateUtils.getDBTime(); reportsToArchive.forEach((report) => { const {reportID, ownerAccountID} = report ?? {}; optimisticData.push({ @@ -270,6 +271,8 @@ function deleteWorkspace(policyID: string, policyName: string) { statusNum: CONST.REPORT.STATUS_NUM.CLOSED, oldPolicyName: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name ?? '', policyName: '', + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: currentTime, }, }); diff --git a/src/libs/actions/Policy/ReportField.ts b/src/libs/actions/Policy/ReportField.ts index 27b67c9fe686..3538a348bcca 100644 --- a/src/libs/actions/Policy/ReportField.ts +++ b/src/libs/actions/Policy/ReportField.ts @@ -7,11 +7,13 @@ import type { CreateWorkspaceReportFieldParams, DeletePolicyReportField, EnableWorkspaceReportFieldListValueParams, + OpenPolicyReportFieldsPageParams, RemoveWorkspaceReportFieldListValueParams, UpdateWorkspaceReportFieldInitialValueParams, } from '@libs/API/parameters'; -import {WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; +import Log from '@libs/Log'; import * as ReportUtils from '@libs/ReportUtils'; import * as WorkspaceReportFieldUtils from '@libs/WorkspaceReportFieldUtils'; import CONST from '@src/CONST'; @@ -69,6 +71,19 @@ Onyx.connect({ }, }); +function openPolicyReportFieldsPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyReportFieldsPage invalid params', {policyID}); + return; + } + + const params: OpenPolicyReportFieldsPageParams = { + policyID, + }; + + API.read(READ_COMMANDS.OPEN_POLICY_REPORT_FIELDS_PAGE, params); +} + /** * Sets the initial form values for the workspace report fields form. */ @@ -540,6 +555,7 @@ export { deleteReportFields, updateReportFieldInitialValue, updateReportFieldListValueEnabled, + openPolicyReportFieldsPage, addReportFieldListValue, removeReportFieldListValue, }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index eaa54c7e08db..1bbc81c2939f 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1,3 +1,4 @@ +import {findFocusedRoute} from '@react-navigation/native'; import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz'; import {Str} from 'expensify-common'; import isEmpty from 'lodash/isEmpty'; @@ -55,11 +56,14 @@ import {prepareDraftComment} from '@libs/DraftCommentUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; import * as Environment from '@libs/Environment/Environment'; import * as ErrorUtils from '@libs/ErrorUtils'; +import hasCompletedGuidedSetupFlowSelector from '@libs/hasCompletedGuidedSetupFlowSelector'; +import HttpUtils from '@libs/HttpUtils'; import isPublicScreenRoute from '@libs/isPublicScreenRoute'; import * as Localize from '@libs/Localize'; import Log from '@libs/Log'; import {registerPaginationConfig} from '@libs/Middleware/Pagination'; -import Navigation from '@libs/Navigation/Navigation'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import {isOnboardingFlowName} from '@libs/NavigationUtils'; import type {NetworkStatus} from '@libs/NetworkConnection'; import LocalNotification from '@libs/Notification/LocalNotification'; import Parser from '@libs/Parser'; @@ -2579,28 +2583,47 @@ function openReportFromDeepLink(url: string) { // Navigate to the report after sign-in/sign-up. InteractionManager.runAfterInteractions(() => { Session.waitForUserSignIn().then(() => { - Navigation.waitForProtectedRoutes().then(() => { - if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) { - Session.signOutAndRedirectToSignIn(true); - return; - } - - // We don't want to navigate to the exitTo route when creating a new workspace from a deep link, - // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate, - // which is already called when AuthScreens mounts. - if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) { - return; - } - - if (shouldSkipDeepLinkNavigation(route)) { - return; - } - - if (isAuthenticated) { - return; - } - - Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH); + Onyx.connect({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (onboarding) => { + Navigation.waitForProtectedRoutes().then(() => { + if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) { + Session.signOutAndRedirectToSignIn(true); + return; + } + + // We don't want to navigate to the exitTo route when creating a new workspace from a deep link, + // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate, + // which is already called when AuthScreens mounts. + if (new URL(url).searchParams.get('exitTo') === ROUTES.WORKSPACE_NEW) { + return; + } + + if (shouldSkipDeepLinkNavigation(route)) { + return; + } + + const state = navigationRef.getRootState(); + const currentFocusedRoute = findFocusedRoute(state); + const hasCompletedGuidedSetupFlow = hasCompletedGuidedSetupFlowSelector(onboarding); + + // We need skip deeplinking if the user hasn't completed the guided setup flow. + if (!hasCompletedGuidedSetupFlow) { + return; + } + + if (isOnboardingFlowName(currentFocusedRoute?.name)) { + Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton')); + return; + } + + if (isAuthenticated) { + return; + } + + Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH); + }); + }, }); }); }); @@ -3591,6 +3614,11 @@ function searchForReports(searchInput: string, policyID?: string) { const searchForRoomToMentionParams: SearchForRoomsToMentionParams = {query: searchInput, policyID: policyID ?? '-1'}; const searchForReportsParams: SearchForReportsParams = {searchInput, canCancel: true}; + // We want to cancel all pending SearchForReports API calls before making another one + if (!policyID) { + HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS); + } + API.read(policyID ? READ_COMMANDS.SEARCH_FOR_ROOMS_TO_MENTION : READ_COMMANDS.SEARCH_FOR_REPORTS, policyID ? searchForRoomToMentionParams : searchForReportsParams, { successData, failureData, diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts index 0c3224ee37d9..19797caee1a8 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome.ts @@ -11,7 +11,8 @@ import ROUTES from '@src/ROUTES'; import type Onboarding from '@src/types/onyx/Onboarding'; import type TryNewDot from '@src/types/onyx/TryNewDot'; -let onboarding: Onboarding | [] | undefined; +type OnboardingData = Onboarding | [] | undefined; + let isLoadingReportData = true; let tryNewDotData: TryNewDot | undefined; @@ -30,8 +31,8 @@ let isServerDataReadyPromise = new Promise((resolve) => { resolveIsReadyPromise = resolve; }); -let resolveOnboardingFlowStatus: (value?: Promise) => void | undefined; -let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { +let resolveOnboardingFlowStatus: (value?: OnboardingData) => void; +let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { resolveOnboardingFlowStatus = resolve; }); @@ -45,7 +46,7 @@ function onServerDataReady(): Promise { } function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOnboardingFlowProps) { - isOnboardingFlowStatusKnownPromise.then(() => { + isOnboardingFlowStatusKnownPromise.then((onboarding) => { if (Array.isArray(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { return; } @@ -102,23 +103,7 @@ function handleHybridAppOnboarding() { } /** - * Check that a few requests have completed so that the welcome action can proceed: - * - * - Whether we are a first time new expensify user - * - Whether we have loaded all policies the server knows about - * - Whether we have loaded all reports the server knows about - * Check if onboarding data is ready in order to check if the user has completed onboarding or not - */ -function checkOnboardingDataReady() { - if (onboarding === undefined) { - return; - } - - resolveOnboardingFlowStatus?.(); -} - -/** - * Check if user dismissed modal and if report data are loaded + * Check if report data are loaded */ function checkServerDataReady() { if (isLoadingReportData) { @@ -143,6 +128,10 @@ function setOnboardingPurposeSelected(value: OnboardingPurposeType) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } +function setOnboardingErrorMessage(value: string) { + Onyx.set(ONYXKEYS.ONBOARDING_ERROR_MESSAGE, value ?? null); +} + function setOnboardingAdminsChatReportID(adminsChatReportID?: string) { Onyx.set(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID, adminsChatReportID ?? null); } @@ -186,9 +175,7 @@ Onyx.connect({ return; } - onboarding = value; - - checkOnboardingDataReady(); + resolveOnboardingFlowStatus(value); }, }); @@ -213,10 +200,9 @@ function resetAllChecks() { isServerDataReadyPromise = new Promise((resolve) => { resolveIsReadyPromise = resolve; }); - isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { + isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { resolveOnboardingFlowStatus = resolve; }); - onboarding = undefined; isLoadingReportData = true; } @@ -229,4 +215,5 @@ export { setOnboardingPolicyID, completeHybridAppOnboarding, handleHybridAppOnboarding, + setOnboardingErrorMessage, }; diff --git a/src/libs/hasCompletedGuidedSetupFlowSelector.ts b/src/libs/hasCompletedGuidedSetupFlowSelector.ts new file mode 100644 index 000000000000..83cde0a0be8c --- /dev/null +++ b/src/libs/hasCompletedGuidedSetupFlowSelector.ts @@ -0,0 +1,12 @@ +import type {OnyxValue} from 'react-native-onyx'; +import type ONYXKEYS from '@src/ONYXKEYS'; + +function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue): boolean { + // onboarding is an array for old accounts and accounts created from olddot + if (Array.isArray(onboarding)) { + return true; + } + return onboarding?.hasCompletedGuidedSetupFlow ?? false; +} + +export default hasCompletedGuidedSetupFlowSelector; diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index f5bd14ed7aa1..52e2d817e6db 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; @@ -12,7 +12,6 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; -import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape'; import useLocalize from '@hooks/useLocalize'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -46,7 +45,9 @@ function BaseOnboardingPersonalDetails({ const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false); const {accountID} = useSession(); - useDisableModalDismissOnEscape(); + useEffect(() => { + Welcome.setOnboardingErrorMessage(''); + }, []); const completeEngagement = useCallback( (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 03a4b790bc5f..7304c1822ae9 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -2,7 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {ScrollView} from 'react-native-gesture-handler'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; @@ -13,7 +13,6 @@ import MenuItemList from '@components/MenuItemList'; import OfflineIndicator from '@components/OfflineIndicator'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import Text from '@components/Text'; -import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape'; import useLocalize from '@hooks/useLocalize'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useTheme from '@hooks/useTheme'; @@ -28,7 +27,8 @@ import type {OnboardingPurposeType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {BaseOnboardingPurposeOnyxProps, BaseOnboardingPurposeProps} from './types'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {BaseOnboardingPurposeProps} from './types'; const menuIcons = { [CONST.ONBOARDING_CHOICES.EMPLOYER]: Illustrations.ReceiptUpload, @@ -38,15 +38,15 @@ const menuIcons = { [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: Illustrations.Binoculars, }; -function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, onboardingPurposeSelected}: BaseOnboardingPurposeProps) { +function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight}: BaseOnboardingPurposeProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useOnboardingLayout(); const [selectedPurpose, setSelectedPurpose] = useState(undefined); const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); const theme = useTheme(); - - useDisableModalDismissOnEscape(); + const [onboardingPurposeSelected, onboardingPurposeSelectedResult] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); + const [onboardingErrorMessage, onboardingErrorMessageResult] = useOnyx(ONYXKEYS.ONBOARDING_ERROR_MESSAGE); const PurposeFooterInstance = ; @@ -83,8 +83,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS); }, [selectedPurpose]); - const [errorMessage, setErrorMessage] = useState(''); - const menuItems: MenuItemProps[] = Object.values(CONST.ONBOARDING_CHOICES).map((choice) => { const translationKey = `onboarding.purpose.${choice}` as const; const isSelected = selectedPurpose === choice; @@ -103,7 +101,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on numberOfLinesTitle: 0, onPress: () => { Welcome.setOnboardingPurposeSelected(choice); - setErrorMessage(''); + Welcome.setOnboardingErrorMessage(''); }, }; }); @@ -111,15 +109,18 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on const handleOuterClick = useCallback(() => { if (!selectedPurpose) { - setErrorMessage(translate('onboarding.purpose.errorSelection')); + Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorSelection')); } else { - setErrorMessage(translate('onboarding.purpose.errorContinue')); + Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorContinue')); } - }, [selectedPurpose, setErrorMessage, translate]); + }, [selectedPurpose, translate]); const onboardingLocalRef = useRef(null); useImperativeHandle(isFocused ? OnboardingRefManager.ref : onboardingLocalRef, () => ({handleOuterClick}), [handleOuterClick]); + if (isLoadingOnyxValue(onboardingPurposeSelectedResult, onboardingErrorMessageResult)) { + return null; + } return ( {({safeAreaPaddingBottomStyle}) => ( @@ -148,14 +149,14 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on buttonText={translate('common.continue')} onSubmit={() => { if (!selectedPurpose) { - setErrorMessage(translate('onboarding.purpose.errorSelection')); + Welcome.setOnboardingErrorMessage(translate('onboarding.purpose.errorSelection')); return; } - setErrorMessage(''); + Welcome.setOnboardingErrorMessage(''); saveAndNavigate(); }} - message={errorMessage} - isAlertVisible={!!errorMessage} + message={onboardingErrorMessage} + isAlertVisible={!!onboardingErrorMessage} containerStyles={[styles.w100, styles.mb5, styles.mh0, paddingHorizontal]} /> @@ -166,10 +167,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, on BaseOnboardingPurpose.displayName = 'BaseOnboardingPurpose'; -export default withOnyx({ - onboardingPurposeSelected: { - key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, - }, -})(BaseOnboardingPurpose); +export default BaseOnboardingPurpose; export type {BaseOnboardingPurposeProps}; diff --git a/src/pages/OnboardingPurpose/types.ts b/src/pages/OnboardingPurpose/types.ts index 8c8f11503f1a..17970dbab9a6 100644 --- a/src/pages/OnboardingPurpose/types.ts +++ b/src/pages/OnboardingPurpose/types.ts @@ -1,20 +1,11 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import type {OnboardingPurposeType} from '@src/CONST'; - type OnboardingPurposeProps = Record; -type BaseOnboardingPurposeOnyxProps = { - /** Saved onboarding purpose selected by the user */ - onboardingPurposeSelected: OnyxEntry; -}; - -type BaseOnboardingPurposeProps = OnboardingPurposeProps & - BaseOnboardingPurposeOnyxProps & { - /* Whether to use native styles tailored for native devices */ - shouldUseNativeStyles: boolean; +type BaseOnboardingPurposeProps = OnboardingPurposeProps & { + /* Whether to use native styles tailored for native devices */ + shouldUseNativeStyles: boolean; - /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ - shouldEnableMaxHeight: boolean; - }; + /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ + shouldEnableMaxHeight: boolean; +}; -export type {BaseOnboardingPurposeOnyxProps, BaseOnboardingPurposeProps, OnboardingPurposeProps}; +export type {BaseOnboardingPurposeProps, OnboardingPurposeProps}; diff --git a/src/pages/OnboardingWork/BaseOnboardingWork.tsx b/src/pages/OnboardingWork/BaseOnboardingWork.tsx index 9b8824300d30..14f9223f6c67 100644 --- a/src/pages/OnboardingWork/BaseOnboardingWork.tsx +++ b/src/pages/OnboardingWork/BaseOnboardingWork.tsx @@ -9,7 +9,6 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import OfflineIndicator from '@components/OfflineIndicator'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import useDisableModalDismissOnEscape from '@hooks/useDisableModalDismissOnEscape'; import useLocalize from '@hooks/useLocalize'; import useOnboardingLayout from '@hooks/useOnboardingLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -33,8 +32,6 @@ function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, o const {isSmallScreenWidth} = useWindowDimensions(); const {shouldUseNarrowLayout} = useOnboardingLayout(); - useDisableModalDismissOnEscape(); - const completeEngagement = useCallback( (values: FormOnyxValues<'onboardingWorkForm'>) => { if (!onboardingPurposeSelected) { diff --git a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx index 52156c05a873..94462c92f17c 100644 --- a/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx +++ b/src/pages/RestrictedAction/Workspace/WorkspaceOwnerRestrictedAction.tsx @@ -21,7 +21,7 @@ function WorkspaceOwnerRestrictedAction() { const addPaymentCard = () => { Navigation.closeRHPFlow(); - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION); + Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD); }; return ( diff --git a/src/pages/Search/SearchFilters.tsx b/src/pages/Search/SearchFilters.tsx index 0728743c1875..26c22f7203b5 100644 --- a/src/pages/Search/SearchFilters.tsx +++ b/src/pages/Search/SearchFilters.tsx @@ -37,25 +37,25 @@ function SearchFilters({query}: SearchFiltersProps) { title: translate('common.expenses'), query: CONST.SEARCH.TAB.ALL, icon: Expensicons.Receipt, - route: ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL), + route: ROUTES.SEARCH_CENTRAL_PANE.getRoute(CONST.SEARCH.TAB.ALL), }, { title: translate('common.shared'), query: CONST.SEARCH.TAB.SHARED, icon: Expensicons.Send, - route: ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.SHARED), + route: ROUTES.SEARCH_CENTRAL_PANE.getRoute(CONST.SEARCH.TAB.SHARED), }, { title: translate('common.drafts'), query: CONST.SEARCH.TAB.DRAFTS, icon: Expensicons.Pencil, - route: ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.DRAFTS), + route: ROUTES.SEARCH_CENTRAL_PANE.getRoute(CONST.SEARCH.TAB.DRAFTS), }, { title: translate('common.finished'), query: CONST.SEARCH.TAB.FINISHED, icon: Expensicons.CheckCircle, - route: ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.FINISHED), + route: ROUTES.SEARCH_CENTRAL_PANE.getRoute(CONST.SEARCH.TAB.FINISHED), }, ]; const activeItemIndex = filterItems.findIndex((item) => item.query === query); diff --git a/src/pages/Search/SearchHoldReasonPage.tsx b/src/pages/Search/SearchHoldReasonPage.tsx index 2506f6bf2453..50044e784a63 100644 --- a/src/pages/Search/SearchHoldReasonPage.tsx +++ b/src/pages/Search/SearchHoldReasonPage.tsx @@ -28,12 +28,11 @@ type SearchHoldReasonPageProps = { function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { const {translate} = useLocalize(); - const {currentSearchHash} = useSearchContext(); - const {transactionID, backTo} = route.params; + const {currentSearchHash, selectedTransactionIDs} = useSearchContext(); + const {backTo} = route.params; const onSubmit = (values: FormOnyxValues) => { - SearchActions.holdMoneyRequestOnSearch(currentSearchHash, [transactionID], values.comment); - + SearchActions.holdMoneyRequestOnSearch(currentSearchHash, selectedTransactionIDs, values.comment); Navigation.goBack(); }; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 6e734fd835d2..558830da983b 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -23,7 +23,7 @@ function SearchPage({route}: SearchPageProps) { const query = rawQuery as SearchQuery; const isValidQuery = Object.values(CONST.SEARCH.TAB).includes(query); - const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL)); + const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_CENTRAL_PANE.getRoute(CONST.SEARCH.TAB.ALL)); // On small screens this page is not displayed, the configuration is in the file: src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx // To avoid calling hooks in the Search component when this page isn't visible, we return null here. diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx index 965917bc5ebe..3729d754fb61 100644 --- a/src/pages/Search/SearchPageBottomTab.tsx +++ b/src/pages/Search/SearchPageBottomTab.tsx @@ -48,7 +48,7 @@ function SearchPageBottomTab() { const isValidQuery = Object.values(CONST.SEARCH.TAB).includes(query); - const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL)); + const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_CENTRAL_PANE.getRoute(CONST.SEARCH.TAB.ALL)); return ( { // Don't update if there is a reportID in the params already @@ -173,6 +172,8 @@ function ReportScreen({ return; } + const lastAccessedReportID = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, !!route.params.openOnAdminRoom, activeWorkspaceID)?.reportID; + // It's possible that reports aren't fully loaded yet // in that case the reportID is undefined if (!lastAccessedReportID) { @@ -181,7 +182,7 @@ function ReportScreen({ Log.info(`[ReportScreen] no reportID found in params, setting it to lastAccessedReportID: ${lastAccessedReportID}`); navigation.setParams({reportID: lastAccessedReportID}); - }, [lastAccessedReportID, activeWorkspaceID, canUseDefaultRooms, navigation, route]); + }, [activeWorkspaceID, canUseDefaultRooms, navigation, route, finishedLoadingApp]); /** * Create a lightweight Report so as to keep the re-rendering as light as possible by @@ -227,6 +228,8 @@ function ReportScreen({ visibility: reportOnyx?.visibility, oldPolicyName: reportOnyx?.oldPolicyName, policyName: reportOnyx?.policyName, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: reportOnyx?.private_isArchived, isOptimisticReport: reportOnyx?.isOptimisticReport, lastMentionedTime: reportOnyx?.lastMentionedTime, avatarUrl: reportOnyx?.avatarUrl, @@ -268,6 +271,7 @@ function ReportScreen({ reportOnyx?.visibility, reportOnyx?.oldPolicyName, reportOnyx?.policyName, + reportOnyx?.private_isArchived, reportOnyx?.isOptimisticReport, reportOnyx?.lastMentionedTime, reportOnyx?.avatarUrl, diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 10ec9bb72dff..b5ee3607f359 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -408,7 +408,7 @@ function ReportActionItem({ text: 'subscription.cardSection.addCardButton', key: `${action.reportActionID}-actionableAddPaymentCard-submit`, onPress: () => { - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION); + Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD); }, isMediumSized: true, isPrimary: true, diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 6cab01cfc3a3..7af6170da53d 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -199,7 +199,6 @@ function ReportActionsView({ report.reportID, false, false, - {}, false, DateUtils.subtractMillisecondsFromDateTime(actions[actions.length - 1].created, 1), ) as OnyxTypes.ReportAction; diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 4d2f946195da..66f7ec28fa01 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -94,13 +94,12 @@ function IOURequestStepDistance({ const isLoadingRoute = transaction?.comment?.isLoading ?? false; const isLoading = transaction?.isLoading ?? false; const hasRouteError = !!transaction?.errorFields?.route; - const hasRoute = TransactionUtils.hasRoute(transaction, true); + const hasRoute = TransactionUtils.hasRoute(transaction); const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); const previousValidatedWaypoints = usePrevious(validatedWaypoints); const haveValidatedWaypointsChanged = !isEqual(previousValidatedWaypoints, validatedWaypoints); const isRouteAbsentWithoutErrors = !hasRoute && !hasRouteError; - const isEmptyCoordinates = !transaction?.routes?.route0?.geometry?.coordinates?.length; - const shouldFetchRoute = (isEmptyCoordinates || isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged) && !isLoadingRoute && Object.keys(validatedWaypoints).length > 1; + const shouldFetchRoute = (isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged) && !isLoadingRoute && Object.keys(validatedWaypoints).length > 1; const [shouldShowAtLeastTwoDifferentWaypointsError, setShouldShowAtLeastTwoDifferentWaypointsError] = useState(false); const isWaypointEmpty = (waypoint?: Waypoint) => { if (!waypoint) { diff --git a/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx b/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx index fca23d1e1cb9..f0169b410257 100644 --- a/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx +++ b/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx @@ -27,30 +27,52 @@ function BaseShareLogList({onAttachLogToReport}: BaseShareLogListProps) { const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); const {options, areOptionsInitialized} = useOptionsList(); - const searchOptions = useMemo(() => { + const defaultOptions = useMemo(() => { if (!areOptionsInitialized) { return { recentReports: [], personalDetails: [], - userToInvite: undefined, + userToInvite: null, + currentUserOption: null, + categoryOptions: [], + tagOptions: [], + taxRatesOptions: [], headerMessage: '', }; } - const { - recentReports: localRecentReports, - personalDetails: localPersonalDetails, - userToInvite: localUserToInvite, - } = OptionsListUtils.getShareLogOptions(options, debouncedSearchValue.trim(), betas ?? []); + const shareLogOptions = OptionsListUtils.getShareLogOptions(options, '', betas ?? []); - const header = OptionsListUtils.getHeaderMessage((localRecentReports?.length || 0) + (localPersonalDetails?.length || 0) !== 0, !!localUserToInvite, debouncedSearchValue); + const header = OptionsListUtils.getHeaderMessage( + (shareLogOptions.recentReports.length || 0) + (shareLogOptions.personalDetails.length || 0) !== 0, + !!shareLogOptions.userToInvite, + '', + ); return { - recentReports: localRecentReports, - personalDetails: localPersonalDetails, - userToInvite: localUserToInvite, + ...shareLogOptions, headerMessage: header, }; - }, [areOptionsInitialized, options, debouncedSearchValue, betas]); + }, [areOptionsInitialized, options, betas]); + + const searchOptions = useMemo(() => { + if (debouncedSearchValue.trim() === '') { + return defaultOptions; + } + + const filteredOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchValue, { + includeChatRoomsByParticipants: true, + preferChatroomsOverThreads: true, + sortByReportTypeInSearch: true, + }); + + const headerMessage = OptionsListUtils.getHeaderMessage( + (filteredOptions.recentReports?.length || 0) + (filteredOptions.personalDetails?.length || 0) !== 0, + !!filteredOptions.userToInvite, + debouncedSearchValue.trim(), + ); + + return {...filteredOptions, headerMessage}; + }, [debouncedSearchValue, defaultOptions]); const sections = useMemo(() => { const sectionsList = []; diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index 4cc160fc13b2..bd3eb3a07fa7 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -148,7 +148,7 @@ function CardSection() { title={translate('subscription.cardSection.viewPaymentHistory')} titleStyle={styles.textStrong} style={styles.mt5} - onPress={() => Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL))} + onPress={() => Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute(CONST.SEARCH.TAB.ALL))} hoverAndPressStyle={styles.hoveredComponentBG} /> )} diff --git a/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx b/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx index 27f31f587944..f6304b7e439a 100644 --- a/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx +++ b/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx @@ -1,19 +1,64 @@ -import React from 'react'; +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback} from 'react'; +import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; -function CardReconciliationPage({policy}: WithPolicyConnectionsProps) { +type CardReconciliationPageProps = WithPolicyConnectionsProps & StackScreenProps; + +function CardReconciliationPage({policy, route}: CardReconciliationPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [reconciliationConnection] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION}${policy?.id}`); + const [isContinuousReconciliationOn] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION}${policy?.id}`); + const policyID = policy?.id ?? '-1'; + const {connection} = route.params; + const autoSync = !!policy?.connections?.[connection]?.config?.autoSync.enabled; + + // eslint-disable-next-line rulesdir/prefer-early-return + const toggleContinuousReconciliation = () => { + if (!isContinuousReconciliationOn) { + Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS.getRoute(policyID, connection)); + } + // TODO: add API call when it's supported https://github.com/Expensify/Expensify/issues/407834 + }; + + const navigateToAdvancedSettings = useCallback(() => { + switch (connection) { + case CONST.POLICY.CONNECTIONS.NAME.QBO: + Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)); + break; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_ADVANCED.getRoute(policyID)); + break; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); + break; + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED.getRoute(policyID)); + break; + default: + break; + } + }, [connection, policyID]); return ( - + + + {!autoSync && ( + + {translate('workspace.accounting.enableContinuousReconciliation')} + + {translate('workspace.accounting.autoSync').toLowerCase()} + {' '} + {translate('common.conjunctionFor')} {CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connection]} + + )} + {!!reconciliationConnection && ( + + )} + ); diff --git a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx new file mode 100644 index 000000000000..6dc6bb2a78ca --- /dev/null +++ b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx @@ -0,0 +1,154 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useMemo, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import AmountForm from '@components/AmountForm'; +import ConfirmModal from '@components/ConfirmModal'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import Navigation from '@navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Card from '@userActions/Card'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/EditExpensifyCardLimitForm'; + +type ConfirmationWarningTranslationPaths = 'workspace.expensifyCard.smartLimitWarning' | 'workspace.expensifyCard.monthlyLimitWarning' | 'workspace.expensifyCard.fixedLimitWarning'; + +// TODO: remove when Onyx data is available +const mockedCard = { + accountID: 885646, + availableSpend: 1000, + nameValuePairs: { + cardTitle: 'Test 1', + isVirtual: true, + limit: 2000, + limitType: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART, + }, + lastFourPAN: '1234', +}; + +type WorkspaceEditCardLimitPageProps = StackScreenProps; + +function WorkspaceEditCardLimitPage({route}: WorkspaceEditCardLimitPageProps) { + const {policyID, cardID} = route.params; + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const styles = useThemeStyles(); + const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); + + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${policyID}_${CONST.EXPENSIFY_CARD.BANK}`); + const card = cardsList?.[cardID] ?? mockedCard; + + const getPromptTextKey = useMemo((): ConfirmationWarningTranslationPaths => { + switch (card.nameValuePairs?.limitType) { + case CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART: + return 'workspace.expensifyCard.smartLimitWarning'; + case CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED: + return 'workspace.expensifyCard.fixedLimitWarning'; + case CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY: + return 'workspace.expensifyCard.monthlyLimitWarning'; + default: + return 'workspace.expensifyCard.fixedLimitWarning'; + } + }, [card.nameValuePairs?.limitType]); + + const updateCardLimit = (newLimit: string) => { + setIsConfirmModalVisible(false); + + Card.updateExpensifyCardLimit(policyID, Number(cardID), Number(newLimit) * 100, card.nameValuePairs?.limit); + + Navigation.goBack(); + }; + + const submit = (values: FormOnyxValues) => { + const currentLimit = card.nameValuePairs?.limit ?? 0; + const currentSpend = currentLimit - card.availableSpend; + const newLimit = Number(values[INPUT_IDS.LIMIT]) * 100; + const newAvailableSpend = newLimit - currentSpend; + + if (newAvailableSpend <= 0) { + setIsConfirmModalVisible(true); + return; + } + + updateCardLimit(values[INPUT_IDS.LIMIT]); + }; + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIMIT]); + + // We only want integers to be sent as the limit + if (!Number(values.limit) || !Number.isInteger(Number(values.limit))) { + errors.limit = translate('iou.error.invalidAmount'); + } + + return errors; + }, + [translate], + ); + + return ( + + + + + {({inputValues}) => ( + <> + + updateCardLimit(inputValues[INPUT_IDS.LIMIT])} + onCancel={() => setIsConfirmModalVisible(false)} + prompt={translate(getPromptTextKey, CurrencyUtils.convertToDisplayString(Number(inputValues[INPUT_IDS.LIMIT]) * 100, CONST.CURRENCY.USD))} + confirmText={translate('workspace.expensifyCard.changeLimit')} + cancelText={translate('common.cancel')} + danger + shouldEnableNewFocusManagement + /> + + )} + + + + ); +} + +WorkspaceEditCardLimitPage.displayName = 'WorkspaceEditCardLimitPage'; + +export default WorkspaceEditCardLimitPage; diff --git a/src/pages/workspace/expensifyCard/WorkspaceEditCardNamePage.tsx b/src/pages/workspace/expensifyCard/WorkspaceEditCardNamePage.tsx new file mode 100644 index 000000000000..51bbe440f5d2 --- /dev/null +++ b/src/pages/workspace/expensifyCard/WorkspaceEditCardNamePage.tsx @@ -0,0 +1,94 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import Navigation from '@navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/EditExpensifyCardNameForm'; + +// TODO: remove when Onyx data is available +const mockedCard = { + accountID: 885646, + availableSpend: 1000, + nameValuePairs: { + cardTitle: 'Test 1', + isVirtual: true, + limit: 2000, + limitType: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART, + }, + lastFourPAN: '1234', +}; + +type WorkspaceEditCardNamePageProps = StackScreenProps; + +function WorkspaceEditCardNamePage({route}: WorkspaceEditCardNamePageProps) { + const {policyID, cardID} = route.params; + + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const styles = useThemeStyles(); + + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${policyID}_${CONST.EXPENSIFY_CARD.BANK}`); + const card = cardsList?.[cardID] ?? mockedCard; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const submit = (values: FormOnyxValues) => { + // TODO: add API call when it's supported https://github.com/Expensify/Expensify/issues/407832 + Navigation.goBack(); + }; + + const validate = (values: FormOnyxValues): FormInputErrors => + ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]); + + return ( + + + + + + + + + ); +} + +WorkspaceEditCardNamePage.displayName = 'WorkspaceEditCardNamePage'; + +export default WorkspaceEditCardNamePage; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx index 6934776e44ba..9fd03b3f22ce 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx @@ -24,6 +24,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; // TODO: remove when Onyx data is available @@ -115,7 +116,7 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail description={translate('workspace.expensifyCard.cardLimit')} title={formattedLimit} shouldShowRightIcon - onPress={() => {}} // TODO: navigate to Edit card limit page https://github.com/Expensify/App/issues/44326 + onPress={() => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_LIMIT.getRoute(policyID, cardID))} /> {}} // TODO: navigate to Edit card name page https://github.com/Expensify/App/issues/44327 + onPress={() => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_NAME.getRoute(policyID, cardID))} /> ; +type ReportFieldsAddListValuePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; function ReportFieldsAddListValuePage({ + policy, route: { params: {policyID, reportFieldID}, }, @@ -57,6 +61,7 @@ function ReportFieldsAddListValuePage({ accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED} + shouldBeBlocked={PolicyUtils.hasAccountingConnections(policy)} > ; +type ReportFieldsEditValuePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; function ReportFieldsEditValuePage({ + policy, route: { params: {policyID, valueIndex}, }, @@ -63,6 +67,7 @@ function ReportFieldsEditValuePage({ accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED} + shouldBeBlocked={PolicyUtils.hasAccountingConnections(policy)} > !disabledListValue).length; @@ -82,7 +86,7 @@ function ReportFieldsInitialValuePage({ [availableListValuesLength, policy?.fieldList, translate], ); - if (!reportField) { + if (!reportField || hasAccountingConnections) { return ; } @@ -105,6 +109,12 @@ function ReportFieldsInitialValuePage({ title={reportField.name} onBackButtonPress={Navigation.goBack} /> + {isListFieldType && ( + + {translate('workspace.reportFields.listValuesInputSubtitle')} + + )} + {isTextFieldType && ( selectedValues[key]); @@ -136,7 +138,7 @@ function ReportFieldsListValuesPage({ }; const openListValuePage = (valueItem: ValueListItem) => { - if (valueItem.index === undefined) { + if (valueItem.index === undefined || hasAccountingConnections) { return; } @@ -145,12 +147,26 @@ function ReportFieldsListValuesPage({ setSelectedValues({}); }; - const getCustomListHeader = () => ( - - {translate('common.name')} - {translate('statusPage.status')} - - ); + const getCustomListHeader = () => { + const header = ( + + {translate('common.name')} + {translate('statusPage.status')} + + ); + if (!hasAccountingConnections) { + return header; + } + return {header}; + }; const getHeaderButtons = () => { const options: Array>> = []; @@ -269,9 +285,9 @@ function ReportFieldsListValuesPage({ title={translate('workspace.reportFields.listValues')} onBackButtonPress={Navigation.goBack} > - {!isSmallScreenWidth && getHeaderButtons()} + {!isSmallScreenWidth && !hasAccountingConnections && getHeaderButtons()} - {isSmallScreenWidth && {getHeaderButtons()}} + {isSmallScreenWidth && {!hasAccountingConnections && getHeaderButtons()}} {translate('workspace.reportFields.listInputSubtitle')} @@ -285,7 +301,7 @@ function ReportFieldsListValuesPage({ )} {!shouldShowEmptyState && ( { ReportField.deleteReportFields(policyID, [reportFieldKey]); @@ -80,15 +83,17 @@ function ReportFieldsSettingsPage({ description={translate('common.type')} interactive={false} /> - Navigation.navigate(ROUTES.WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE.getRoute(policyID, reportFieldID))} - /> + {!isListFieldEmpty && ( + Navigation.navigate(ROUTES.WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE.getRoute(policyID, reportFieldID))} + /> + )} {isListFieldType && ( Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELDS_LIST_VALUES.getRoute(policyID, reportFieldID))} /> )} - - setIsDeleteModalVisible(true)} - /> - + {!hasAccountingConnections && ( + + setIsDeleteModalVisible(true)} + /> + + )} setIsDeleteModalVisible(false)} shouldSetModalVisibility={false} diff --git a/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx b/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx index 209be2be26b1..977e64c882f1 100644 --- a/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx +++ b/src/pages/workspace/reportFields/ReportFieldsValueSettingsPage.tsx @@ -14,6 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportField from '@libs/actions/Policy/ReportField'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -56,7 +57,9 @@ function ReportFieldsValueSettingsPage({ return [reportFieldValue, reportFieldDisabledValue]; }, [formDraft?.disabledListValues, formDraft?.listValues, policy?.fieldList, reportFieldID, valueIndex]); - if (!currentValueName) { + const hasAccountingConnections = PolicyUtils.hasAccountingConnections(policy); + + if (!currentValueName || hasAccountingConnections) { return ; } diff --git a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx index 50b3f8394584..f4e3b01145da 100644 --- a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx +++ b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx @@ -1,7 +1,7 @@ -import {useIsFocused} from '@react-navigation/native'; +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; import {Str} from 'expensify-common'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -17,8 +17,11 @@ import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRight import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; +import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -26,7 +29,9 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as WorkspaceReportFieldUtils from '@libs/WorkspaceReportFieldUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import * as ReportField from '@userActions/Policy/ReportField'; import CONST from '@src/CONST'; @@ -55,6 +60,7 @@ function WorkspaceReportFieldsPage({ const theme = useTheme(); const {translate} = useLocalize(); const isFocused = useIsFocused(); + const {environmentURL} = useEnvironment(); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const filteredPolicyFieldList = useMemo(() => { if (!policy?.fieldList) { @@ -66,6 +72,14 @@ function WorkspaceReportFieldsPage({ const [selectedReportFields, setSelectedReportFields] = useState([]); const [deleteReportFieldsConfirmModalVisible, setDeleteReportFieldsConfirmModalVisible] = useState(false); + const fetchReportFields = useCallback(() => { + ReportField.openPolicyReportFieldsPage(policyID); + }, [policyID]); + + const {isOffline} = useNetwork({onReconnect: fetchReportFields}); + + useFocusEffect(fetchReportFields); + useEffect(() => { if (isFocused) { return; @@ -92,14 +106,14 @@ function WorkspaceReportFieldsPage({ rightElement: ( ), })), isDisabled: false, }, ]; - }, [filteredPolicyFieldList, policy, selectedReportFields]); + }, [filteredPolicyFieldList, policy, selectedReportFields, translate]); const updateSelectedReportFields = (item: ReportFieldForList) => { const fieldKey = ReportUtils.getReportFieldKey(item.fieldID); @@ -126,9 +140,12 @@ function WorkspaceReportFieldsPage({ setDeleteReportFieldsConfirmModalVisible(false); }; - const isLoading = policy === undefined; + const isLoading = !isOffline && policy === undefined; const shouldShowEmptyState = - Object.values(filteredPolicyFieldList).filter((reportField) => reportField.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length <= 0 && !isLoading; + !Object.values(filteredPolicyFieldList).some((reportField) => reportField.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline) && !isLoading; + const hasAccountingConnections = PolicyUtils.hasAccountingConnections(policy); + const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; + const currentConnectionName = PolicyUtils.getCurrentConnectionName(policy); const getHeaderButtons = () => { const options: Array>> = []; @@ -168,16 +185,43 @@ function WorkspaceReportFieldsPage({ ); }; - const getCustomListHeader = () => ( - - {translate('common.name')} - {translate('common.type')} - - ); + const getCustomListHeader = () => { + const header = ( + + {translate('common.name')} + {translate('common.type')} + + ); + if (!hasAccountingConnections) { + return header; + } + return {header}; + }; const getHeaderText = () => ( - {translate('workspace.reportFields.subtitle')} + {isConnectedToAccounting ? ( + + {`${translate('workspace.reportFields.importedFromAccountingSoftware')} `} + + {`${currentConnectionName} ${translate('workspace.accounting.settings')}`} + + . + + ) : ( + {translate('workspace.reportFields.subtitle')} + )} ); @@ -199,9 +243,9 @@ function WorkspaceReportFieldsPage({ title={translate('workspace.common.reportFields')} shouldShowBackButton={isSmallScreenWidth} > - {!isSmallScreenWidth && getHeaderButtons()} + {!isSmallScreenWidth && !hasAccountingConnections && getHeaderButtons()} - {isSmallScreenWidth && {getHeaderButtons()}} + {isSmallScreenWidth && {!hasAccountingConnections && getHeaderButtons()}} ; function getPolicyIDFromRoute(route: PolicyRoute): string { diff --git a/src/types/form/EditExpensifyCardLimitForm.ts b/src/types/form/EditExpensifyCardLimitForm.ts new file mode 100644 index 000000000000..8958999cfd7c --- /dev/null +++ b/src/types/form/EditExpensifyCardLimitForm.ts @@ -0,0 +1,13 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + LIMIT: 'limit', +} as const; + +type InputID = ValueOf; + +type EditExpensifyCardLimitForm = Form; + +export type {EditExpensifyCardLimitForm}; +export default INPUT_IDS; diff --git a/src/types/form/EditExpensifyCardNameForm.ts b/src/types/form/EditExpensifyCardNameForm.ts new file mode 100644 index 000000000000..197ba70801b6 --- /dev/null +++ b/src/types/form/EditExpensifyCardNameForm.ts @@ -0,0 +1,13 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + NAME: 'name', +} as const; + +type InputID = ValueOf; + +type EditExpensifyCardNameForm = Form; + +export type {EditExpensifyCardNameForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 7cd10488399c..3c6946dd97e8 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -10,6 +10,7 @@ export type {HomeAddressForm} from './HomeAddressForm'; export type {IKnowTeacherForm} from './IKnowTeacherForm'; export type {IntroSchoolPrincipalForm} from './IntroSchoolPrincipalForm'; export type {IssueNewExpensifyCardForm} from './IssueNewExpensifyCardForm'; +export type {EditExpensifyCardNameForm} from './EditExpensifyCardNameForm'; export type {LegalNameForm} from './LegalNameForm'; export type {MoneyRequestAmountForm} from './MoneyRequestAmountForm'; export type {MoneyRequestDateForm} from './MoneyRequestDateForm'; @@ -61,4 +62,5 @@ export type {SageIntactCredentialsForm} from './SageIntactCredentialsForm'; export type {NetSuiteCustomFieldForm} from './NetSuiteCustomFieldForm'; export type {NetSuiteTokenInputForm} from './NetSuiteTokenInputForm'; export type {NetSuiteCustomFormIDForm} from './NetSuiteCustomFormIDForm'; +export type {EditExpensifyCardLimitForm} from './EditExpensifyCardLimitForm'; export type {default as Form} from './Form'; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index b5d0a0a15d13..14fd2bd3ac81 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -289,6 +289,10 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** The trip ID in spotnana */ tripID: string; }; + + /** Whether the report is archived */ + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived?: string; }, PolicyReportField['fieldID'] >; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 97a8b459ff0f..8496ce296eda 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -146,6 +146,12 @@ type SearchTransaction = { /** If the transaction can be deleted */ canDelete: boolean; + /** If the transaction can be put on hold */ + canHold: boolean; + + /** If the transaction can be removed from hold */ + canUnhold: boolean; + /** The edited transaction amount */ modifiedAmount: number; diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index b10cae2e7736..ca30eb10b065 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -39,6 +39,10 @@ jest.mock('../../src/components/ConfirmedRoute.tsx'); TestHelper.setupApp(); TestHelper.setupGlobalFetchMock(); +beforeEach(() => { + Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); +}); + function scrollUpToRevealNewMessagesBadge() { const hintText = Localize.translateLocal('sidebarScreen.listOfChatMessages'); fireEvent.scroll(screen.getByLabelText(hintText), { diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 4a1171658f4d..79f19649f337 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -2,6 +2,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {SelectedTagOption} from '@components/TagPicker'; +import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; @@ -153,6 +154,8 @@ describe('OptionsListUtils', () => { // This indicates that the report is archived stateNum: 2, statusNum: 2, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), }, }; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 34a9a923bd61..14c710c6ddf2 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -2,6 +2,7 @@ import {addDays, format as formatDate, subDays} from 'date-fns'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import DateUtils from '@libs/DateUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -166,6 +167,8 @@ describe('ReportUtils', () => { ...baseAdminsRoom, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, stateNum: CONST.REPORT.STATE_NUM.APPROVED, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), }; expect(ReportUtils.getReportName(archivedAdminsRoom)).toBe('#admins (archived)'); @@ -190,6 +193,8 @@ describe('ReportUtils', () => { ...baseUserCreatedRoom, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, stateNum: CONST.REPORT.STATE_NUM.APPROVED, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), }; expect(ReportUtils.getReportName(archivedPolicyRoom)).toBe('#VikingsChat (archived)'); @@ -234,6 +239,8 @@ describe('ReportUtils', () => { oldPolicyName: policy.name, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, stateNum: CONST.REPORT.STATE_NUM.APPROVED, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), }; test('as member', () => { diff --git a/tests/unit/SidebarFilterTest.ts b/tests/unit/SidebarFilterTest.ts index 1e0745916717..1c70993c8fad 100644 --- a/tests/unit/SidebarFilterTest.ts +++ b/tests/unit/SidebarFilterTest.ts @@ -283,11 +283,16 @@ xdescribe('Sidebar', () => { it('filter paycheck and bill report', () => { const report1: Report = { ...LHNTestUtils.getFakeReport(), - type: CONST.REPORT.TYPE.PAYCHECK, + type: CONST.REPORT.UNSUPPORTED_TYPE.PAYCHECK, }; const report2: Report = { ...LHNTestUtils.getFakeReport(), - type: CONST.REPORT.TYPE.BILL, + type: CONST.REPORT.UNSUPPORTED_TYPE.BILL, + errorFields: { + notFound: { + error: 'Report not found', + }, + }, }; const report3: Report = LHNTestUtils.getFakeReport(); LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID); diff --git a/tests/unit/SidebarOrderTest.ts b/tests/unit/SidebarOrderTest.ts index 41e85c4fdd78..c8b885f98205 100644 --- a/tests/unit/SidebarOrderTest.ts +++ b/tests/unit/SidebarOrderTest.ts @@ -812,6 +812,8 @@ describe('Sidebar', () => { chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, stateNum: CONST.REPORT.STATE_NUM.APPROVED, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), }; const report2 = LHNTestUtils.getFakeReport([3, 4]); const report3 = LHNTestUtils.getFakeReport([5, 6]); @@ -919,6 +921,8 @@ describe('Sidebar', () => { statusNum: CONST.REPORT.STATUS_NUM.CLOSED, stateNum: CONST.REPORT.STATE_NUM.APPROVED, lastMessageText: 'test', + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), }; const report2 = { ...LHNTestUtils.getFakeReport([3, 4], 2, true), diff --git a/tests/unit/SidebarTest.ts b/tests/unit/SidebarTest.ts index 4c585f29bc61..f9b258228937 100644 --- a/tests/unit/SidebarTest.ts +++ b/tests/unit/SidebarTest.ts @@ -1,5 +1,6 @@ import {screen} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; +import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import * as Localize from '@src/libs/Localize'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -42,6 +43,8 @@ describe('Sidebar', () => { chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, stateNum: CONST.REPORT.STATE_NUM.APPROVED, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), lastMessageText: 'test', }; @@ -95,6 +98,8 @@ describe('Sidebar', () => { chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, stateNum: CONST.REPORT.STATE_NUM.APPROVED, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), lastMessageText: 'test', }; const action = { diff --git a/workflow_tests/preDeploy.test.ts b/workflow_tests/preDeploy.test.ts index a69356f84972..1d467b0bd705 100644 --- a/workflow_tests/preDeploy.test.ts +++ b/workflow_tests/preDeploy.test.ts @@ -278,7 +278,7 @@ describe('test workflow preDeploy', () => { utils.createStepAssertion('Announce failed workflow in Slack', true, null, 'CONFIRM_PASSING_BUILD', 'Announcing failed workflow in slack', [ {key: 'SLACK_WEBHOOK', value: '***'}, ]), - utils.createStepAssertion('Exit failed workflow', false, ''), + utils.createStepAssertion('Exit failed workflow', false, 'Checks failed, exiting ~ typecheck: failure, lint: success, test: success'), ]), ); assertions.assertChooseDeployActionsJobExecuted(result, false); @@ -349,7 +349,7 @@ describe('test workflow preDeploy', () => { utils.createStepAssertion('Announce failed workflow in Slack', true, null, 'CONFIRM_PASSING_BUILD', 'Announcing failed workflow in slack', [ {key: 'SLACK_WEBHOOK', value: '***'}, ]), - utils.createStepAssertion('Exit failed workflow', false, ''), + utils.createStepAssertion('Exit failed workflow', false, 'Checks failed, exiting ~ typecheck: success, lint: failure, test: success'), ]), ); assertions.assertChooseDeployActionsJobExecuted(result, false); @@ -420,7 +420,7 @@ describe('test workflow preDeploy', () => { utils.createStepAssertion('Announce failed workflow in Slack', true, null, 'CONFIRM_PASSING_BUILD', 'Announcing failed workflow in slack', [ {key: 'SLACK_WEBHOOK', value: '***'}, ]), - utils.createStepAssertion('Exit failed workflow', false, ''), + utils.createStepAssertion('Exit failed workflow', false, 'Checks failed, exiting ~ typecheck: success, lint: success, test: failure'), ]), ); assertions.assertChooseDeployActionsJobExecuted(result, false);