diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 459a780ca8b4..f4f6a90ae6db 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -85,7 +85,7 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c - [ ] I verified that comments were added to code that is not self explanatory - [ ] I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing. - [ ] I verified any copy / text shown in the product is localized by adding it to `src/languages/*` files and using the [translation method](https://github.com/Expensify/App/blob/4bd99402cebdf4d7394e0d1f260879ea238197eb/src/components/withLocalize.js#L60) - - [ ] If any non-english text was added/modified, I verified the translation was requested/reviewed in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message: + - [ ] If any non-english text was added/modified, I used [JaimeGPT](https://chatgpt.com/g/g-2dgOQl5VM-english-to-spanish-translator-aka-jaimegpt) to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message: - [ ] I verified all numbers, amounts, dates and phone numbers shown in the product are using the [localization methods](https://github.com/Expensify/App/blob/4bd99402cebdf4d7394e0d1f260879ea238197eb/src/components/withLocalize.js#L60-L68) - [ ] I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue) - [ ] I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README. diff --git a/android/app/build.gradle b/android/app/build.gradle index 2f7f2bd527db..ce5927fc2ad9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009006403 - versionName "9.0.64-3" + versionCode 1009006504 + versionName "9.0.65-4" // 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/desktop/main.ts b/desktop/main.ts index 04aa3e1b478e..4f642d90da51 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -25,6 +25,14 @@ const {DESKTOP_SHORTCUT_ACCELERATOR, LOCALES} = CONST; // geolocation api (window.navigator.geolocation.getCurrentPosition) to work on desktop. // Source: https://github.com/electron/electron/blob/98cd16d336f512406eee3565be1cead86514db7b/docs/api/environment-variables.md#google_api_key process.env.GOOGLE_API_KEY = CONFIG.GCP_GEOLOCATION_API_KEY; +/** + * Suppresses Content Security Policy (CSP) console warnings related to 'unsafe-eval'. + * This is required because: + * 1. Webpack utilizes eval() for module bundling + * 2. The application requires 'unsafe-eval' in CSP to function properly + * Note: CSP warnings are expected and unavoidable in this context + */ +process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = CONFIG.ELECTRON_DISABLE_SECURITY_WARNINGS; app.setName('New Expensify'); diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md index aff11c059d81..b231984f61e2 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md @@ -11,7 +11,7 @@ You can receive bills in three ways: - Manual Upload: For physical bills, create a Bill in Expensify from the Reports page. # Bill Pay Workflow -1. When a vendor or supplier sends a bill to Expensify, the document is automatically SmartScanned, and a Bill is created. This Bill is managed by the primary domain contact, who can view it on the Reports page within their default group policy. +1. When a vendor or supplier sends a bill to Expensify, the document is automatically SmartScanned, and a Bill is created. This Bill is managed by the primary domain contact, who can view it on the Reports page within their default group workspace. 2. Once the Bill is ready for processing, it follows the established approval workflow. As each person approves it, the Bill appears in the next approver’s Inbox. The final approver will pay the Bill using one of the available payment methods. diff --git a/docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md b/docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md index 05149ebf868e..36717a421c67 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md +++ b/docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md @@ -8,7 +8,7 @@ Welcome to the world of effortless expense tracking! Connecting your personal cr ## How to connect your personal card to import expenses Importing your card or bank via Account Settings will: Automatically sync your bank/card transactions with your Expensify account. These will merge seamlessly with any SmartScanned expenses in your account. -Generate IRS-compliant eReceipts, provided your Policy Admin has enabled this feature. +Generate IRS-compliant eReceipts, provided your Workspace Admin has enabled this feature. Discover below the numerous ways to easily bring your personal card expenses into Expensify below. ### *Important terms to know:* @@ -45,7 +45,7 @@ _Please note: an OFX file type will require no editing but not all banks' OFX fi 6. Set the date format to match your CSV and adjust the currency to match your bank account currency. 7. If you've previously imported expenses for the same card, choose the default layout of a previously uploaded spreadsheet. 8. Scroll down and select which columns map to the merchant, date and amount (as a number without a currency symbol) – these are required presets which must be assigned. -9. If applicable, you can also map specific Categories and Tags as long as you don't have an integration connection to your default group policy. If you have an integration connected, you'll want to add the Categories and Tags to the expense after the expense is uploaded. +9. If applicable, you can also map specific Categories and Tags as long as you don't have an integration connection to your default group workspace. If you have an integration connected, you'll want to add the Categories and Tags to the expense after the expense is uploaded. 10. Check the preview of your selection under *Output Preview*. If everything looks good, you can then select *Add Expenses*. 11. For checking accounts, you may need to "Flip Amount Sign" as transactions are often exported as negative amounts. diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md index 0fde76c8fa92..553171d73dde 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md +++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md @@ -29,9 +29,9 @@ Personal Liability: Users are allowed to delete company card expenses. If you update the settings on an existing company card feed, the changes will apply to expenses imported after the date the setting is saved. The update will not affect previously imported expenses. -## Preferred policy +## Preferred workspace -Setting a preferred policy for a company card feed will ensure that the imported transactions are added to a report on the policy you set. This setting is useful when members are on multiple policies and need to ensure their company card expenses are reported to a particular policy. +Setting a preferred workspace for a company card feed will ensure that the imported transactions are added to a report on the workspace you set. This setting is useful when members are on multiple workspaces and need to ensure their company card expenses are reported to a particular workspace. # How to use Scheduled Submit with company cards All expenses must be placed on a report if they need to be approved; with Scheduled Submit, you no longer need to worry about the arduous task of employees creating their expenses, adding them to a report, and submitting them manually. All they need to do is SmartScan their receipts and Concierge will take care of the rest, on a variety of schedules that you can set according to your preferences! @@ -41,11 +41,11 @@ Concierge won't automatically submit expenses on reports that have Expense Viola An employee can add comments in the Expense Comment field or at the bottom of the report to clarify any details. ## Enable Scheduled Submit -Scheduled Submit is enabled in the Group Policy by navigating to Settings > Policies > Group > Policy Name > Reports > Scheduled Submit +Scheduled Submit is enabled in the Group Workspace by navigating to Settings > Workspaces > Group > Workspace Name > Reports > Scheduled Submit Use the toggle to enable Scheduled Submit Choose your desired frequency -If Scheduled Submit is disabled on the group policy level (or set to a manual frequency), and you have noticed expense reports are still automatically submitted to the group policy, it's likely Scheduled Submit is enabled on the user’s Individual Policy settings. +If Scheduled Submit is disabled on the group workspace level (or set to a manual frequency), and you have noticed expense reports are still automatically submitted to the group workspace, it's likely Scheduled Submit is enabled on the user’s Individual Workspace settings. # How to connect company cards to an accounting integration @@ -59,7 +59,7 @@ You're all done. After the account is set, exported expenses will be mapped to t ## Pooled GL account To export credit card expenses to a pooled GL account: -Go to Settings > Policies > Group > Policy Name > Connections > Accounting Integrations > Configure +Go to Settings > Workspaces > Group > Workspace Name > Connections > Accounting Integrations > Configure Select Credit Card / Charge Card / Bank Transaction as your Non-reimbursable export option. Please review the Export Settings page for exporting Expense Reports to NetSuite Select the Vendor/liability account you want to export all non-reimbursable expenses to. @@ -86,7 +86,7 @@ It's important to note that eReceipts are not generated for lodging expenses. Mo {% include faq-begin.md %} ## What plan/subscription is required in order to manage corporate cards? -Group Policy (Collect or Control plan only) +Group Workspace (Collect or Control plan only) ## When do my company card transactions import to Expensify? Credit card transactions are imported to Expensify once they’re posted to the bank account. This usually takes 1-3 business days between the point of purchase and when the transactions populate in your account. diff --git a/docs/articles/expensify-classic/connections/Deel.md b/docs/articles/expensify-classic/connections/Deel.md index 12e616d9657f..bdc4b89206ca 100644 --- a/docs/articles/expensify-classic/connections/Deel.md +++ b/docs/articles/expensify-classic/connections/Deel.md @@ -5,7 +5,7 @@ description: Automatically sync expenses from Expensify to Deel # Overview -This guide is for business clients who want to set up policies and synchronize expenses from Expensify to Deel. This one-way synchronization ensures that Expensify becomes the definitive source for all employee expenses. +This guide is for business clients who want to set up workspaces and synchronize expenses from Expensify to Deel. This one-way synchronization ensures that Expensify becomes the definitive source for all employee expenses. If you are a contractor or employee working for a company using Expensify, please refer to: @@ -16,7 +16,7 @@ If you are a contractor or employee working for a company using Expensify, pleas By integrating Expensify with Deel, you can utilize Expensify’s approval workflows to ensure timely payment through Deel for your team. -This process involves aligning user profiles and expense policies between Expensify and Deel. Once connected, Deel will scan for approved expenses from matched users included in selected workspaces for integration, allowing Deel to import these expenses for reimbursement. +This process involves aligning user profiles and expense workspaces between Expensify and Deel. Once connected, Deel will scan for approved expenses from matched users included in selected workspaces for integration, allowing Deel to import these expenses for reimbursement. This synchronization is one-way. Expenses and receipts logged and approved in Expensify will sync to Deel. Expenses logged in Deel will not sync to Expensify. @@ -27,7 +27,7 @@ This synchronization is one-way. Expenses and receipts logged and approved in Ex To establish a connection, make sure you have the following: - Deel Organization Manager permissions -- Expensify Admin permissions for policies you wish to integrate with Deel +- Expensify Admin permissions for workspaces you wish to integrate with Deel - A paid Expensify subscription to approve expenses and sync them to Deel Expensify Admin permissions can be intricate. Refer to [Expensify’s Introduction to Integration]([https://example.com](https://integrations.expensify.com/Integration-Server/doc/#introduction)) for more details. diff --git a/docs/articles/expensify-classic/connections/Greenhouse.md b/docs/articles/expensify-classic/connections/Greenhouse.md index b44e5a090d17..282ba33fd607 100644 --- a/docs/articles/expensify-classic/connections/Greenhouse.md +++ b/docs/articles/expensify-classic/connections/Greenhouse.md @@ -38,6 +38,6 @@ Expensify's direct integration with Greenhouse allows you to automatically send ## In Expensify: -1. Navigate to **Settings > Policies > Group > _[Workspace Name]_ > Members** +1. Navigate to **Settings > Workspaces > Group > _[Workspace Name]_ > Members** 2. The candidate you just sent to Expensify should be listed in the workspace members list 3. If the Recruiter (or Recruiting Coordinator) field was filled in in Greenhouse, the candidate will already be configured to submit reports to that recruiter for approval. If no Recruiter was selected, then the candidate will submit based on the Expensify workspace approval settings. diff --git a/docs/articles/expensify-classic/connections/QuickBooks-Time.md b/docs/articles/expensify-classic/connections/QuickBooks-Time.md index 5bbd2c4b583c..bcc06e171d4f 100644 --- a/docs/articles/expensify-classic/connections/QuickBooks-Time.md +++ b/docs/articles/expensify-classic/connections/QuickBooks-Time.md @@ -1,6 +1,6 @@ --- title: Expensify and TSheets/QuickBooks Time Integration Guide -description: This help document explains how to connect TSheets/QuickBooks Time to your Expensify policy +description: This help document explains how to connect TSheets/QuickBooks Time to your Expensify workspace --- # Overview diff --git a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md index aecf21acfc3f..068e4dd5bca9 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md +++ b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md @@ -40,18 +40,18 @@ The three options for the date your report will export with are: **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. +**Vendor Bills:** Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding workspace. 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. +**Journal Entries:** Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this workspace. All the transactions will be posted to the payable account specified in the workspace. You can also set an approval level in NetSuite for the journal entries. - 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 ## Export Settings for 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. +**Vendor Bills:** Non-reimbursable expenses will be posted as a vendor bill payable to the default vendor specified in your workspace'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. +**Journal Entries:** Non-reimbursable expenses will be posted to the Journal Entries posting account selected in your workspace'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. - 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 diff --git a/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md b/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md index c2dbb969d007..302277c3a45a 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md @@ -94,8 +94,8 @@ This can happen if the employee’s subsidiary in NetSuite doesn’t match what - Ensure the email on the employee record in NetSuite matches the email address of the report submitter in Expensify. - In NetSuite, make sure the employee's hire date is in the past and/or the termination date is in the future. 4. **Currency Match for Journal Entries:** - - If exporting as Journal Entries, ensure the currency for the NetSuite employee record, NetSuite subsidiary, and Expensify policy all match. - - In NetSuite, go to the **Human Resources** tab > **Expense Report Currencies**, and add the subsidiary/policy currency if necessary. + - If exporting as Journal Entries, ensure the currency for the NetSuite employee record, NetSuite subsidiary, and Expensify workspace all match. + - In NetSuite, go to the **Human Resources** tab > **Expense Report Currencies**, and add the subsidiary/workspace currency if necessary. # ExpensiError NS0024: Invalid Customer or Project Tag Employees must be listed as a resource on the customer/project in NetSuite to be able to apply it to an expense. If that isn’t set up in NetSuite, you can run into this error. diff --git a/docs/articles/expensify-classic/domains/SAML-SSO.md b/docs/articles/expensify-classic/domains/SAML-SSO.md index a6032afe8d24..da4bd5639120 100644 --- a/docs/articles/expensify-classic/domains/SAML-SSO.md +++ b/docs/articles/expensify-classic/domains/SAML-SSO.md @@ -88,7 +88,7 @@ Before getting started, you will need a verified domain and Control plan to set 6. The new trust is now created. Highlight the trust, then click *Edit claim rules* on the right. 7. Click *Add a Rule*. 8. The default option should be *Send LDAP Attributes as Claims*. Click Next. -9. Depending upon how your Active Directory is set up, you may or may not have a useful email address associated with each user, or you may have a policy to use the UPN as the user attribute for authentication. If so, using the UPN user attribute may be appropriate for you. If not, you can use the email address attribute. +9. Depending upon how your Active Directory is set up, you may or may not have a useful email address associated with each user, or you may have a workspace to use the UPN as the user attribute for authentication. If so, using the UPN user attribute may be appropriate for you. If not, you can use the email address attribute. 10. Give the rule a name like *Get email address from AD*. Choose Active Directory as the attribute store from the dropdown list. Choose your source user attribute to pass to Expensify that has users’ email address info in it, usually either *E-Mail-Address* or *User-Principal-Name*. Select the outgoing claim type as “E-Mail Address”. Click OK. 11. Add another rule; this time, we want to *Transform an Incoming Claim*. Click Next. 12. Name the rule *Send email address*. The Incoming claim type should be *E-Mail Address*. The outgoing claim type should be *Name ID*, and the outgoing name ID format should be *Email*. Click OK. diff --git a/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md b/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md index b245a26d10a0..0c0153522af3 100644 --- a/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md +++ b/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md @@ -80,7 +80,7 @@ Once you’ve successfully downgraded to a free Expensify account, your Workspac ## Will I be charged for a monthly subscription even if I don't use SmartScans? Yes, the Monthly Subscription is prepaid and not based on activity, so you'll be charged regardless of usage. -## I'm on a group policy; do I need the monthly subscription too? -Probably not. Group policy members already have unlimited SmartScans, so there's usually no need to buy the subscription. However, you can use it for personal use if you leave your company's Workspace. +## I'm on a group workspace; do I need the monthly subscription too? +Probably not. Group workspace members already have unlimited SmartScans, so there's usually no need to buy the subscription. However, you can use it for personal use if you leave your company's Workspace. {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself.md b/docs/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself.md index 69dea87ad8ea..5d64a9de3df5 100644 --- a/docs/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself.md +++ b/docs/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself.md @@ -39,7 +39,7 @@ Here’s how to determine whether a personal or group workspace might be best fo
  1. Hover over Settings, then click Workspaces.
  2. Click the Individual tab on the left.
  3. -
  4. Select the policy type that best fits your needs.
  5. +
  6. Select the workspace type that best fits your needs.
  7. Set up your workspace details including the workspace name, expense rules, categories, and more.
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md index bded231d1daa..66466b57c854 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md @@ -90,4 +90,4 @@ To view and pay bills: When you have bills to pay you can click *View all bills* under the *Manage your bills* box and we’ll keep a neatly organized list of all of the bills you can pay via ACH directly from your Expensify account. # You’re all set! -Congrats, you are all set up! If you need any assistance with anything mentioned above, reach out to either your Concierge directly in *[new.expensify.com](https://new.expensify.com/concierge)*, or email concierge@expensify.com. Create a Collect or Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you. +Congrats, you are all set up! If you need any assistance with anything mentioned above, reach out to either your Concierge directly in *[new.expensify.com](https://new.expensify.com/concierge)*, or email concierge@expensify.com. Create a Collect or Control Workspace, and we’ll automatically assign a dedicated Setup Specialist to you. diff --git a/docs/articles/expensify-classic/reports/Automatic-Receipt-Audit.md b/docs/articles/expensify-classic/reports/Automatic-Receipt-Audit.md index f0d112b86e9f..61640ce69b77 100644 --- a/docs/articles/expensify-classic/reports/Automatic-Receipt-Audit.md +++ b/docs/articles/expensify-classic/reports/Automatic-Receipt-Audit.md @@ -17,5 +17,5 @@ All Expensify Control plans automatically come with Concierge Receipt Audit. If **Can I disable Concierge Receipt Audit?** -All Control plan policies automatically include Concierge Receipt Audit. At this time, it cannot be disabled. +All Control plan workspaces automatically include Concierge Receipt Audit. At this time, it cannot be disabled. {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md b/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md index 7c3d8077c14d..1d5814138f6e 100644 --- a/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md +++ b/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md @@ -29,7 +29,7 @@ If your workspace has automations set to automatically submit reports for approv - **Receipt required amount**: How much a single expense can cost before a receipt is required {% include info.html %} -Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off. +Expensify includes certain system mandatory violations that can't be disabled, even if your workspace has violations turned off. {% include end-info.html %} # Set category rules diff --git a/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md index c8be9a2728d5..04f2688eee90 100644 --- a/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md +++ b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md @@ -10,7 +10,7 @@ To set up your individual workspace, 1. Hover over Settings, then click **Workspaces**. 2. Click the **Individual** tab on the left. -3. Select the policy type that best fits your needs. +3. Select the workspace type that best fits your needs. 4. Set up your workspace details including the workspace name, expense rules, categories, and more. {% include info.html %} diff --git a/docs/articles/expensify-classic/workspaces/Tax-Tracking.md b/docs/articles/expensify-classic/workspaces/Tax-Tracking.md index 7b859c5101b1..c47e5ed51f32 100644 --- a/docs/articles/expensify-classic/workspaces/Tax-Tracking.md +++ b/docs/articles/expensify-classic/workspaces/Tax-Tracking.md @@ -11,9 +11,9 @@ Expensify’s tax tracking feature allows you to: # How to Enable Tax Tracking Tax tracking can be enabled in the Tax section of the Workspace settings of any Workspace, whether group or individual. ## If Connected to an Accounting Integration -If your group Workspace is connected to Xero, QuickBooks Online, Sage Intacct, or NetSuite, make sure to first enable tax via the connection configuration page (Settings > Policies > Group > [Workspace Name] > Connections > Configure) and then sync the connection. Your tax rates will be imported from the accounting system and indicated by its logo. +If your group Workspace is connected to Xero, QuickBooks Online, Sage Intacct, or NetSuite, make sure to first enable tax via the connection configuration page (Settings > Workspaces > Group > [Workspace Name] > Connections > Configure) and then sync the connection. Your tax rates will be imported from the accounting system and indicated by its logo. ## Not Connected to an Accounting Integration -If your Workspace is not connected to an accounting system, go to Settings > Policies > Group > [Workspace Name] > Tax to enable tax. +If your Workspace is not connected to an accounting system, go to Settings > Workspaces > Group > [Workspace Name] > Tax to enable tax. # Tracking Tax by Expense Category To set a different tax rate for a specific expense type in the Workspace currency, go to Settings > Workspaces > Group > [Workspace Name] > Categories page. Click "Edit Rules" next to the desired category and set the "Category default tax". This will be applied to new expenses, overriding the default Workspace currency tax rate. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index dcf7c9f238a6..54084367040c 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -244,12 +244,12 @@ platform :android do ENV["SUPPLY_UPLOAD_MAX_RETRIES"]="5" google_play_track_version_codes( package_name: "org.me.mobiexpensifyg", - json_key: './android/app/android-fastlane-json-key.json', + json_key: './android-fastlane-json-key.json', track: 'internal' ) upload_to_play_store( package_name: "org.me.mobiexpensifyg", - json_key: './android/app/android-fastlane-json-key.json', + json_key: './android-fastlane-json-key.json', version_code: ENV["VERSION"].to_i, track: 'internal', track_promote_to: 'production', @@ -268,11 +268,11 @@ platform :android do productionVersionCodes = google_play_track_version_codes( track: 'production', package_name: "org.me.mobiexpensifyg", - json_key: './android/app/android-fastlane-json-key.json', + json_key: './android-fastlane-json-key.json', ) upload_to_play_store( package_name: "org.me.mobiexpensifyg", - json_key: './android/app/android-fastlane-json-key.json', + json_key: './android-fastlane-json-key.json', version_code: productionVersionCodes.sort.last, # Get the latest version code track: 'production', rollout: '1', @@ -592,7 +592,7 @@ platform :ios do desc "Submit HybridApp to 100% rollout on App Store" lane :complete_hybrid_rollout do - api_token = Spaceship::ConnectAPI::Token.from_json_file("./ios/ios-fastlane-json-key.json") + api_token = Spaceship::ConnectAPI::Token.from_json_file("./ios-fastlane-json-key.json") Spaceship::ConnectAPI.token = api_token app = Spaceship::ConnectAPI::App.find("com.expensify.expensifylite") @@ -604,7 +604,7 @@ platform :ios do lane :submit_hybrid_for_rollout do deliver( app_identifier: "com.expensify.expensifylite", - api_key_path: "./ios/ios-fastlane-json-key.json", + api_key_path: "./ios-fastlane-json-key.json", # Skip HTML report verification force: true, diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 5fb52516800a..91cb6a7f745b 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.64 + 9.0.65 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.64.3 + 9.0.65.4 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 09403adcc493..38b36ea381f6 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.64 + 9.0.65 CFBundleSignature ???? CFBundleVersion - 9.0.64.3 + 9.0.65.4 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index d4ecfca41e38..def1f5fd29dd 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.64 + 9.0.65 CFBundleVersion - 9.0.64.3 + 9.0.65.4 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c485585e27e5..595920f5138e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1728,7 +1728,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-keyboard-controller (1.14.1): + - react-native-keyboard-controller (1.14.4): - DoubleConversion - glog - hermes-engine @@ -2674,7 +2674,7 @@ PODS: - RNSound/Core (= 0.11.2) - RNSound/Core (0.11.2): - React-Core - - RNSVG (15.8.0): + - RNSVG (15.9.0): - DoubleConversion - glog - hermes-engine @@ -2694,9 +2694,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNSVG/common (= 15.8.0) + - RNSVG/common (= 15.9.0) - Yoga - - RNSVG/common (15.8.0): + - RNSVG/common (15.9.0): - DoubleConversion - glog - hermes-engine @@ -3256,7 +3256,7 @@ SPEC CHECKSUMS: react-native-geolocation: b9bd12beaf0ebca61a01514517ca8455bd26fa06 react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: aae312752fcdfaa2240be9a015fc41ce54087546 - react-native-keyboard-controller: 902c07f41a415b632583b384427a71770a8b02a3 + react-native-keyboard-controller: 97bb7b48fa427c7455afdc8870c2978efd9bfa3a react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5 react-native-pager-view: c64a744211a46202619a77509f802765d1659dba @@ -3316,7 +3316,7 @@ SPEC CHECKSUMS: RNScreens: e389d6a6a66a4f0d3662924ecae803073ccce8ec RNShare: bd4fe9b95d1ee89a200778cc0753ebe650154bb0 RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 - RNSVG: 536cd3c866c878faf72beaba166c8b02fe2b762b + RNSVG: b2fbe96b2bb3887752f8abc1f495953847e90384 SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c diff --git a/package-lock.json b/package-lock.json index 4bc3319308ec..2af2ad860281 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.64-3", + "version": "9.0.65-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.64-3", + "version": "9.0.65-4", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -93,11 +93,11 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "1.14.1", + "react-native-keyboard-controller": "1.14.4", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.79", + "react-native-onyx": "2.0.81", "react-native-pager-view": "6.5.0", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -113,7 +113,7 @@ "react-native-screens": "3.35.0", "react-native-share": "11.0.2", "react-native-sound": "^0.11.2", - "react-native-svg": "15.8.0", + "react-native-svg": "15.9.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", @@ -31705,6 +31705,18 @@ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, + "node_modules/lodash.bindall": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.bindall/-/lodash.bindall-4.4.0.tgz", + "integrity": "sha512-NQ+QvFohS2gPbWpyLfyuiF0ZQA3TTaJ+n0XDID5jwtMZBKE32gN5vSyy7xBVsqvJkvT/UY9dvHXIk9tZmBVF3g==", + "license": "MIT" + }, + "node_modules/lodash.clone": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz", + "integrity": "sha512-GhrVeweiTD6uTmmn5hV/lzgCQhccwReIVRLHp7LT4SopOjqEZ5BbX8b5WWEtAKasjmy8hR7ZPwsYlxRCku5odg==", + "license": "MIT" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "dev": true, @@ -31753,10 +31765,22 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==", + "license": "MIT" + }, "node_modules/lodash.throttle": { "version": "4.1.1", "license": "MIT" }, + "node_modules/lodash.transform": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz", + "integrity": "sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==", + "license": "MIT" + }, "node_modules/lodash.union": { "version": "4.6.0", "dev": true, @@ -35874,6 +35898,16 @@ "resolved": "git+ssh://git@github.com/Expensify/react-native-image-size.git#cb392140db4953a283590d7cf93b4d0461baa2a9", "integrity": "sha512-kF/8fGsKoOnjPZceipRUaM9Xg9a/aKXU2Vm5eHYEKHrRt8FP39oCbaELPTb/vUKRTu1HmEGffDFzRT02BcdzYQ==" }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.6.tgz", + "integrity": "sha512-1pHnFTlBahins6UAajXUqeCOHew9l9C2C8tErnpGC3IyLJzvxD+TpYAixnCbrVS52f7+NvMttbiSI290XfwN0w==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.2.0", + "react-native": ">=0.73.0" + } + }, "node_modules/react-native-key-command": { "version": "1.0.8", "license": "MIT", @@ -35893,9 +35927,13 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.1.tgz", - "integrity": "sha512-HUrZTaaDPxm94EVXlguwJB2gm6mc+VRTTzR66luFGZJZnL2SJoxN+dwsNW3twkwUVDrCPPA3U21q9YWUKVmwvg==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.4.tgz", + "integrity": "sha512-hVt9KhK2dxBNtk4xHTnKLeO9Jv7v5h2TZlIeCQkbBLMd5NIJa4ll0GxIpbuutjP1ctPdhXUVpCfQzgXXJOYlzw==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.1.6" + }, "peerDependencies": { "react": "*", "react-native": "*", @@ -36646,13 +36684,17 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.79", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.79.tgz", - "integrity": "sha512-1rbhDdufp2vXmw3ttCtEXPK3p6F94nqKgqqvcRIqo6xLzgTI74rdm3Kqiyx4r6tYCTjN/TfmI/KLV+2EUShJZQ==", + "version": "2.0.81", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.81.tgz", + "integrity": "sha512-EwBqruX4lLnlk3KyZp4bst/voekLJFus7UhtvKmDuqR2Iz/FremwE04JW6YxGyc7C6KpbQrCFdWg/oF9ptRAtg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", + "lodash.bindall": "^4.4.0", + "lodash.clone": "^4.5.0", + "lodash.pick": "^4.4.0", + "lodash.transform": "^4.6.0", "underscore": "^1.13.6" }, "engines": { @@ -36911,9 +36953,10 @@ } }, "node_modules/react-native-svg": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz", - "integrity": "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw==", + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.9.0.tgz", + "integrity": "sha512-pwo7hteAM0P8jNpPGQtiSd0SnbBhE8tNd94LT8AcZcbnH5AJdXBIcXU4+tWYYeGUjiNAH2E5d0T5XIfnvaz1gA==", + "license": "MIT", "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", diff --git a/package.json b/package.json index 0bcde892622b..56a55d887251 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.64-3", + "version": "9.0.65-4", "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.", @@ -150,11 +150,11 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "1.14.1", + "react-native-keyboard-controller": "1.14.4", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.79", + "react-native-onyx": "2.0.81", "react-native-pager-view": "6.5.0", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -170,7 +170,7 @@ "react-native-screens": "3.35.0", "react-native-share": "11.0.2", "react-native-sound": "^0.11.2", - "react-native-svg": "15.8.0", + "react-native-svg": "15.9.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", diff --git a/patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch b/patches/react-native-keyboard-controller+1.14.4+001+disable-android.patch similarity index 96% rename from patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch rename to patches/react-native-keyboard-controller+1.14.4+001+disable-android.patch index 6bb62155a98c..8d2d81aab40a 100644 --- a/patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch +++ b/patches/react-native-keyboard-controller+1.14.4+001+disable-android.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -index 7ef8b36..f4d44ff 100644 +index 93c20d3..df1e846 100644 --- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt @@ -74,7 +74,7 @@ class EdgeToEdgeReactViewGroup( @@ -51,7 +51,7 @@ index 7ef8b36..f4d44ff 100644 } // endregion -@@ -219,7 +219,7 @@ class EdgeToEdgeReactViewGroup( +@@ -223,7 +223,7 @@ class EdgeToEdgeReactViewGroup( fun forceStatusBarTranslucent(isStatusBarTranslucent: Boolean) { if (active && this.isStatusBarTranslucent != isStatusBarTranslucent) { this.isStatusBarTranslucent = isStatusBarTranslucent diff --git a/src/CONFIG.ts b/src/CONFIG.ts index 8a30c8bf57c2..e5e9a9d1540a 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -103,4 +103,5 @@ export default { }, // to read more about StrictMode see: contributingGuides/STRICT_MODE.md USE_REACT_STRICT_MODE_IN_DEV: false, + ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', } as const; diff --git a/src/CONST.ts b/src/CONST.ts index ef5eaf4e403b..ed5f1837fe3b 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2843,6 +2843,7 @@ const CONST = { AMEX_CUSTOM_FEED: { CORPORATE: 'American Express Corporate Cards', BUSINESS: 'American Express Business Cards', + PERSONAL: 'American Express Personal Cards', }, DELETE_TRANSACTIONS: { RESTRICT: 'corporate', @@ -2969,8 +2970,8 @@ const CONST = { // eslint-disable-next-line max-len, no-misleading-character-class EMOJI: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, - // eslint-disable-next-line max-len, no-misleading-character-class - EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu, + // eslint-disable-next-line max-len, no-misleading-character-class, no-empty-character-class + EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/du, // eslint-disable-next-line max-len, no-misleading-character-class EMOJI_SKIN_TONES: /[\u{1f3fb}-\u{1f3ff}]/gu, @@ -3007,6 +3008,10 @@ const CONST = { return new RegExp(`[\\n\\s]|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu'); }, + get ALL_EMOJIS() { + return new RegExp(this.EMOJIS, this.EMOJIS.flags.concat('g')); + }, + MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/, ROUTES: { VALIDATE_LOGIN: /\/v($|(\/\/*))/, @@ -5034,7 +5039,7 @@ const CONST = { '\n' + `Here’s how to connect to ${integrationName}:\n` + '\n' + - '1. Click your profile photo.\n' + + '1. Click the settings tab.\n' + '2. Go to Workspaces.\n' + '3. Select your workspace.\n' + '4. Click Accounting.\n' + @@ -6294,10 +6299,17 @@ const CONST = { }, DEBUG: { + FORMS: { + REPORT: 'report', + REPORT_ACTION: 'reportAction', + TRANSACTION: 'transaction', + TRANSACTION_VIOLATION: 'transactionViolation', + }, DETAILS: 'details', JSON: 'json', REPORT_ACTIONS: 'actions', REPORT_ACTION_PREVIEW: 'preview', + TRANSACTION_VIOLATIONS: 'violations', }, REPORT_IN_LHN_REASONS: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b4510a2faeed..f97edbd744eb 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -716,10 +716,6 @@ const ONYXKEYS = { RULES_MAX_EXPENSE_AMOUNT_FORM_DRAFT: 'rulesMaxExpenseAmountFormDraft', RULES_MAX_EXPENSE_AGE_FORM: 'rulesMaxExpenseAgeForm', RULES_MAX_EXPENSE_AGE_FORM_DRAFT: 'rulesMaxExpenseAgeFormDraft', - DEBUG_REPORT_PAGE_FORM: 'debugReportPageForm', - DEBUG_REPORT_PAGE_FORM_DRAFT: 'debugReportPageFormDraft', - DEBUG_REPORT_ACTION_PAGE_FORM: 'debugReportActionPageForm', - DEBUG_REPORT_ACTION_PAGE_FORM_DRAFT: 'debugReportActionPageFormDraft', DEBUG_DETAILS_FORM: 'debugDetailsForm', DEBUG_DETAILS_FORM_DRAFT: 'debugDetailsFormDraft', }, @@ -814,9 +810,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AMOUNT_FORM]: FormTypes.RulesMaxExpenseAmountForm; [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; [ONYXKEYS.FORMS.SEARCH_SAVED_SEARCH_RENAME_FORM]: FormTypes.SearchSavedSearchRenameForm; - [ONYXKEYS.FORMS.DEBUG_REPORT_PAGE_FORM]: FormTypes.DebugReportForm; - [ONYXKEYS.FORMS.DEBUG_REPORT_ACTION_PAGE_FORM]: FormTypes.DebugReportActionForm; - [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm; + [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm | FormTypes.DebugTransactionForm | FormTypes.DebugTransactionViolationForm; }; type OnyxFormDraftValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index bdf4d4774ec1..2c44551acaa7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1777,13 +1777,46 @@ const ROUTES = { getRoute: (reportID: string, reportActionID: string) => `debug/report/${reportID}/actions/${reportActionID}/preview` as const, }, DETAILS_CONSTANT_PICKER_PAGE: { - route: 'debug/details/constant/:fieldName', - getRoute: (fieldName: string, fieldValue?: string, backTo?: string) => getUrlWithBackToParam(`debug/details/constant/${fieldName}?fieldValue=${fieldValue}`, backTo), + route: 'debug/:formType/details/constant/:fieldName', + getRoute: (formType: string, fieldName: string, fieldValue?: string, policyID?: string, backTo?: string) => + getUrlWithBackToParam(`debug/${formType}/details/constant/${fieldName}?fieldValue=${fieldValue}&policyID=${policyID}`, backTo), }, DETAILS_DATE_TIME_PICKER_PAGE: { route: 'debug/details/datetime/:fieldName', getRoute: (fieldName: string, fieldValue?: string, backTo?: string) => getUrlWithBackToParam(`debug/details/datetime/${fieldName}?fieldValue=${fieldValue}`, backTo), }, + DEBUG_TRANSACTION: { + route: 'debug/transaction/:transactionID', + getRoute: (transactionID: string) => `debug/transaction/${transactionID}` as const, + }, + DEBUG_TRANSACTION_TAB_DETAILS: { + route: 'debug/transaction/:transactionID/details', + getRoute: (transactionID: string) => `debug/transaction/${transactionID}/details` as const, + }, + DEBUG_TRANSACTION_TAB_JSON: { + route: 'debug/transaction/:transactionID/json', + getRoute: (transactionID: string) => `debug/transaction/${transactionID}/json` as const, + }, + DEBUG_TRANSACTION_TAB_VIOLATIONS: { + route: 'debug/transaction/:transactionID/violations', + getRoute: (transactionID: string) => `debug/transaction/${transactionID}/violations` as const, + }, + DEBUG_TRANSACTION_VIOLATION_CREATE: { + route: 'debug/transaction/:transactionID/violations/create', + getRoute: (transactionID: string) => `debug/transaction/${transactionID}/violations/create` as const, + }, + DEBUG_TRANSACTION_VIOLATION: { + route: 'debug/transaction/:transactionID/violations/:index', + getRoute: (transactionID: string, index: string) => `debug/transaction/${transactionID}/violations/${index}` as const, + }, + DEBUG_TRANSACTION_VIOLATION_TAB_DETAILS: { + route: 'debug/transaction/:transactionID/violations/:index/details', + getRoute: (transactionID: string, index: string) => `debug/transaction/${transactionID}/violations/${index}/details` as const, + }, + DEBUG_TRANSACTION_VIOLATION_TAB_JSON: { + route: 'debug/transaction/:transactionID/violations/:index/json', + getRoute: (transactionID: string, index: string) => `debug/transaction/${transactionID}/violations/${index}/json` as const, + }, } as const; /** diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 543e8708fea3..536dddb1f637 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -620,6 +620,9 @@ const SCREENS = { REPORT_ACTION_CREATE: 'Debug_Report_Action_Create', DETAILS_CONSTANT_PICKER_PAGE: 'Debug_Details_Constant_Picker_Page', DETAILS_DATE_TIME_PICKER_PAGE: 'Debug_Details_Date_Time_Picker_Page', + TRANSACTION: 'Debug_Transaction', + TRANSACTION_VIOLATION_CREATE: 'Debug_Transaction_Violation_Create', + TRANSACTION_VIOLATION: 'Debug_Transaction_Violation', }, } as const; diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index 9a90de17595d..ed2eae7a0a4c 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -10,6 +10,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate'; +import * as EmojiUtils from '@libs/EmojiUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import variables from '@styles/variables'; @@ -46,6 +47,7 @@ function AccountSwitcher() { const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false; const canSwitchAccounts = delegators.length > 0 || isActingAsDelegate; + const processedTextArray = EmojiUtils.splitTextWithEmojis(currentUserPersonalDetails?.displayName); const createBaseMenuItem = ( personalDetails: PersonalDetails | undefined, @@ -149,7 +151,9 @@ function AccountSwitcher() { numberOfLines={1} style={[styles.textBold, styles.textLarge, styles.flexShrink1]} > - {currentUserPersonalDetails?.displayName} + {processedTextArray.length !== 0 + ? EmojiUtils.getProcessedText(processedTextArray, styles.initialSettingsUsernameEmoji) + : currentUserPersonalDetails?.displayName} {!!canSwitchAccounts && ( diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index 23e0227788f2..c443b1ab8093 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -4,6 +4,7 @@ import type {ValueOf} from 'type-fest'; import type {Attachment} from '@components/Attachments/types'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; import type {ReportAction, ReportActions} from '@src/types/onyx'; @@ -19,10 +20,13 @@ function extractAttachments( accountID, parentReportAction, reportActions, - }: {privateNotes?: Record; accountID?: number; parentReportAction?: OnyxEntry; reportActions?: OnyxEntry}, + reportID, + }: {privateNotes?: Record; accountID?: number; parentReportAction?: OnyxEntry; reportActions?: OnyxEntry; reportID: string}, ) { const targetNote = privateNotes?.[Number(accountID)]?.note ?? ''; const attachments: Attachment[] = []; + const report = ReportUtils.getReport(reportID); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // We handle duplicate image sources by considering the first instance as original. Selecting any duplicate // and navigating back (<) shows the image preceding the first instance, not the selected duplicate's position. @@ -111,7 +115,7 @@ function extractAttachments( const actions = [...(parentReportAction ? [parentReportAction] : []), ...ReportActionsUtils.getSortedReportActions(Object.values(reportActions ?? {}))]; actions.forEach((action, key) => { - if (!ReportActionsUtils.shouldReportActionBeVisible(action, key) || ReportActionsUtils.isMoneyRequestAction(action)) { + if (!ReportActionsUtils.shouldReportActionBeVisible(action, key, canUserPerformWriteAction) || ReportActionsUtils.isMoneyRequestAction(action)) { return; } diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index a8eb614202a7..9aa619eb1cda 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -34,9 +34,9 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, reportID: report.reportID}); } else { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions, reportID: report.reportID}); } let newIndex = newAttachments.findIndex(compareImage); diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 335f1811b3b5..3a7540f65055 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -3,7 +3,7 @@ import type {MutableRefObject} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {Keyboard, PixelRatio, View} from 'react-native'; -import type {GestureType} from 'react-native-gesture-handler'; +import type {ComposedGesture, GestureType} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import {useOnyx} from 'react-native-onyx'; import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; @@ -38,6 +38,19 @@ const viewabilityConfig = { const MIN_FLING_VELOCITY = 500; +type DeviceAwareGestureDetectorProps = { + canUseTouchScreen: boolean; + gesture: ComposedGesture | GestureType; + children: React.ReactNode; +}; + +function DeviceAwareGestureDetector({canUseTouchScreen, gesture, children}: DeviceAwareGestureDetectorProps) { + // Don't render GestureDetector on non-touchable devices to prevent unexpected pointer event capture. + // This issue is left out on touchable devices since finger touch works fine. + // See: https://github.com/Expensify/App/issues/51246 + return canUseTouchScreen ? {children} : children; +} + function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose, attachmentLink}: AttachmentCarouselProps) { const theme = useTheme(); const {translate} = useLocalize(); @@ -76,9 +89,9 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, reportID: report.reportID}); } else { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined, reportID: report.reportID}); } if (isEqual(attachments, newAttachments)) { @@ -117,7 +130,19 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi onNavigate(attachment); } } - }, [report.privateNotes, reportActions, parentReportActions, compareImage, report.parentReportActionID, attachments, setDownloadButtonVisibility, onNavigate, accountID, type]); + }, [ + report.privateNotes, + reportActions, + parentReportActions, + compareImage, + report.parentReportActionID, + attachments, + setDownloadButtonVisibility, + onNavigate, + accountID, + type, + report.reportID, + ]); // Scroll position is affected when window width is resized, so we readjust it on width changes useEffect(() => { @@ -290,7 +315,10 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi cancelAutoHideArrow={cancelAutoHideArrows} /> - + - + diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index be875790d75e..e71ade65e66d 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -19,6 +19,7 @@ import * as Browser from '@libs/Browser'; import * as EmojiUtils from '@libs/EmojiUtils'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; const excludeNoStyles: Array = []; @@ -70,6 +71,7 @@ function Composer( start: selectionProp.start, end: selectionProp.end, }); + const [hasMultipleLines, setHasMultipleLines] = useState(false); const [isRendered, setIsRendered] = useState(false); const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); const [prevScroll, setPrevScroll] = useState(); @@ -328,10 +330,10 @@ function Composer( scrollStyleMemo, StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined, - textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}, + textContainsOnlyEmojis && hasMultipleLines ? styles.onlyEmojisTextLineHeight : {}, ], - [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis], + [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, hasMultipleLines, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis], ); return ( @@ -350,6 +352,9 @@ function Composer( /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} onSelectionChange={addCursorPositionToSelectionChange} + onContentSizeChange={(e) => { + setHasMultipleLines(e.nativeEvent.contentSize.height > variables.componentSizeLarge); + }} disabled={isDisabled} onKeyPress={handleKeyPress} addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL} diff --git a/src/components/CurrencySelectionList/index.tsx b/src/components/CurrencySelectionList/index.tsx index 201ed7bab730..1e8b5294286f 100644 --- a/src/components/CurrencySelectionList/index.tsx +++ b/src/components/CurrencySelectionList/index.tsx @@ -1,6 +1,6 @@ import {Str} from 'expensify-common'; import React, {useCallback, useMemo, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import SelectableListItem from '@components/SelectionList/SelectableListItem'; @@ -8,17 +8,17 @@ import useLocalize from '@hooks/useLocalize'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {CurrencyListItem, CurrencySelectionListOnyxProps, CurrencySelectionListProps} from './types'; +import type {CurrencyListItem, CurrencySelectionListProps} from './types'; function CurrencySelectionList({ searchInputLabel, initiallySelectedCurrencyCode, onSelect, - currencyList, selectedCurrencies = [], canSelectMultiple = false, recentlyUsedCurrencies, }: CurrencySelectionListProps) { + const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); const [searchValue, setSearchValue] = useState(''); const {translate} = useLocalize(); const getUnselectedOptions = useCallback((options: CurrencyListItem[]) => options.filter((option) => !option.isSelected), []); @@ -107,8 +107,4 @@ function CurrencySelectionList({ CurrencySelectionList.displayName = 'CurrencySelectionList'; -const CurrencySelectionListWithOnyx = withOnyx({ - currencyList: {key: ONYXKEYS.CURRENCY_LIST}, -})(CurrencySelectionList); - -export default CurrencySelectionListWithOnyx; +export default CurrencySelectionList; diff --git a/src/components/CurrencySelectionList/types.ts b/src/components/CurrencySelectionList/types.ts index 3001b0ceeaab..5cfef604ab94 100644 --- a/src/components/CurrencySelectionList/types.ts +++ b/src/components/CurrencySelectionList/types.ts @@ -1,18 +1,11 @@ -import type {OnyxEntry} from 'react-native-onyx'; import type {ListItem} from '@components/SelectionList/types'; -import type {CurrencyList} from '@src/types/onyx'; type CurrencyListItem = ListItem & { currencyName: string; currencyCode: string; }; -type CurrencySelectionListOnyxProps = { - /** List of available currencies */ - currencyList: OnyxEntry; -}; - -type CurrencySelectionListProps = CurrencySelectionListOnyxProps & { +type CurrencySelectionListProps = { /** Label for the search text input */ searchInputLabel: string; @@ -32,4 +25,4 @@ type CurrencySelectionListProps = CurrencySelectionListOnyxProps & { canSelectMultiple?: boolean; }; -export type {CurrencyListItem, CurrencySelectionListProps, CurrencySelectionListOnyxProps}; +export type {CurrencyListItem, CurrencySelectionListProps}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index 56461d5d9b39..493ddec5a5d0 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -82,7 +82,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { if (props.childTnode.tagName === 'br') { return {'\n'}; } - if (props.childTnode.type === 'text') { + if (props.childTnode.type === 'text' && props.childTnode.tagName !== 'code') { return ( ) { const styles = useThemeStyles(); - const style = {...styleProp, ...('islarge' in tnode.attributes ? styles.onlyEmojisText : {})}; + const style = useMemo(() => { + if ('islarge' in tnode.attributes) { + return [styleProp as TextStyle, styles.onlyEmojisText]; + } + + if ('ismedium' in tnode.attributes) { + return [styleProp as TextStyle, styles.emojisWithTextFontSize, styles.verticalAlignTopText]; + } + + return null; + }, [tnode.attributes, styles, styleProp]); return ( ) { return ( ); @@ -13,4 +17,4 @@ function OptionRowRendererComponent(props: CellContainerProps) { OptionRowRendererComponent.displayName = 'OptionRowRendererComponent'; -export default OptionRowRendererComponent; +export default forwardRef(OptionRowRendererComponent); diff --git a/src/components/OptionsListSkeletonView.tsx b/src/components/OptionsListSkeletonView.tsx index b6333c16e23c..032bf01f5a10 100644 --- a/src/components/OptionsListSkeletonView.tsx +++ b/src/components/OptionsListSkeletonView.tsx @@ -28,7 +28,7 @@ function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = fal return ( { const lineWidth = getLinedWidth(itemIndex); diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index 997106f3e649..6abf72e9e520 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -1,12 +1,15 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams} from '@src/languages/params'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Text from './Text'; @@ -29,6 +32,8 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct const {workspaceName, reportName} = parentNavigationSubtitleData; const {isOffline} = useNetwork(); const {translate} = useLocalize(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // We should not display the parent navigation subtitle if the user does not have access to the parent chat (the reportName is empty in this case) if (!reportName) { @@ -39,7 +44,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct { const parentAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '-1'); - const isVisibleAction = ReportActionsUtils.shouldReportActionBeVisible(parentAction, parentAction?.reportActionID ?? '-1'); + const isVisibleAction = ReportActionsUtils.shouldReportActionBeVisible(parentAction, parentAction?.reportActionID ?? '-1', canUserPerformWriteAction); // Pop the thread report screen before navigating to the chat report. Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); if (isVisibleAction && !isOffline) { @@ -52,7 +57,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct style={pressableStyles} > {!!reportName && ( diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index b59d1604a5aa..a98683b68b41 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -7,6 +7,7 @@ import type {AnchorRef, PopoverContextProps, PopoverContextValue} from './types' const PopoverContext = createContext({ onOpen: () => {}, popover: null, + popoverAnchor: null, close: () => {}, isOpen: false, }); @@ -21,6 +22,7 @@ function elementContains(ref: RefObject | undefined, function PopoverContextProvider(props: PopoverContextProps) { const [isOpen, setIsOpen] = useState(false); const activePopoverRef = useRef(null); + const [activePopoverAnchor, setActivePopoverAnchor] = useState(null); const closePopover = useCallback((anchorRef?: RefObject): boolean => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { @@ -30,6 +32,7 @@ function PopoverContextProvider(props: PopoverContextProps) { activePopoverRef.current.close(); activePopoverRef.current = null; setIsOpen(false); + setActivePopoverAnchor(null); return true; }, []); @@ -108,6 +111,7 @@ function PopoverContextProvider(props: PopoverContextProps) { closePopover(activePopoverRef.current.anchorRef); } activePopoverRef.current = popoverParams; + setActivePopoverAnchor(popoverParams.anchorRef.current); setIsOpen(true); }, [closePopover], @@ -119,9 +123,10 @@ function PopoverContextProvider(props: PopoverContextProps) { close: closePopover, // eslint-disable-next-line react-compiler/react-compiler popover: activePopoverRef.current, + popoverAnchor: activePopoverAnchor, isOpen, }), - [onOpen, closePopover, isOpen], + [onOpen, closePopover, isOpen, activePopoverAnchor], ); return {props.children}; diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts index b3d21e9ed5d9..04b0b8f90305 100644 --- a/src/components/PopoverProvider/types.ts +++ b/src/components/PopoverProvider/types.ts @@ -9,6 +9,7 @@ type PopoverContextProps = { type PopoverContextValue = { onOpen?: (popoverParams: AnchorRef) => void; popover?: AnchorRef | null; + popoverAnchor?: AnchorRef['anchorRef']['current']; close: (anchorRef?: RefObject) => void; isOpen: boolean; }; diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 246a57dccaf2..f1a72cc7fb8e 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -67,9 +67,6 @@ function ProcessMoneyReportHoldMenu({ const onSubmit = (full: boolean) => { if (isApprove) { - if (startAnimation) { - startAnimation(); - } IOU.approveMoneyRequest(moneyRequestReport, full); if (!full && isLinkedTransactionHeld(Navigation.getTopmostReportActionId() ?? '-1', moneyRequestReport?.reportID ?? '')) { Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport?.reportID ?? '')); diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index dfc88840446f..de20575aeef4 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -122,7 +122,7 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo return ( (); const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, policy); @@ -141,18 +140,12 @@ function ReportPreview({ })); const checkMarkScale = useSharedValue(iouSettled ? 1 : 0); - const isApproved = ReportUtils.isReportApproved(iouReport, action); - const thumbsUpScale = useSharedValue(isApproved ? 1 : 0.25); - const thumbsUpStyle = useAnimatedStyle(() => ({ - ...styles.defaultCheckmarkWrapper, - transform: [{scale: thumbsUpScale.value}], - })); - const moneyRequestComment = action?.childLastMoneyRequestComment ?? ''; const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); const isInvoiceRoom = ReportUtils.isInvoiceRoom(chatReport); const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); + const isApproved = ReportUtils.isReportApproved(iouReport, action); const canAllowSettlement = ReportUtils.hasUpdatedTotal(iouReport, policy); const numberOfRequests = allTransactions.length; const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(iouReportID); @@ -203,19 +196,11 @@ function ReportPreview({ const {isDelegateAccessRestricted, delegatorEmail} = useDelegateUserDetails(); const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); - const stopAnimation = useCallback(() => { - setIsPaidAnimationRunning(false); - setIsApprovedAnimationRunning(false); - }, []); - + const stopAnimation = useCallback(() => setIsPaidAnimationRunning(false), []); const startAnimation = useCallback(() => { setIsPaidAnimationRunning(true); HapticFeedback.longPress(); }, []); - const startApprovedAnimation = useCallback(() => { - setIsApprovedAnimationRunning(true); - HapticFeedback.longPress(); - }, []); const confirmPayment = useCallback( (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { @@ -247,8 +232,6 @@ function ReportPreview({ } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else { - setIsApprovedAnimationRunning(true); - HapticFeedback.longPress(); IOU.approveMoneyRequest(iouReport, true); } }; @@ -347,15 +330,14 @@ function ReportPreview({ const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); const getCanIOUBePaid = useCallback( - (onlyShowPayElsewhere = false, shouldCheckApprovedState = true) => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, onlyShowPayElsewhere, shouldCheckApprovedState), + (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, onlyShowPayElsewhere), [iouReport, chatReport, policy, allTransactions], ); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); - const canIOUBePaidAndApproved = useMemo(() => getCanIOUBePaid(false, false), [getCanIOUBePaid]); const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; - const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]) || isApprovedAnimationRunning; + const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]); const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport); @@ -442,7 +424,7 @@ function ReportPreview({ const shouldShowExportIntegrationButton = !shouldShowPayButton && !shouldShowSubmitButton && connectedIntegration && isAdmin && ReportUtils.canBeExported(iouReport); useEffect(() => { - if (!isPaidAnimationRunning || isApprovedAnimationRunning) { + if (!isPaidAnimationRunning) { return; } @@ -468,14 +450,6 @@ function ReportPreview({ } }, [isPaidAnimationRunning, iouSettled, checkMarkScale]); - useEffect(() => { - if (!isApproved) { - return; - } - - thumbsUpScale.value = withSpring(1, {duration: 200}); - }, [isApproved, thumbsUpScale]); - return ( - {previewMessage} + {previewMessage} {shouldShowRBR && ( )} - {isApproved && ( - - - - )} {shouldShowSubtitle && !!supportText && ( @@ -572,8 +538,6 @@ function ReportPreview({ { - if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { - startApprovedAnimation(); - } else { - startAnimation(); - } - }} + startAnimation={startAnimation} /> )} diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 65d253fad0b5..b05d34b2351b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,5 +1,6 @@ import {useNavigationState} from '@react-navigation/native'; import {Str} from 'expensify-common'; +import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {TextInputProps} from 'react-native'; @@ -323,6 +324,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) ], ); + const prevUserQueryRef = useRef(null); useEffect(() => { Report.searchInServer(debouncedInputValue.trim()); }, [debouncedInputValue]); @@ -340,11 +342,14 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); setAutocompleteSubstitutions(updatedSubstitutionsMap); - if (newUserQuery) { + if (newUserQuery || !isEmpty(prevUserQueryRef.current)) { listRef.current?.updateAndScrollToFocusedIndex(0); } else { listRef.current?.updateAndScrollToFocusedIndex(-1); } + + // Store the previous newUserQuery + prevUserQueryRef.current = newUserQuery; }, [autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete], ); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index b7bef18896d1..0e12e993cc79 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -487,7 +487,8 @@ function BaseSelectionList( const renderItem = ({item, index, section}: SectionListRenderItemInfo>) => { const normalizedIndex = index + (section?.indexOffset ?? 0); const isDisabled = !!section.isDisabled || item.isDisabled; - const isItemFocused = (!isDisabled || item.isSelected) && (focusedIndex === normalizedIndex || itemsToHighlight?.has(item.keyForList ?? '')); + const isItemFocused = (!isDisabled || item.isSelected) && focusedIndex === normalizedIndex; + const isItemHighlighted = !!itemsToHighlight?.has(item.keyForList ?? ''); // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const showTooltip = shouldShowTooltips && normalizedIndex < 10; @@ -495,7 +496,10 @@ function BaseSelectionList( onItemLayout(event, item?.keyForList)}> ( ) { return; } - // Remove the focus if the search input is empty or selected options length is changed (and allOptions length remains the same) + // Remove the focus if the search input is empty and prev search input not empty or selected options length is changed (and allOptions length remains the same) // else focus on the first non disabled item const newSelectedIndex = - textInputValue === '' || (flattenedSections.selectedOptions.length !== prevSelectedOptionsLength && prevAllOptionsLength === flattenedSections.allOptions.length) ? -1 : 0; + (isEmpty(prevTextInputValue) && textInputValue === '') || + (flattenedSections.selectedOptions.length !== prevSelectedOptionsLength && prevAllOptionsLength === flattenedSections.allOptions.length) + ? -1 + : 0; // reseting the currrent page to 1 when the user types something setCurrentPage(1); @@ -683,25 +690,31 @@ function BaseSelectionList( * @param timeout - The timeout in milliseconds before removing the highlight. */ const scrollAndHighlightItem = useCallback( - (items: string[], timeout: number) => { + (items: string[]) => { const newItemsToHighlight = new Set(); items.forEach((item) => { newItemsToHighlight.add(item); }); const index = flattenedSections.allOptions.findIndex((option) => newItemsToHighlight.has(option.keyForList ?? '')); - updateAndScrollToFocusedIndex(index); + scrollToIndex(index); setItemsToHighlight(newItemsToHighlight); if (itemFocusTimeoutRef.current) { clearTimeout(itemFocusTimeoutRef.current); } + const duration = + CONST.ANIMATED_HIGHLIGHT_ENTRY_DELAY + + CONST.ANIMATED_HIGHLIGHT_ENTRY_DURATION + + CONST.ANIMATED_HIGHLIGHT_START_DELAY + + CONST.ANIMATED_HIGHLIGHT_START_DURATION + + CONST.ANIMATED_HIGHLIGHT_END_DELAY + + CONST.ANIMATED_HIGHLIGHT_END_DURATION; itemFocusTimeoutRef.current = setTimeout(() => { - setFocusedIndex(-1); setItemsToHighlight(null); - }, timeout); + }, duration); }, - [flattenedSections.allOptions, setFocusedIndex, updateAndScrollToFocusedIndex], + [flattenedSections.allOptions, scrollToIndex], ); /** diff --git a/src/components/SelectionList/Search/UserInfoCell.tsx b/src/components/SelectionList/Search/UserInfoCell.tsx index 6a653471683a..4e71b97028bb 100644 --- a/src/components/SelectionList/Search/UserInfoCell.tsx +++ b/src/components/SelectionList/Search/UserInfoCell.tsx @@ -35,7 +35,7 @@ function UserInfoCell({participant, displayName}: UserInfoCellProps) { /> {displayName} diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 0900e49f43ce..8b27ee8a20f8 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -5,6 +5,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import MultipleAvatars from '@components/MultipleAvatars'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import TextWithTooltip from '@components/TextWithTooltip'; +import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -30,6 +31,13 @@ function TableListItem({ const theme = useTheme(); const StyleUtils = useStyleUtils(); + const animatedHighlightStyle = useAnimatedHighlightStyle({ + borderRadius: styles.selectionListPressableItemWrapper.borderRadius, + shouldHighlight: !!item.shouldAnimateInHighlight, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.highlightBG, + }); + const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; @@ -44,7 +52,17 @@ function TableListItem({ return ( = Partial & { } & TRightHandSideComponent; type SelectionListHandle = { - scrollAndHighlightItem?: (items: string[], timeout: number) => void; + scrollAndHighlightItem?: (items: string[]) => void; clearInputAfterSelect?: () => void; scrollToIndex: (index: number, animated?: boolean) => void; updateAndScrollToFocusedIndex: (newFocusedIndex: number) => void; diff --git a/src/components/SettlementButton/AnimatedSettlementButton.tsx b/src/components/SettlementButton/AnimatedSettlementButton.tsx index 7e42c8cdc45c..5de528d741a2 100644 --- a/src/components/SettlementButton/AnimatedSettlementButton.tsx +++ b/src/components/SettlementButton/AnimatedSettlementButton.tsx @@ -11,18 +11,9 @@ import type SettlementButtonProps from './types'; type AnimatedSettlementButtonProps = SettlementButtonProps & { isPaidAnimationRunning: boolean; onAnimationFinish: () => void; - isApprovedAnimationRunning: boolean; - canIOUBePaid: boolean; }; -function AnimatedSettlementButton({ - isPaidAnimationRunning, - onAnimationFinish, - isApprovedAnimationRunning, - isDisabled, - canIOUBePaid, - ...settlementButtonProps -}: AnimatedSettlementButtonProps) { +function AnimatedSettlementButton({isPaidAnimationRunning, onAnimationFinish, isDisabled, ...settlementButtonProps}: AnimatedSettlementButtonProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const buttonScale = useSharedValue(1); @@ -47,13 +38,12 @@ function AnimatedSettlementButton({ overflow: 'hidden', marginTop: buttonMarginTop.value, })); - const buttonDisabledStyle = - isPaidAnimationRunning || isApprovedAnimationRunning - ? { - opacity: 1, - ...styles.cursorDefault, - } - : undefined; + const buttonDisabledStyle = isPaidAnimationRunning + ? { + opacity: 1, + ...styles.cursorDefault, + } + : undefined; const resetAnimation = useCallback(() => { // eslint-disable-next-line react-compiler/react-compiler @@ -66,7 +56,7 @@ function AnimatedSettlementButton({ }, [buttonScale, buttonOpacity, paymentCompleteTextScale, paymentCompleteTextOpacity, height, buttonMarginTop, styles.expenseAndReportPreviewTextButtonContainer.gap]); useEffect(() => { - if (!isApprovedAnimationRunning && !isPaidAnimationRunning) { + if (!isPaidAnimationRunning) { resetAnimation(); return; } @@ -77,30 +67,13 @@ function AnimatedSettlementButton({ // Wait for the above animation + 1s delay before hiding the component const totalDelay = CONST.ANIMATION_PAID_DURATION + CONST.ANIMATION_PAID_BUTTON_HIDE_DELAY; - const willShowPaymentButton = canIOUBePaid && isApprovedAnimationRunning; height.value = withDelay( totalDelay, - withTiming(willShowPaymentButton ? variables.componentSizeNormal : 0, {duration: CONST.ANIMATION_PAID_DURATION}, () => runOnJS(onAnimationFinish)()), - ); - buttonMarginTop.value = withDelay( - totalDelay, - withTiming(willShowPaymentButton ? styles.expenseAndReportPreviewTextButtonContainer.gap : 0, {duration: CONST.ANIMATION_PAID_DURATION}), + withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION}, () => runOnJS(onAnimationFinish)()), ); + buttonMarginTop.value = withDelay(totalDelay, withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION})); paymentCompleteTextOpacity.value = withDelay(totalDelay, withTiming(0, {duration: CONST.ANIMATION_PAID_DURATION})); - }, [ - isPaidAnimationRunning, - isApprovedAnimationRunning, - onAnimationFinish, - buttonOpacity, - buttonScale, - height, - paymentCompleteTextOpacity, - paymentCompleteTextScale, - buttonMarginTop, - resetAnimation, - canIOUBePaid, - styles.expenseAndReportPreviewTextButtonContainer.gap, - ]); + }, [isPaidAnimationRunning, onAnimationFinish, buttonOpacity, buttonScale, height, paymentCompleteTextOpacity, paymentCompleteTextScale, buttonMarginTop, resetAnimation]); return ( @@ -109,16 +82,11 @@ function AnimatedSettlementButton({ {translate('iou.paymentComplete')} )} - {isApprovedAnimationRunning && ( - - {translate('iou.approved')} - - )} diff --git a/src/components/SkeletonViewContentLoader/index.native.tsx b/src/components/SkeletonViewContentLoader/index.native.tsx index 6d275e065bb0..afd58361947a 100644 --- a/src/components/SkeletonViewContentLoader/index.native.tsx +++ b/src/components/SkeletonViewContentLoader/index.native.tsx @@ -1,10 +1,17 @@ import React from 'react'; import SkeletonViewContentLoader from 'react-content-loader/native'; +import {StyleSheet} from 'react-native'; import type SkeletonViewContentLoaderProps from './types'; -function ContentLoader(props: SkeletonViewContentLoaderProps) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; +function ContentLoader({style, ...props}: SkeletonViewContentLoaderProps) { + return ( + + ); } export default ContentLoader; diff --git a/src/components/SkeletonViewContentLoader/index.tsx b/src/components/SkeletonViewContentLoader/index.tsx index ad3858a2d8d4..cab7710d02ee 100644 --- a/src/components/SkeletonViewContentLoader/index.tsx +++ b/src/components/SkeletonViewContentLoader/index.tsx @@ -1,10 +1,19 @@ import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {CSSProperties} from 'react'; import SkeletonViewContentLoader from 'react-content-loader'; +import {StyleSheet} from 'react-native'; import type SkeletonViewContentLoaderProps from './types'; -function ContentLoader(props: SkeletonViewContentLoaderProps) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; +function ContentLoader({style, ...props}: SkeletonViewContentLoaderProps) { + return ( + + ); } export default ContentLoader; diff --git a/src/components/SkeletonViewContentLoader/types.ts b/src/components/SkeletonViewContentLoader/types.ts index 5f4089f316dd..de1bdef558ef 100644 --- a/src/components/SkeletonViewContentLoader/types.ts +++ b/src/components/SkeletonViewContentLoader/types.ts @@ -1,6 +1,6 @@ import type {IContentLoaderProps} from 'react-content-loader'; import type {IContentLoaderProps as NativeIContentLoaderProps} from 'react-content-loader/native'; -type SkeletonViewContentLoaderProps = IContentLoaderProps & NativeIContentLoaderProps; +type SkeletonViewContentLoaderProps = Omit & NativeIContentLoaderProps; export default SkeletonViewContentLoaderProps; diff --git a/src/components/Skeletons/CardRowSkeleton.tsx b/src/components/Skeletons/CardRowSkeleton.tsx index d0e14b2bbb9a..24a2f8826908 100644 --- a/src/components/Skeletons/CardRowSkeleton.tsx +++ b/src/components/Skeletons/CardRowSkeleton.tsx @@ -31,7 +31,7 @@ function CardRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEn shouldAnimate={shouldAnimate} fixedNumItems={fixedNumItems} gradientOpacityEnabled={gradientOpacityEnabled} - itemViewStyle={[styles.highlightBG, styles.mb3, styles.br3, styles.mh5]} + itemViewStyle={[styles.highlightBG, styles.mb3, styles.br3, styles.ml5]} renderSkeletonItem={() => ( <> - - {renderSkeletonItem({itemIndex: i})} - - , + {renderSkeletonItem({itemIndex: i})} + , ); } return items; @@ -83,7 +80,7 @@ function ItemListSkeletonView({ return ( {skeletonViewItems} diff --git a/src/components/Skeletons/SearchRowSkeleton.tsx b/src/components/Skeletons/SearchRowSkeleton.tsx index 3535ba329a90..53f5aaa6065b 100644 --- a/src/components/Skeletons/SearchRowSkeleton.tsx +++ b/src/components/Skeletons/SearchRowSkeleton.tsx @@ -42,7 +42,7 @@ function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacity ( <> ( <> policyTag.enabled && !selectedNames.includes(policyTag.name))]; }, [selectedOptions, policyTagList, shouldShowDisabledAndSelectedOption]); - const sections = useMemo( - () => - TagOptionListUtils.getTagListSections({ - searchValue, - selectedOptions, - tags: enabledTags, - recentlyUsedTags: policyRecentlyUsedTagsList, - }), - [searchValue, enabledTags, selectedOptions, policyRecentlyUsedTagsList], - ); + const sections = useMemo(() => { + const tagSections = TagOptionListUtils.getTagListSections({ + searchValue, + selectedOptions, + tags: enabledTags, + recentlyUsedTags: policyRecentlyUsedTagsList, + }); + return shouldOrderListByTagName + ? tagSections.map((option) => ({ + ...option, + data: option.data.sort((a, b) => a.text?.localeCompare(b.text ?? '') ?? 0), + })) + : tagSections; + }, [searchValue, selectedOptions, enabledTags, policyRecentlyUsedTagsList, shouldOrderListByTagName]); const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList((sections?.at(0)?.data?.length ?? 0) > 0, searchValue); diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index 670126f8c6ec..9de6b6dd6d08 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -183,8 +183,11 @@ function BaseTextInput( const layout = event.nativeEvent.layout; + // We need to increase the height for single line inputs to escape cursor jumping on ios + const heightToFitEmojis = 1; + setWidth((prevWidth: number | null) => (autoGrowHeight ? layout.width : prevWidth)); - setHeight((prevHeight: number) => (!multiline ? layout.height : prevHeight)); + setHeight((prevHeight: number) => (!multiline ? layout.height + heightToFitEmojis : prevHeight)); }, [autoGrowHeight, multiline], ); diff --git a/src/components/TextWithTooltip/index.native.tsx b/src/components/TextWithTooltip/index.native.tsx index b857ded2588b..9f5f246ff9d3 100644 --- a/src/components/TextWithTooltip/index.native.tsx +++ b/src/components/TextWithTooltip/index.native.tsx @@ -1,14 +1,19 @@ import React from 'react'; import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as EmojiUtils from '@libs/EmojiUtils'; import type TextWithTooltipProps from './types'; function TextWithTooltip({text, style, numberOfLines = 1}: TextWithTooltipProps) { + const styles = useThemeStyles(); + const processedTextArray = EmojiUtils.splitTextWithEmojis(text); + return ( - {text} + {processedTextArray.length !== 0 ? EmojiUtils.getProcessedText(processedTextArray, [style, styles.emojisFontFamily]) : text} ); } diff --git a/src/components/Tooltip/PopoverAnchorTooltip.tsx b/src/components/Tooltip/PopoverAnchorTooltip.tsx index 1af0f01cf957..7e2d9b1ebd4c 100644 --- a/src/components/Tooltip/PopoverAnchorTooltip.tsx +++ b/src/components/Tooltip/PopoverAnchorTooltip.tsx @@ -5,24 +5,19 @@ import BaseTooltip from './BaseTooltip'; import type {TooltipExtendedProps} from './types'; function PopoverAnchorTooltip({shouldRender = true, children, ...props}: TooltipExtendedProps) { - const {isOpen, popover} = useContext(PopoverContext); + const {isOpen, popoverAnchor} = useContext(PopoverContext); const tooltipRef = useRef(null); const isPopoverRelatedToTooltipOpen = useMemo(() => { // eslint-disable-next-line @typescript-eslint/dot-notation, react-compiler/react-compiler const tooltipNode = (tooltipRef.current?.['_childNode'] as Node | undefined) ?? null; - if ( - isOpen && - popover?.anchorRef?.current && - tooltipNode && - ((popover.anchorRef.current instanceof Node && tooltipNode.contains(popover.anchorRef.current)) || tooltipNode === popover.anchorRef.current) - ) { + if (isOpen && popoverAnchor && tooltipNode && ((popoverAnchor instanceof Node && tooltipNode.contains(popoverAnchor)) || tooltipNode === popoverAnchor)) { return true; } return false; - }, [isOpen, popover]); + }, [isOpen, popoverAnchor]); if (!shouldRender || isPopoverRelatedToTooltipOpen) { return children; diff --git a/src/components/WorkspacesListRowDisplayName/index.native.tsx b/src/components/WorkspacesListRowDisplayName/index.native.tsx new file mode 100644 index 000000000000..1a91e2857db3 --- /dev/null +++ b/src/components/WorkspacesListRowDisplayName/index.native.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as EmojiUtils from '@libs/EmojiUtils'; +import type WorkspacesListRowDisplayNameProps from './types'; + +function WorkspacesListRowDisplayName({isDeleted, ownerName}: WorkspacesListRowDisplayNameProps) { + const styles = useThemeStyles(); + const processedOwnerName = EmojiUtils.splitTextWithEmojis(ownerName); + + return ( + + {processedOwnerName.length !== 0 + ? EmojiUtils.getProcessedText(processedOwnerName, [styles.labelStrong, isDeleted ? styles.offlineFeedback.deleted : {}, styles.emojisWithTextFontFamily]) + : ownerName} + + ); +} + +WorkspacesListRowDisplayName.displayName = 'WorkspacesListRowDisplayName'; + +export default WorkspacesListRowDisplayName; diff --git a/src/components/WorkspacesListRowDisplayName/index.tsx b/src/components/WorkspacesListRowDisplayName/index.tsx new file mode 100644 index 000000000000..0d3acb736d2f --- /dev/null +++ b/src/components/WorkspacesListRowDisplayName/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type WorkspacesListRowDisplayNameProps from './types'; + +function WorkspacesListRowDisplayName({isDeleted, ownerName}: WorkspacesListRowDisplayNameProps) { + const styles = useThemeStyles(); + + return ( + + {ownerName} + + ); +} + +WorkspacesListRowDisplayName.displayName = 'WorkspacesListRowDisplayName'; + +export default WorkspacesListRowDisplayName; diff --git a/src/components/WorkspacesListRowDisplayName/types.tsx b/src/components/WorkspacesListRowDisplayName/types.tsx new file mode 100644 index 000000000000..0744ebc18fc1 --- /dev/null +++ b/src/components/WorkspacesListRowDisplayName/types.tsx @@ -0,0 +1,9 @@ +type WorkspacesListRowDisplayNameProps = { + /** Should the deleted style be applied */ + isDeleted: boolean; + + /** Workspace owner name */ + ownerName: string; +}; + +export default WorkspacesListRowDisplayNameProps; diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index 2006ca85dd13..7b38cc12347f 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -10,7 +10,7 @@ const defaultEmptyArray: Array = []; function useMarkdownStyle(message: string | null = null, excludeStyles: Array = defaultEmptyArray): MarkdownStyle { const theme = useTheme(); const hasMessageOnlyEmojis = message != null && message.length > 0 && containsOnlyEmojis(message); - const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeNormal; + const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeEmojisWithinText; // this map is used to reset the styles that are not needed - passing undefined value can break the native side const nonStylingDefaultValues: Record = useMemo( @@ -38,6 +38,7 @@ function useMarkdownStyle(message: string | null = null, excludeStyles: Array ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), + selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, canUserPerformWriteAction, true), }); const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportIDWithDefault}`); diff --git a/src/languages/en.ts b/src/languages/en.ts index 591f7eb0ed42..9bea1261ddbd 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1173,7 +1173,9 @@ const translations = { updateRequiredView: { updateRequired: 'Update required', pleaseInstall: 'Please update to the latest version of New Expensify', + pleaseInstallExpensifyClassic: 'Please install the latest version of Expensify', toGetLatestChanges: 'For mobile or desktop, download and install the latest version. For web, refresh your browser.', + newAppNotAvailable: 'The New Expensify app is no longer available.', }, initialSettingsPage: { about: 'About', @@ -1465,7 +1467,7 @@ const translations = { workflowTitle: 'Spend', workflowDescription: 'Configure a workflow from the moment spend occurs, including approval and payment.', delaySubmissionTitle: 'Delay submissions', - delaySubmissionDescription: 'Delay expense submissions based on a custom schedule, or keep this option disabled to maintain realtime spend visibility.', + delaySubmissionDescription: 'Choose a custom schedule for submitting expenses, or leave this off for realtime updates on spending.', submissionFrequency: 'Submission frequency', submissionFrequencyDateOfMonth: 'Date of month', addApprovalsTitle: 'Add approvals', @@ -2369,10 +2371,25 @@ const translations = { agreementsStep: { agreements: 'Agreements', pleaseConfirm: 'Please confirm the agreements below', + regulationRequiresUs: 'Regulation requires us to verify the identity of any individual who owns more than 25% of the business.', + iAmAuthorized: 'I am authorized to use the business bank account for business spend.', + iCertify: 'I certify that the information provided is true and accurate.', + termsAndConditions: 'terms and conditions.', accept: 'Accept and add bank account', + error: { + authorized: 'You must be a controlling officer with authorization to operate the business bank account', + certify: 'Please certify that the information is true and accurate', + }, }, finishStep: { connect: 'Connect bank account', + letsFinish: "Let's finish in chat!", + thanksFor: + "Thanks for those details. A dedicated support agent will now review your information. We'll circle back if we need anything else from you, but in the meantime, feel free to reach out to us with any questions.", + iHaveA: 'I have a question', + enable2FA: 'Enable two-factor authentication (2FA) to prevent fraud', + weTake: 'We take your security seriously. Please set up 2FA now to add an extra layer of protection to your account.', + secure: 'Secure your account', }, reimbursementAccountLoadingAnimation: { oneMoment: 'One moment', @@ -3237,7 +3254,9 @@ const translations = { enableFeed: { title: ({provider}: GoBackMessageParams) => `Enable your ${provider} feed`, heading: 'We have a direct integration with your card issuer and can import your transaction data into Expensify quickly and accurately.\n\nTo get started, simply:', - vcf: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructions on how to set up your Visa Commercial Cards.\n\n2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them toenable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`, + visa: 'We have global integrations with Visa, though eligibility varies by bank and card program.\n\nTo get started, simply:', + mastercard: 'We have global integrations with Mastercard, though eligibility varies by bank and card program.\n\nTo get started, simply:', + vcf: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructions on how to set up your Visa Commercial Cards.\n\n2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them to enable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`, gl1025: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) to find out if American Express can enable a custom feed for your program.\n\n2. Once the feed is enabled, Amex will send you a production letter.\n\n3. *Once you have the feed information, continue to the next screen.*`, cdf: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructions on how to set up your Mastercard Commercial Cards.\n\n 2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them to enable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`, stripe: `1. Visit Stripe’s Dashboard, and go to [Settings](${CONST.COMPANY_CARDS_STRIPE_HELP}).\n\n2. Under Product Integrations, click Enable next to Expensify.\n\n3. Once the feed is enabled, click Submit below and we’ll work on adding it.`, @@ -3265,6 +3284,7 @@ const translations = { }, amexCorporate: 'Select this if the front of your cards say “Corporate”', amexBusiness: 'Select this if the front of your cards say “Business”', + amexPersonal: 'Select this if your cards are personal', error: { pleaseSelectProvider: 'Please select a card provider before continuing.', pleaseSelectBankAccount: 'Please select a bank account before continuing.', @@ -5308,6 +5328,9 @@ const translations = { createReportAction: 'Create Report Action', reportAction: 'Report Action', report: 'Report', + transaction: 'Transaction', + violations: 'Violations', + transactionViolation: 'Transaction Violation', hint: "Data changes won't be sent to the backend", textFields: 'Text fields', numberFields: 'Number fields', @@ -5323,6 +5346,8 @@ const translations = { true: 'true', false: 'false', viewReport: 'View Report', + viewTransaction: 'View transaction', + createTransactionViolation: 'Create transaction violation', reasonVisibleInLHN: { hasDraftComment: 'Has draft comment', hasGBR: 'Has GBR', diff --git a/src/languages/es.ts b/src/languages/es.ts index 91557c9defbf..7e6f8efc897a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -666,7 +666,7 @@ const translations = { beginningOfChatHistoryDomainRoomPartTwo: ' Úsalo para chatear con colegas, compartir consejos y hacer preguntas.', beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => `Este chat es con los administradores del espacio de trabajo ${workspaceName}.`, - beginningOfChatHistoryAdminRoomPartTwo: ' Use it to chat about workspace setup and more.', + beginningOfChatHistoryAdminRoomPartTwo: ' Úsalo para hablar sobre la configuración del espacio de trabajo y más.', beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => `Este chat es con todos en ${workspaceName}.`, beginningOfChatHistoryAnnounceRoomPartTwo: ` Úsalo para hablar sobre la configuración del espacio de trabajo y más.`, beginningOfChatHistoryUserRoomPartOne: 'ste chat es para todo lo relacionado con ', @@ -1171,13 +1171,15 @@ const translations = { }, updateRequiredView: { updateRequired: 'Actualización requerida', - pleaseInstall: 'Por favor, actualiza a la última versión de Nuevo Expensify', + pleaseInstall: 'Por favor, actualiza a la última versión de New Expensify', + pleaseInstallExpensifyClassic: 'Por favor, instala la última versión de Expensify', toGetLatestChanges: 'Para móvil o escritorio, descarga e instala la última versión. Para la web, actualiza tu navegador.', + newAppNotAvailable: 'La App New Expensify ya no está disponible.', }, initialSettingsPage: { about: 'Acerca de', aboutPage: { - description: 'La Nueva Expensify está creada por una comunidad de desarrolladores de código abierto de todo el mundo. Ayúdanos a construir el futuro de Expensify.', + description: 'New Expensify está creada por una comunidad de desarrolladores de código abierto de todo el mundo. Ayúdanos a construir el futuro de Expensify.', appDownloadLinks: 'Enlaces para descargar la App', viewKeyboardShortcuts: 'Ver atajos de teclado', viewTheCode: 'Ver código', @@ -1266,7 +1268,7 @@ const translations = { }, passwordPage: { changePassword: 'Cambiar contraseña', - changingYourPasswordPrompt: 'El cambio de contraseña va a afectar tanto a la cuenta de Expensify.com como la de Nuevo Expensify.', + changingYourPasswordPrompt: 'El cambio de contraseña va a afectar tanto a la cuenta de Expensify.com como la de New Expensify.', currentPassword: 'Contraseña actual', newPassword: 'Nueva contraseña', newPasswordPrompt: 'La nueva contraseña debe ser diferente de la antigua y contener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.', @@ -1466,7 +1468,7 @@ const translations = { workflowTitle: 'Gasto', workflowDescription: 'Configure un flujo de trabajo desde el momento en que se produce el gasto, incluida la aprobación y el pago', delaySubmissionTitle: 'Retrasar envíos', - delaySubmissionDescription: 'Retrasa la presentación de gastos en base a un calendario personalizado, o mantén esta opción desactivada para seguir viendo los gastos en tiempo real.', + delaySubmissionDescription: 'Elige una frecuencia para enviar los gastos, o dejalo desactivado para recibir actualizaciones en tiempo real sobre los gastos.', submissionFrequency: 'Frecuencia de envíos', submissionFrequencyDateOfMonth: 'Fecha del mes', addApprovalsTitle: 'Aprobaciones', @@ -1861,7 +1863,7 @@ const translations = { enterPassword: 'Escribe una contraseña', setPassword: 'Configura tu contraseña', newPasswordPrompt: 'La contraseña debe tener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.', - passwordFormTitle: '¡Bienvenido de vuelta al Nuevo Expensify! Por favor, elige una contraseña.', + passwordFormTitle: '¡Bienvenido de vuelta a New Expensify! Por favor, elige una contraseña.', passwordNotSet: 'No se pudo cambiar tu clave. Te hemos enviado un nuevo enlace para que intentes cambiar la clave nuevamente.', setPasswordLinkInvalid: 'El enlace para configurar tu contraseña ha expirado. Te hemos enviado un nuevo enlace a tu correo.', validateAccount: 'Verificar cuenta', @@ -2015,10 +2017,9 @@ const translations = { butFirst: 'Pero primero, lo aburrido. Lee la jerga legal en el siguiente paso y haz clic en "Aceptar" cuando estés listo.', genericError: 'Se ha producido un error al procesar este paso. Inténtalo de nuevo.', cameraPermissionsNotGranted: 'Permiso para acceder a la cámara', - cameraRequestMessage: 'Necesitamos acceso a tu cámara para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.', + cameraRequestMessage: 'Necesitamos acceso a tu cámara para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > New Expensify.', microphonePermissionsNotGranted: 'Permiso para acceder al micrófono', - microphoneRequestMessage: - 'Necesitamos acceso a tu micrófono para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.', + microphoneRequestMessage: 'Necesitamos acceso a tu micrófono para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > New Expensify.', originalDocumentNeeded: 'Por favor, sube una imagen original de tu identificación en lugar de una captura de pantalla o imagen escaneada.', documentNeedsBetterQuality: 'Parece que tu identificación esta dañado o le faltan características de seguridad. Por favor, sube una imagen de tu documento sin daños y que se vea completamente.', @@ -2380,7 +2381,7 @@ const translations = { uploadID: 'Subir documento de identidad y prueba de domicilio', id: 'Identificación (licencia de conducir o pasaporte)', personalAddress: 'Prueba de domicilio personal (por ejemplo, factura de servicios públicos)', - letsDoubleCheck: 'Vamos a comprobar que todo está bien.', + letsDoubleCheck: 'Vamos a verificar que todo esté correcto.', legalName: 'Nombre legal', proofOf: 'Comprobante de domicilio personal', enterOneEmail: 'Introduce el correo electrónico del director o alto funcionario en', @@ -2394,10 +2395,25 @@ const translations = { agreementsStep: { agreements: 'Acuerdos', pleaseConfirm: 'Por favor confirme los acuerdos a continuación', - accept: 'Aceptar y añadir cuenta bancaria', + regulationRequiresUs: 'La normativa requiere que verifiquemos la identidad de cualquier individuo que posea más del 25% del negocio.', + iAmAuthorized: 'Estoy autorizado para usar la cuenta bancaria para gastos del negocio.', + iCertify: 'Certifico que la información proporcionada es verdadera y correcta.', + termsAndConditions: 'términos y condiciones.', + accept: 'Agregar y aceptar cuenta bancaria', + error: { + authorized: 'Debe ser un funcionario controlador con autorización para operar la cuenta bancaria comercial', + certify: 'Por favor certifique que la información es verdadera y exacta', + }, }, finishStep: { connect: 'Conectar cuenta bancaria', + letsFinish: '¡Terminemos en el chat!', + thanksFor: + 'Gracias por esos detalles. Un agente de soporte dedicado revisará ahora tu información. Nos pondremos en contacto si necesitamos algo más de tu parte, pero mientras tanto, no dudes en comunicarte con nosotros si tienes alguna pregunta.', + iHaveA: 'Tengo una pregunta', + enable2FA: 'Habilite la autenticación de dos factores (2FA) para prevenir fraudes', + weTake: 'Nos tomamos su seguridad en serio. Por favor, configure 2FA ahora para agregar una capa adicional de protección a su cuenta.', + secure: 'Asegure su cuenta', }, reimbursementAccountLoadingAnimation: { oneMoment: 'Un momento', @@ -3278,6 +3294,8 @@ const translations = { title: ({provider}: GoBackMessageParams) => `Habilita tu feed ${provider}`, heading: 'Tenemos una integración directa con el emisor de su tarjeta y podemos importar los datos de sus transacciones a Expensify de forma rápida y precisa.\n\nPara empezar, simplemente:', + visa: 'Contamos con integraciones globales con Visa, aunque la elegibilidad varía según el banco y el programa de la tarjeta.\n\nTPara empezar, simplemente:', + mastercard: 'Contamos con integraciones globales con Mastercard, aunque la elegibilidad varía según el banco y el programa de la tarjeta.\n\nPara empezar, simplemente:', vcf: `1. Visite [este artículo de ayuda](${CONST.COMPANY_CARDS_HELP}) para obtener instrucciones detalladas sobre cómo configurar sus tarjetas comerciales Visa.\n\n2. [Póngase en contacto con su banco](${CONST.COMPANY_CARDS_HELP}) para comprobar que admiten un feed personalizado para su programa, y pídales que lo activen.\n\n3. *Una vez que el feed esté habilitado y tengas sus datos, pasa a la siguiente pantalla.*`, gl1025: `1. Visite [este artículo de ayuda](${CONST.COMPANY_CARDS_HELP}) para saber si American Express puede habilitar un feed personalizado para su programa.\n\n2. Una vez activada la alimentación, Amex le enviará una carta de producción.\n\n3. *Una vez que tenga la información de alimentación, continúe con la siguiente pantalla.*`, cdf: `1. Visite [este artículo de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS}) para obtener instrucciones detalladas sobre cómo configurar sus tarjetas comerciales Mastercard.\n\n 2. [Póngase en contacto con su banco](${CONST.COMPANY_CARDS_HELP}) para verificar que admiten un feed personalizado para su programa, y pídales que lo habiliten.\n\n3. *Una vez que el feed esté habilitado y tengas sus datos, pasa a la siguiente pantalla.*`, @@ -3306,6 +3324,7 @@ const translations = { }, amexCorporate: 'Seleccione esto si el frente de sus tarjetas dice “Corporativa”', amexBusiness: 'Seleccione esta opción si el frente de sus tarjetas dice “Negocios”', + amexPersonal: 'Selecciona esta opción si tus tarjetas son personales', error: { pleaseSelectProvider: 'Seleccione un proveedor de tarjetas antes de continuar.', pleaseSelectBankAccount: 'Seleccione una cuenta bancaria antes de continuar.', @@ -4610,17 +4629,17 @@ const translations = { }, }, desktopApplicationMenu: { - mainMenu: 'Nuevo Expensify', - about: 'Sobre Nuevo Expensify', - update: 'Actualizar Nuevo Expensify', + mainMenu: 'New Expensify', + about: 'Sobre New Expensify', + update: 'Actualizar New Expensify', checkForUpdates: 'Buscar actualizaciones', toggleDevTools: 'Ver herramientas de desarrollo', viewShortcuts: 'Ver atajos de teclado', services: 'Servicios', - hide: 'Ocultar Nuevo Expensify', + hide: 'Ocultar New Expensify', hideOthers: 'Ocultar otros', showAll: 'Mostrar todos', - quit: 'Salir de Nuevo Expensify', + quit: 'Salir de New Expensify', fileMenu: 'Archivo', closeWindow: 'Cerrar ventana', editMenu: 'Editar', @@ -5828,6 +5847,9 @@ const translations = { createReportAction: 'Crear Report Action', reportAction: 'Report Action', report: 'Report', + transaction: 'Transacción', + violations: 'Violaciones', + transactionViolation: 'Violación de transacción', hint: 'Los cambios de datos no se enviarán al backend', textFields: 'Campos de texto', numberFields: 'Campos numéricos', @@ -5843,6 +5865,8 @@ const translations = { true: 'verdadero', false: 'falso', viewReport: 'Ver Informe', + viewTransaction: 'Ver transacción', + createTransactionViolation: 'Crear infracción de transacción', reasonVisibleInLHN: { hasDraftComment: 'Tiene comentario en borrador', hasGBR: 'Tiene GBR', diff --git a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts index d999f96fb505..78eb0adecc5e 100644 --- a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts +++ b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts @@ -20,8 +20,6 @@ type CategorizeTrackedExpenseParams = { taxCode: string; taxAmount: number; billable?: boolean; - waypoints?: string; - customUnitRateID?: string; }; export default CategorizeTrackedExpenseParams; diff --git a/src/libs/API/parameters/ShareTrackedExpenseParams.ts b/src/libs/API/parameters/ShareTrackedExpenseParams.ts index c89c0d400e72..cee4bc40d9ac 100644 --- a/src/libs/API/parameters/ShareTrackedExpenseParams.ts +++ b/src/libs/API/parameters/ShareTrackedExpenseParams.ts @@ -20,8 +20,6 @@ type ShareTrackedExpenseParams = { taxCode: string; taxAmount: number; billable?: boolean; - customUnitRateID?: string; - waypoints?: string; }; export default ShareTrackedExpenseParams; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index d0e203342aba..671fb03f268b 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1,13 +1,18 @@ +/* eslint-disable default-case */ + /* eslint-disable max-classes-per-file */ import {isMatch, isValid} from 'date-fns'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {TupleToUnion} from 'type-fest'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, Policy, Report, ReportAction, ReportActions, TransactionViolation} from '@src/types/onyx'; +import type {Beta, Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolation} from '@src/types/onyx'; +import * as ReportActionsUtils from './ReportActionsUtils'; import * as ReportUtils from './ReportUtils'; import SidebarUtils from './SidebarUtils'; +import * as TransactionUtils from './TransactionUtils'; class NumberError extends SyntaxError { constructor() { @@ -35,39 +40,25 @@ class ObjectError extends SyntaxError { } } -type ObjectType = Record; +type ObjectType> = Record; -type ConstantEnum = Record; +type ConstantEnum = Record>; type PropertyTypes = Array<'string' | 'number' | 'object' | 'boolean' | 'undefined'>; -const OPTIONAL_BOOLEAN_STRINGS = ['true', 'false', 'undefined']; +type ArrayTypeFromOnyxDefinition = T extends unknown[] ? NonNullable : never; + +type ArrayElement, K extends keyof TOnyx> = ArrayTypeFromOnyxDefinition[K]>; -const REPORT_NUMBER_PROPERTIES: Array = [ - 'lastReadSequenceNumber', - 'managerID', - 'lastActorAccountID', - 'ownerAccountID', - 'total', - 'unheldTotal', - 'nonReimbursableTotal', -] satisfies Array; - -const REPORT_BOOLEAN_PROPERTIES: Array = [ - 'hasOutstandingChildRequest', - 'hasOutstandingChildTask', - 'isOwnPolicyExpenseChat', - 'isPinned', - 'hasParentAccess', - 'isDeletedParentAction', - 'isOptimisticReport', - 'isWaitingOnBankAccount', - 'isCancelledIOU', - 'isHidden', - 'isLoadingPrivateNotes', -] satisfies Array; - -const REPORT_DATE_PROPERTIES: Array = ['lastVisibleActionCreated', 'lastReadTime', 'lastMentionedTime', 'lastVisibleActionLastModified'] satisfies Array; +type KeysOfUnion = T extends T ? keyof T : never; + +type ObjectElement = Required[K] extends Record + ? TCollectionKey extends string | number + ? {[ValueTypeKey in KeysOfUnion]: ValueType[ValueTypeKey]} + : {[ElementKey in KeysOfUnion[K]>]: Required[K]>[ElementKey]} + : never; + +const OPTIONAL_BOOLEAN_STRINGS = ['true', 'false', 'undefined']; const REPORT_REQUIRED_PROPERTIES: Array = ['reportID'] satisfies Array; @@ -89,18 +80,9 @@ const REPORT_ACTION_NUMBER_PROPERTIES: Array = [ 'timestamp', ] satisfies Array; -const REPORT_ACTION_BOOLEAN_PROPERTIES: Array = [ - 'isLoading', - 'automatic', - 'shouldShow', - 'isFirstItem', - 'isAttachmentOnly', - 'isAttachmentWithText', - 'isNewestReportAction', - 'isOptimisticAction', -] satisfies Array; +const TRANSACTION_REQUIRED_PROPERTIES: Array = ['transactionID', 'reportID', 'amount', 'created', 'currency', 'merchant'] satisfies Array; -const REPORT_ACTION_DATE_PROPERTIES: Array = ['created', 'lastModified'] satisfies Array; +const TRANSACTION_VIOLATION_REQUIRED_PROPERTIES: Array = ['type', 'name'] satisfies Array; let isInFocusMode: OnyxEntry; Onyx.connect({ @@ -175,6 +157,10 @@ type OnyxData = (T extends 'number' ? number : T extends * @throws {SyntaxError} if type is object but the provided string does not represent an object */ function stringToOnyxData(data: string, type?: T): OnyxData { + if (isEmptyValue(data)) { + return data as OnyxData; + } + let onyxData; switch (type) { @@ -236,11 +222,28 @@ function onyxDataToDraftData(data: OnyxEntry>) { return Object.fromEntries(Object.entries(data ?? {}).map(([key, value]) => [key, onyxDataToString(value)])); } +/** + * Whether a string representation is an empty value + * + * @param value - string representantion + * @returns whether the value is an empty value + */ +function isEmptyValue(value: string): boolean { + switch (value) { + case 'undefined': + case 'null': + case '': + return true; + default: + return false; + } +} + /** * Validates if a string is a valid representation of a number. */ function validateNumber(value: string) { - if (value === 'undefined' || value === '' || (!value.includes(' ') && !Number.isNaN(Number(value)))) { + if (isEmptyValue(value) || (!value.includes(' ') && !Number.isNaN(Number(value)))) { return; } @@ -262,7 +265,7 @@ function validateBoolean(value: string) { * Validates if a string is a valid representation of a date. */ function validateDate(value: string) { - if (value === 'undefined' || (isMatch(value, CONST.DATE.FNS_DB_FORMAT_STRING) && isValid(new Date(value)))) { + if (isEmptyValue(value) || ((isMatch(value, CONST.DATE.FNS_DB_FORMAT_STRING) || isMatch(value, CONST.DATE.FNS_FORMAT_STRING)) && isValid(new Date(value)))) { return; } @@ -280,7 +283,7 @@ function validateConstantEnum(value: string, constEnum: ConstantEnum) { return String(val); }); - if (value === 'undefined' || value === '' || enumValues.includes(value)) { + if (isEmptyValue(value) || enumValues.includes(value)) { return; } @@ -290,11 +293,15 @@ function validateConstantEnum(value: string, constEnum: ConstantEnum) { /** * Validates if a string is a valid representation of an array. */ -function validateArray( +function validateArray | 'constantEnum' = 'string'>( value: string, - arrayType: 'string' | 'number' | 'boolean' | ConstantEnum | Record, + arrayType: T extends Record + ? Record + : T extends 'constantEnum' + ? ConstantEnum + : T, ) { - if (value === 'undefined') { + if (isEmptyValue(value)) { return; } @@ -307,22 +314,22 @@ function validateArray( array.forEach((element) => { // Element is an object if (element && typeof element === 'object' && typeof arrayType === 'object') { - Object.entries(arrayType).forEach(([key, val]) => { - const property = element[key as keyof typeof element]; + Object.entries(element).forEach(([key, val]) => { + const expectedType = arrayType[key as keyof typeof arrayType]; // Property is a constant enum, so we apply validateConstantEnum - if (typeof val === 'object' && !Array.isArray(val)) { - return validateConstantEnum(property, val as ConstantEnum); + if (typeof expectedType === 'object' && !Array.isArray(expectedType)) { + return validateConstantEnum(String(val), expectedType as ConstantEnum); } // Expected property type is array - if (val === 'array') { + if (expectedType === 'array') { // Property type is not array - if (!Array.isArray(property)) { + if (!Array.isArray(val)) { throw new ArrayError(arrayType); } return; } // Property type is not one of the valid types - if (Array.isArray(val) ? !val.includes(typeof property) : typeof property !== val) { + if (Array.isArray(expectedType) ? !expectedType.includes(typeof val as TupleToUnion) : typeof val !== expectedType) { throw new ArrayError(arrayType); } }); @@ -346,8 +353,8 @@ function validateArray( /** * Validates if a string is a valid representation of an object. */ -function validateObject(value: string, type: ObjectType, collectionIndexType?: 'string' | 'number') { - if (value === 'undefined') { +function validateObject>(value: string, type: ObjectType, collectionIndexType?: 'string' | 'number') { + if (isEmptyValue(value)) { return; } @@ -357,7 +364,7 @@ function validateObject(value: string, type: ObjectType, collectionIndexType?: ' } : type; - const object = parseJSON(value) as ObjectType; + const object = parseJSON(value) as ObjectType; if (typeof object !== 'object' || Array.isArray(object)) { throw new ObjectError(expectedType); @@ -382,12 +389,13 @@ function validateObject(value: string, type: ObjectType, collectionIndexType?: ' throw new ObjectError(expectedType); } - Object.entries(type).forEach(([key, val]) => { - // test[key] is a constant enum - if (typeof val === 'object') { - return validateConstantEnum(test[key] as string, val); + Object.entries(test).forEach(([key, val]) => { + const expectedValueType = type[key]; + // val is a constant enum + if (typeof expectedValueType === 'object') { + return validateConstantEnum(val as string, expectedValueType); } - if (val === 'array' ? !Array.isArray(test[key]) : typeof test[key] !== val) { + if (expectedValueType === 'array' ? !Array.isArray(val) : typeof val !== expectedValueType) { throw new ObjectError(expectedType); } }); @@ -398,7 +406,7 @@ function validateObject(value: string, type: ObjectType, collectionIndexType?: ' * Validates if a string is a valid representation of a string. */ function validateString(value: string) { - if (value === 'undefined') { + if (isEmptyValue(value)) { return; } @@ -416,6 +424,17 @@ function validateString(value: string) { } } +/** + * Execute validation of a union type (e.g. Record | Array) + */ +function unionValidation(firstValidation: () => void, secondValidation: () => void) { + try { + firstValidation(); + } catch (e) { + secondValidation(); + } +} + /** * Validates if a property of Report is of the expected type * @@ -423,78 +442,200 @@ function validateString(value: string) { * @param value - value provided by the user */ function validateReportDraftProperty(key: keyof Report, value: string) { - if (REPORT_REQUIRED_PROPERTIES.includes(key) && value === 'undefined') { + if (REPORT_REQUIRED_PROPERTIES.includes(key) && isEmptyValue(value)) { throw SyntaxError('debug.missingValue'); } - if (key === 'privateNotes') { - return validateObject( - value, - { - note: 'string', - }, - 'number', - ); - } - if (key === 'permissions') { - return validateArray(value, CONST.REPORT.PERMISSIONS); - } - if (key === 'pendingChatMembers') { - return validateArray(value, { - accountID: 'string', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, - }); - } - if (key === 'participants') { - return validateObject( - value, - { - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE, - }, - 'number', - ); - } - if (REPORT_NUMBER_PROPERTIES.includes(key)) { - return validateNumber(value); - } - if (REPORT_BOOLEAN_PROPERTIES.includes(key)) { - return validateBoolean(value); - } - if (REPORT_DATE_PROPERTIES.includes(key)) { - return validateDate(value); - } - if (key === 'tripData') { - return validateObject(value, { - startDate: 'string', - endDate: 'string', - tripID: 'string', - }); - } - if (key === 'lastActionType') { - return validateConstantEnum(value, CONST.REPORT.ACTIONS.TYPE); - } - if (key === 'writeCapability') { - return validateConstantEnum(value, CONST.REPORT.WRITE_CAPABILITIES); - } - if (key === 'visibility') { - return validateConstantEnum(value, CONST.REPORT.VISIBILITY); - } - if (key === 'stateNum') { - return validateConstantEnum(value, CONST.REPORT.STATE_NUM); - } - if (key === 'statusNum') { - return validateConstantEnum(value, CONST.REPORT.STATUS_NUM); - } - if (key === 'chatType') { - return validateConstantEnum(value, CONST.REPORT.CHAT_TYPE); - } - if (key === 'errorFields') { - return validateObject(value, {}); - } - if (key === 'pendingFields') { - return validateObject(value, {}); + switch (key) { + case 'avatarUrl': + case 'lastMessageText': + case 'lastVisibleActionCreated': + case 'lastReadTime': + case 'lastMentionedTime': + case 'policyAvatar': + case 'policyName': + case 'oldPolicyName': + case 'description': + case 'policyID': + case 'reportName': + case 'reportID': + case 'reportActionID': + case 'chatReportID': + case 'type': + case 'lastMessageTranslationKey': + case 'parentReportID': + case 'parentReportActionID': + case 'lastVisibleActionLastModified': + case 'lastMessageHtml': + case 'currency': + case 'iouReportID': + case 'preexistingReportID': + case 'private_isArchived': + return validateString(value); + case 'hasOutstandingChildRequest': + case 'hasOutstandingChildTask': + case 'isOwnPolicyExpenseChat': + case 'isPinned': + case 'hasParentAccess': + case 'isDeletedParentAction': + case 'isOptimisticReport': + case 'isWaitingOnBankAccount': + case 'isCancelledIOU': + case 'isHidden': + return validateBoolean(value); + case 'lastReadSequenceNumber': + case 'managerID': + case 'lastActorAccountID': + case 'ownerAccountID': + case 'total': + case 'unheldTotal': + case 'nonReimbursableTotal': + return validateNumber(value); + case 'chatType': + return validateConstantEnum(value, CONST.REPORT.CHAT_TYPE); + case 'stateNum': + return validateConstantEnum(value, CONST.REPORT.STATE_NUM); + case 'statusNum': + return validateConstantEnum(value, CONST.REPORT.STATUS_NUM); + case 'writeCapability': + return validateConstantEnum(value, CONST.REPORT.WRITE_CAPABILITIES); + case 'visibility': + return validateConstantEnum(value, CONST.REPORT.VISIBILITY); + case 'invoiceReceiver': + return validateObject>(value, { + type: 'string', + policyID: 'string', + accountID: 'string', + }); + case 'lastActionType': + return validateConstantEnum(value, CONST.REPORT.ACTIONS.TYPE); + case 'participants': + return validateObject>( + value, + { + role: CONST.REPORT.ROLE, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, + pendingFields: 'object', + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE, + }, + 'number', + ); + case 'errorFields': + return validateObject>(value, {}, 'string'); + case 'privateNotes': + return validateObject>( + value, + { + note: 'string', + errors: 'string', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, + pendingFields: 'object', + }, + 'number', + ); + case 'pendingChatMembers': + return validateArray>(value, { + accountID: 'string', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, + errors: 'object', + }); + case 'fieldList': + return validateObject>( + value, + { + fieldID: 'string', + type: 'string', + name: 'string', + keys: 'array', + values: 'array', + defaultValue: 'string', + orderWeight: 'number', + deletable: 'boolean', + value: 'string', + target: 'string', + externalIDs: 'array', + disabledOptions: 'array', + isTax: 'boolean', + externalID: 'string', + origin: 'string', + defaultExternalID: 'string', + }, + 'string', + ); + case 'permissions': + return validateArray<'constantEnum'>(value, CONST.REPORT.PERMISSIONS); + case 'tripData': + return validateObject>(value, { + startDate: 'string', + endDate: 'string', + tripID: 'string', + }); + case 'pendingAction': + return validateConstantEnum(value, CONST.RED_BRICK_ROAD_PENDING_ACTION); + case 'pendingFields': + return validateObject>(value, { + description: CONST.RED_BRICK_ROAD_PENDING_ACTION, + privateNotes: CONST.RED_BRICK_ROAD_PENDING_ACTION, + currency: CONST.RED_BRICK_ROAD_PENDING_ACTION, + type: CONST.RED_BRICK_ROAD_PENDING_ACTION, + policyID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + avatarUrl: CONST.RED_BRICK_ROAD_PENDING_ACTION, + chatType: CONST.RED_BRICK_ROAD_PENDING_ACTION, + hasOutstandingChildRequest: CONST.RED_BRICK_ROAD_PENDING_ACTION, + hasOutstandingChildTask: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isOwnPolicyExpenseChat: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isPinned: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastMessageText: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastVisibleActionCreated: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastReadTime: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastReadSequenceNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastMentionedTime: CONST.RED_BRICK_ROAD_PENDING_ACTION, + policyAvatar: CONST.RED_BRICK_ROAD_PENDING_ACTION, + policyName: CONST.RED_BRICK_ROAD_PENDING_ACTION, + oldPolicyName: CONST.RED_BRICK_ROAD_PENDING_ACTION, + hasParentAccess: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isDeletedParentAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, + reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION, + reportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + chatReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + stateNum: CONST.RED_BRICK_ROAD_PENDING_ACTION, + statusNum: CONST.RED_BRICK_ROAD_PENDING_ACTION, + writeCapability: CONST.RED_BRICK_ROAD_PENDING_ACTION, + visibility: CONST.RED_BRICK_ROAD_PENDING_ACTION, + invoiceReceiver: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastMessageTranslationKey: CONST.RED_BRICK_ROAD_PENDING_ACTION, + parentReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + parentReportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isOptimisticReport: CONST.RED_BRICK_ROAD_PENDING_ACTION, + managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastVisibleActionLastModified: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastMessageHtml: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastActorAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastActionType: CONST.RED_BRICK_ROAD_PENDING_ACTION, + ownerAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + participants: CONST.RED_BRICK_ROAD_PENDING_ACTION, + total: CONST.RED_BRICK_ROAD_PENDING_ACTION, + unheldTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isWaitingOnBankAccount: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isCancelledIOU: CONST.RED_BRICK_ROAD_PENDING_ACTION, + iouReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + preexistingReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + nonReimbursableTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isHidden: CONST.RED_BRICK_ROAD_PENDING_ACTION, + pendingChatMembers: CONST.RED_BRICK_ROAD_PENDING_ACTION, + fieldList: CONST.RED_BRICK_ROAD_PENDING_ACTION, + permissions: CONST.RED_BRICK_ROAD_PENDING_ACTION, + tripData: CONST.RED_BRICK_ROAD_PENDING_ACTION, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: CONST.RED_BRICK_ROAD_PENDING_ACTION, + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION, + avatar: CONST.RED_BRICK_ROAD_PENDING_ACTION, + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION, + partial: CONST.RED_BRICK_ROAD_PENDING_ACTION, + reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION, + preview: CONST.RED_BRICK_ROAD_PENDING_ACTION, + }); } - - validateString(value); } /** @@ -504,49 +645,608 @@ function validateReportDraftProperty(key: keyof Report, value: string) { * @param value - value provided by the user */ function validateReportActionDraftProperty(key: keyof ReportAction, value: string) { - if (REPORT_ACTION_REQUIRED_PROPERTIES.includes(key) && value === 'undefined') { + if (REPORT_ACTION_REQUIRED_PROPERTIES.includes(key) && isEmptyValue(value)) { throw SyntaxError('debug.missingValue'); } - if (REPORT_ACTION_NUMBER_PROPERTIES.includes(key)) { - return validateNumber(value); - } - if (REPORT_ACTION_BOOLEAN_PROPERTIES.includes(key)) { - return validateBoolean(value); - } - if (key === 'actionName') { - return validateConstantEnum(value, CONST.REPORT.ACTIONS.TYPE); - } - if (key === 'childStatusNum') { - return validateConstantEnum(value, CONST.REPORT.STATUS_NUM); - } - if (key === 'childStateNum') { - return validateConstantEnum(value, CONST.REPORT.STATE_NUM); - } - if (key === 'childReportNotificationPreference') { - return validateConstantEnum(value, CONST.REPORT.NOTIFICATION_PREFERENCE); - } - if (REPORT_ACTION_DATE_PROPERTIES.includes(key)) { - return validateDate(value); - } - if (key === 'whisperedToAccountIDs') { - return validateArray(value, 'number'); - } - if (key === 'message') { - return validateArray(value, {text: 'string', html: ['string', 'undefined'], type: 'string'}); + switch (key) { + case 'reportID': + case 'reportActionID': + case 'parentReportID': + case 'childReportID': + case 'childReportName': + case 'childType': + case 'childOldestFourAccountIDs': + case 'childLastVisibleActionCreated': + case 'actor': + case 'avatar': + case 'childLastMoneyRequestComment': + case 'reportActionTimestamp': + case 'timestamp': + case 'error': + return validateString(value); + case 'actorAccountID': + case 'sequenceNumber': + case 'accountID': + case 'childCommenterCount': + case 'childVisibleActionCount': + case 'childManagerAccountID': + case 'childOwnerAccountID': + case 'childLastActorAccountID': + case 'childMoneyRequestCount': + case 'adminAccountID': + case 'delegateAccountID': + return validateNumber(value); + case 'isLoading': + case 'automatic': + case 'shouldShow': + case 'isFirstItem': + case 'isAttachmentOnly': + case 'isAttachmentWithText': + case 'isNewestReportAction': + case 'isOptimisticAction': + return validateBoolean(value); + case 'created': + case 'lastModified': + return validateDate(value); + case 'errors': + return validateObject>(value, {}); + case 'pendingAction': + return validateConstantEnum(value, CONST.RED_BRICK_ROAD_PENDING_ACTION); + case 'pendingFields': + return validateObject>(value, { + reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + reportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + parentReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + errors: CONST.RED_BRICK_ROAD_PENDING_ACTION, + sequenceNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION, + actionName: CONST.RED_BRICK_ROAD_PENDING_ACTION, + actorAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + actor: CONST.RED_BRICK_ROAD_PENDING_ACTION, + person: CONST.RED_BRICK_ROAD_PENDING_ACTION, + created: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isLoading: CONST.RED_BRICK_ROAD_PENDING_ACTION, + avatar: CONST.RED_BRICK_ROAD_PENDING_ACTION, + automatic: CONST.RED_BRICK_ROAD_PENDING_ACTION, + shouldShow: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childReportName: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childType: CONST.RED_BRICK_ROAD_PENDING_ACTION, + accountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childOldestFourAccountIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childCommenterCount: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childLastVisibleActionCreated: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childVisibleActionCount: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childManagerAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childOwnerAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childStatusNum: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childStateNum: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childLastMoneyRequestComment: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childLastActorAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childMoneyRequestCount: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isFirstItem: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isAttachmentOnly: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isAttachmentWithText: CONST.RED_BRICK_ROAD_PENDING_ACTION, + receipt: CONST.RED_BRICK_ROAD_PENDING_ACTION, + lastModified: CONST.RED_BRICK_ROAD_PENDING_ACTION, + delegateAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + error: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childRecentReceiptTransactionIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION, + linkMetadata: CONST.RED_BRICK_ROAD_PENDING_ACTION, + childReportNotificationPreference: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isNewestReportAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isOptimisticAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, + adminAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + whisperedToAccountIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION, + reportActionTimestamp: CONST.RED_BRICK_ROAD_PENDING_ACTION, + timestamp: CONST.RED_BRICK_ROAD_PENDING_ACTION, + }); + case 'actionName': + return validateConstantEnum(value, CONST.REPORT.ACTIONS.TYPE); + case 'person': + return validateArray>(value, { + type: 'string', + text: 'string', + style: 'string', + }); + case 'childStatusNum': + return validateConstantEnum(value, CONST.REPORT.STATUS_NUM); + case 'childStateNum': + return validateConstantEnum(value, CONST.REPORT.STATE_NUM); + case 'receipt': + return validateObject>(value, { + state: 'string', + type: 'string', + name: 'string', + receiptID: 'string', + source: 'string', + filename: 'string', + reservationList: 'string', + }); + case 'childRecentReceiptTransactionIDs': + return validateObject>(value, {}, 'string'); + case 'linkMetadata': + return validateArray>(value, { + url: 'string', + image: 'object', + description: 'string', + title: 'string', + publisher: 'string', + logo: 'object', + }); + case 'childReportNotificationPreference': + return validateConstantEnum(value, CONST.REPORT.NOTIFICATION_PREFERENCE); + case 'whisperedToAccountIDs': + return validateArray(value, 'number'); + case 'message': + return unionValidation( + () => + validateArray>(value, { + text: 'string', + html: 'string', + type: 'string', + isDeletedParentAction: 'boolean', + policyID: 'string', + reportID: 'string', + currency: 'string', + amount: 'number', + style: 'string', + target: 'string', + href: 'string', + iconUrl: 'string', + isEdited: 'boolean', + isReversedTransaction: 'boolean', + whisperedTo: 'array', + moderationDecision: 'object', + translationKey: 'string', + taskReportID: 'string', + cancellationReason: 'string', + expenseReportID: 'string', + resolution: { + ...CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION, + ...CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION, + }, + deleted: 'string', + }), + () => + validateObject>(value, { + html: 'string', + text: 'string', + amount: 'string', + currency: 'string', + type: 'string', + policyID: 'string', + reportID: 'string', + isDeletedParentAction: 'boolean', + target: 'string', + style: 'string', + href: 'string', + iconUrl: 'boolean', + isEdited: 'boolean', + isReversedTransaction: 'boolean', + whisperedTo: 'array', + moderationDecision: 'object', + translationKey: 'string', + taskReportID: 'string', + cancellationReason: 'string', + expenseReportID: 'string', + resolution: { + ...CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION, + ...CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION, + }, + deleted: 'string', + }), + ); + case 'originalMessage': + return validateObject>(value, {}); + case 'previousMessage': + return unionValidation( + () => + validateObject>(value, { + html: 'string', + text: 'string', + amount: 'string', + currency: 'string', + type: 'string', + policyID: 'string', + reportID: 'string', + style: 'string', + target: 'string', + href: 'string', + iconUrl: 'string', + isEdited: 'boolean', + isDeletedParentAction: 'boolean', + isReversedTransaction: 'boolean', + whisperedTo: 'array', + moderationDecision: 'string', + translationKey: 'string', + taskReportID: 'string', + cancellationReason: 'string', + expenseReportID: 'string', + resolution: 'string', + deleted: 'string', + }), + () => + validateArray>(value, { + reportID: 'string', + html: 'string', + text: 'string', + amount: 'string', + currency: 'string', + type: 'string', + policyID: 'string', + style: 'string', + target: 'string', + href: 'string', + iconUrl: 'string', + isEdited: 'string', + isDeletedParentAction: 'string', + isReversedTransaction: 'string', + whisperedTo: 'string', + moderationDecision: 'string', + translationKey: 'string', + taskReportID: 'string', + cancellationReason: 'string', + expenseReportID: 'string', + resolution: 'string', + deleted: 'string', + }), + ); } - if (key === 'person') { - return validateArray(value, {}); +} + +/** + * Validates if a property of Transaction is of the expected type + * + * @param key - property key + * @param value - value provided by the user + */ +function validateTransactionDraftProperty(key: keyof Transaction, value: string) { + if (TRANSACTION_REQUIRED_PROPERTIES.includes(key) && isEmptyValue(value)) { + throw SyntaxError('debug.missingValue'); } - if (key === 'errors') { - return validateObject(value, {}); + switch (key) { + case 'reportID': + case 'currency': + case 'tag': + case 'category': + case 'merchant': + case 'taxCode': + case 'filename': + case 'modifiedCurrency': + case 'modifiedMerchant': + case 'transactionID': + case 'parentTransactionID': + case 'originalCurrency': + case 'actionableWhisperReportActionID': + case 'linkedTrackedExpenseReportID': + case 'bank': + case 'cardName': + case 'cardNumber': + return validateString(value); + case 'created': + case 'modifiedCreated': + return validateDate(value); + case 'isLoading': + case 'billable': + case 'reimbursable': + case 'participantsAutoAssigned': + case 'isFromGlobalCreate': + case 'hasEReceipt': + case 'shouldShowOriginalAmount': + case 'managedCard': + return validateBoolean(value); + case 'amount': + case 'taxAmount': + case 'modifiedAmount': + case 'cardID': + case 'originalAmount': + return validateNumber(value); + case 'iouRequestType': + return validateConstantEnum(value, CONST.IOU.REQUEST_TYPE); + case 'participants': + return validateArray>(value, { + accountID: 'number', + login: 'string', + displayName: 'string', + isPolicyExpenseChat: 'boolean', + isInvoiceRoom: 'boolean', + isOwnPolicyExpenseChat: 'boolean', + chatType: CONST.REPORT.CHAT_TYPE, + reportID: 'string', + policyID: 'string', + selected: 'boolean', + searchText: 'string', + alternateText: 'string', + firstName: 'string', + keyForList: 'string', + lastName: 'string', + phoneNumber: 'string', + text: 'string', + isSelected: 'boolean', + isSelfDM: 'boolean', + isSender: 'boolean', + iouType: CONST.IOU.TYPE, + ownerAccountID: 'number', + icons: 'array', + item: 'string', + }); + case 'errors': + return validateObject>(value, {}); + case 'errorFields': + return validateObject>( + value, + { + route: 'object', + }, + 'string', + ); + case 'pendingAction': + return validateConstantEnum(value, CONST.RED_BRICK_ROAD_PENDING_ACTION); + case 'pendingFields': + return validateObject>( + value, + { + comment: CONST.RED_BRICK_ROAD_PENDING_ACTION, + hold: CONST.RED_BRICK_ROAD_PENDING_ACTION, + waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isLoading: CONST.RED_BRICK_ROAD_PENDING_ACTION, + type: CONST.RED_BRICK_ROAD_PENDING_ACTION, + customUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION, + source: CONST.RED_BRICK_ROAD_PENDING_ACTION, + originalTransactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + splits: CONST.RED_BRICK_ROAD_PENDING_ACTION, + dismissedViolations: CONST.RED_BRICK_ROAD_PENDING_ACTION, + customUnitID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + customUnitRateID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + quantity: CONST.RED_BRICK_ROAD_PENDING_ACTION, + name: CONST.RED_BRICK_ROAD_PENDING_ACTION, + defaultP2PRate: CONST.RED_BRICK_ROAD_PENDING_ACTION, + distanceUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION, + attendees: CONST.RED_BRICK_ROAD_PENDING_ACTION, + amount: CONST.RED_BRICK_ROAD_PENDING_ACTION, + taxAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION, + taxCode: CONST.RED_BRICK_ROAD_PENDING_ACTION, + billable: CONST.RED_BRICK_ROAD_PENDING_ACTION, + category: CONST.RED_BRICK_ROAD_PENDING_ACTION, + created: CONST.RED_BRICK_ROAD_PENDING_ACTION, + currency: CONST.RED_BRICK_ROAD_PENDING_ACTION, + errors: CONST.RED_BRICK_ROAD_PENDING_ACTION, + filename: CONST.RED_BRICK_ROAD_PENDING_ACTION, + iouRequestType: CONST.RED_BRICK_ROAD_PENDING_ACTION, + merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION, + modifiedAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION, + modifiedAttendees: CONST.RED_BRICK_ROAD_PENDING_ACTION, + modifiedCreated: CONST.RED_BRICK_ROAD_PENDING_ACTION, + modifiedCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION, + modifiedMerchant: CONST.RED_BRICK_ROAD_PENDING_ACTION, + modifiedWaypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION, + participantsAutoAssigned: CONST.RED_BRICK_ROAD_PENDING_ACTION, + participants: CONST.RED_BRICK_ROAD_PENDING_ACTION, + receipt: CONST.RED_BRICK_ROAD_PENDING_ACTION, + reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + routes: CONST.RED_BRICK_ROAD_PENDING_ACTION, + transactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + tag: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isFromGlobalCreate: CONST.RED_BRICK_ROAD_PENDING_ACTION, + taxRate: CONST.RED_BRICK_ROAD_PENDING_ACTION, + parentTransactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + reimbursable: CONST.RED_BRICK_ROAD_PENDING_ACTION, + cardID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + status: CONST.RED_BRICK_ROAD_PENDING_ACTION, + hasEReceipt: CONST.RED_BRICK_ROAD_PENDING_ACTION, + mccGroup: CONST.RED_BRICK_ROAD_PENDING_ACTION, + modifiedMCCGroup: CONST.RED_BRICK_ROAD_PENDING_ACTION, + originalAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION, + originalCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION, + splitShares: CONST.RED_BRICK_ROAD_PENDING_ACTION, + splitPayerAccountIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION, + shouldShowOriginalAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION, + actionableWhisperReportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + linkedTrackedExpenseReportAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, + linkedTrackedExpenseReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + bank: CONST.RED_BRICK_ROAD_PENDING_ACTION, + cardName: CONST.RED_BRICK_ROAD_PENDING_ACTION, + cardNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION, + managedCard: CONST.RED_BRICK_ROAD_PENDING_ACTION, + }, + 'string', + ); + case 'receipt': + return validateObject>(value, { + type: 'string', + source: 'string', + name: 'string', + filename: 'string', + state: CONST.IOU.RECEIPT_STATE, + receiptID: 'number', + reservationList: 'array', + }); + case 'taxRate': + return validateObject>(value, { + keyForList: 'string', + text: 'string', + data: 'object', + }); + case 'status': + return validateConstantEnum(value, CONST.TRANSACTION.STATUS); + case 'comment': + return validateObject>(value, { + comment: 'string', + hold: 'string', + waypoints: 'object', + isLoading: 'boolean', + type: CONST.TRANSACTION.TYPE, + customUnit: 'object', + source: 'string', + originalTransactionID: 'string', + splits: 'array', + dismissedViolations: 'object', + }); + case 'attendees': + return validateArray>(value, { + email: 'string', + displayName: 'string', + avatarUrl: 'string', + accountID: 'number', + text: 'string', + login: 'string', + searchText: 'string', + selected: 'boolean', + iouType: CONST.IOU.TYPE, + reportID: 'string', + }); + case 'modifiedAttendees': + return validateArray>(value, { + email: 'string', + displayName: 'string', + avatarUrl: 'string', + accountID: 'number', + text: 'string', + login: 'string', + searchText: 'string', + selected: 'boolean', + iouType: CONST.IOU.TYPE, + reportID: 'string', + }); + case 'modifiedWaypoints': + return validateObject>( + value, + { + name: 'string', + address: 'string', + lat: 'number', + lng: 'number', + keyForList: 'string', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, + street: 'string', + city: 'string', + state: 'string', + zipCode: 'string', + country: 'string', + street2: 'string', + }, + 'string', + ); + case 'routes': + return validateObject>( + value, + { + distance: 'number', + geometry: 'object', + }, + 'string', + ); + case 'mccGroup': + return validateConstantEnum(value, CONST.MCC_GROUPS); + case 'modifiedMCCGroup': + return validateConstantEnum(value, CONST.MCC_GROUPS); + case 'splitShares': + return validateObject>( + value, + { + amount: 'number', + isModified: 'boolean', + }, + 'number', + ); + case 'splitPayerAccountIDs': + return validateArray(value, 'number'); + case 'linkedTrackedExpenseReportAction': + return validateObject(value, { + accountID: 'number', + message: 'string', + created: 'string', + error: 'string', + avatar: 'string', + receipt: 'object', + reportID: 'string', + automatic: 'boolean', + reportActionID: 'string', + parentReportID: 'string', + errors: 'object', + isLoading: 'boolean', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION, + pendingFields: 'object', + sequenceNumber: 'number', + actionName: CONST.REPORT.ACTIONS.TYPE, + actorAccountID: 'number', + actor: 'string', + person: 'array', + shouldShow: 'boolean', + childReportID: 'string', + childReportName: 'string', + childType: 'string', + childOldestFourAccountIDs: 'string', + childCommenterCount: 'number', + childLastVisibleActionCreated: 'string', + childVisibleActionCount: 'number', + childManagerAccountID: 'number', + childOwnerAccountID: 'number', + childStatusNum: CONST.REPORT.STATUS_NUM, + childStateNum: CONST.REPORT.STATE_NUM, + childLastMoneyRequestComment: 'string', + childLastActorAccountID: 'number', + childMoneyRequestCount: 'number', + isFirstItem: 'boolean', + isAttachmentOnly: 'boolean', + isAttachmentWithText: 'boolean', + lastModified: 'string', + delegateAccountID: 'number', + childRecentReceiptTransactionIDs: 'object', + linkMetadata: 'array', + childReportNotificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE, + isNewestReportAction: 'boolean', + isOptimisticAction: 'boolean', + adminAccountID: 'number', + whisperedToAccountIDs: 'array', + reportActionTimestamp: 'string', + timestamp: 'string', + originalMessage: 'object', + previousMessage: 'object', + }); } - if (key === 'originalMessage') { - return validateObject(value, {}); +} + +function validateTransactionViolationDraftProperty(key: keyof TransactionViolation, value: string) { + if (TRANSACTION_VIOLATION_REQUIRED_PROPERTIES.includes(key) && isEmptyValue(value)) { + throw SyntaxError('debug.missingValue'); } - if (key === 'childRecentReceiptTransactionIDs') { - return validateObject(value, {}, 'string'); + switch (key) { + case 'type': + return validateConstantEnum(value, CONST.VIOLATION_TYPES); + + case 'name': + return validateConstantEnum(value, CONST.VIOLATIONS); + + case 'data': + return validateObject>(value, { + rejectedBy: 'string', + rejectReason: 'string', + formattedLimit: 'string', + surcharge: 'number', + invoiceMarkup: 'number', + maxAge: 'number', + tagName: 'string', + category: 'string', + brokenBankConnection: 'boolean', + isAdmin: 'boolean', + email: 'string', + isTransactionOlderThan7Days: 'boolean', + member: 'string', + taxName: 'string', + tagListIndex: 'number', + tagListName: 'string', + errorIndexes: 'array', + pendingPattern: 'string', + type: CONST.MODIFIED_AMOUNT_VIOLATION_DATA, + displayPercentVariance: 'number', + duplicates: 'array', + rterType: CONST.RTER_VIOLATION_TYPES, + tooltip: 'string', + }); + case 'showInReview': + return validateBoolean(value); } - validateString(value); } /** @@ -563,7 +1263,7 @@ function validateReportActionJSON(json: string) { }); Object.entries(parsedReportAction).forEach(([key, val]) => { try { - if (val !== 'undefined' && REPORT_ACTION_NUMBER_PROPERTIES.includes(key as keyof ReportAction) && typeof val !== 'number') { + if (!isEmptyValue(val as string) && REPORT_ACTION_NUMBER_PROPERTIES.includes(key as keyof ReportAction) && typeof val !== 'number') { throw new NumberError(); } validateReportActionDraftProperty(key as keyof ReportAction, onyxDataToString(val)); @@ -574,6 +1274,25 @@ function validateReportActionJSON(json: string) { }); } +function validateTransactionViolationJSON(json: string) { + const parsedTransactionViolation = parseJSON(json) as TransactionViolation; + TRANSACTION_VIOLATION_REQUIRED_PROPERTIES.forEach((key) => { + if (parsedTransactionViolation[key] !== undefined) { + return; + } + + throw new SyntaxError('debug.missingProperty', {cause: {propertyName: key}}); + }); + Object.entries(parsedTransactionViolation).forEach(([key, val]) => { + try { + validateTransactionViolationDraftProperty(key as keyof TransactionViolation, onyxDataToString(val)); + } catch (e) { + const {cause} = e as SyntaxError & {cause: {expectedValues: string}}; + throw new SyntaxError('debug.invalidProperty', {cause: {propertyName: key, expectedType: cause.expectedValues}}); + } + }); +} + /** * Gets the reason for showing LHN row */ @@ -649,6 +1368,16 @@ function getReasonAndReportActionForRBRInLHNRow(report: Report, reportActions: O return null; } +function getTransactionID(report: OnyxEntry, reportActions: OnyxEntry) { + const transactionID = TransactionUtils.getTransactionID(report?.reportID ?? '-1'); + + return Number(transactionID) > 0 + ? transactionID + : Object.values(reportActions ?? {}) + .map((reportAction) => ReportActionsUtils.getLinkedTransactionID(reportAction)) + .find(Boolean); +} + const DebugUtils = { stringifyJSON, onyxDataToDraftData, @@ -665,12 +1394,17 @@ const DebugUtils = { validateString, validateReportDraftProperty, validateReportActionDraftProperty, + validateTransactionDraftProperty, + validateTransactionViolationDraftProperty, validateReportActionJSON, + validateTransactionViolationJSON, getReasonForShowingRowInLHN, getReasonAndReportActionForGBRInLHNRow, getReasonAndReportActionForRBRInLHNRow, + getTransactionID, REPORT_ACTION_REQUIRED_PROPERTIES, REPORT_REQUIRED_PROPERTIES, + TRANSACTION_REQUIRED_PROPERTIES, }; export type {ObjectType, OnyxDataType}; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.tsx similarity index 89% rename from src/libs/EmojiUtils.ts rename to src/libs/EmojiUtils.tsx index f9fb5f226280..a8fb6f7a92b3 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.tsx @@ -1,9 +1,12 @@ import {Str} from 'expensify-common'; import lodashSortBy from 'lodash/sortBy'; +import React from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import * as Emojis from '@assets/emojis'; import type {Emoji, HeaderEmoji, PickerEmojis} from '@assets/emojis/types'; +import Text from '@components/Text'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {FrequentlyUsedEmoji, Locale} from '@src/types/onyx'; @@ -19,6 +22,10 @@ type EmojiPickerListItem = EmojiSpacer | Emoji | HeaderEmoji; type EmojiPickerList = EmojiPickerListItem[]; type ReplacedEmoji = {text: string; emojis: Emoji[]; cursorPosition?: number}; type EmojiTrieModule = {default: typeof EmojiTrie}; +type TextWithEmoji = { + text: string; + isEmoji: boolean; +}; const findEmojiByName = (name: string): Emoji => Emojis.emojiNameTable[name]; @@ -151,7 +158,7 @@ function trimEmojiUnicode(emojiCode: string): string { */ function isFirstLetterEmoji(message: string): boolean { const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); - const match = trimmedMessage.match(CONST.REGEX.EMOJIS); + const match = trimmedMessage.match(CONST.REGEX.ALL_EMOJIS); if (!match) { return false; @@ -165,7 +172,7 @@ function isFirstLetterEmoji(message: string): boolean { */ function containsOnlyEmojis(message: string): boolean { const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); - const match = trimmedMessage.match(CONST.REGEX.EMOJIS); + const match = trimmedMessage.match(CONST.REGEX.ALL_EMOJIS); if (!match) { return false; @@ -288,7 +295,7 @@ function extractEmojis(text: string): Emoji[] { } // Parse Emojis including skin tones - Eg: ['👩🏻', '👩🏻', '👩🏼', '👩🏻', '👩🏼', '👩'] - const parsedEmojis = text.match(CONST.REGEX.EMOJIS); + const parsedEmojis = text.match(CONST.REGEX.ALL_EMOJIS); if (!parsedEmojis) { return []; @@ -598,6 +605,75 @@ function getSpacersIndexes(allEmojis: EmojiPickerList): number[] { return spacersIndexes; } +/** Splits the text with emojis into array if emojis exist in the text */ +function splitTextWithEmojis(text = ''): TextWithEmoji[] { + if (!text) { + return []; + } + + const doesTextContainEmojis = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')).test(text); + + if (!doesTextContainEmojis) { + return []; + } + + // The regex needs to be cloned because `exec()` is a stateful operation and maintains the state inside + // the regex variable itself, so we must have an independent instance for each function's call. + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + + const splitText: TextWithEmoji[] = []; + let regexResult: RegExpExecArray | null; + let lastMatchIndexEnd = 0; + + do { + regexResult = emojisRegex.exec(text); + + if (regexResult?.indices) { + const matchIndexStart = regexResult.indices[0][0]; + const matchIndexEnd = regexResult.indices[0][1]; + + if (matchIndexStart > lastMatchIndexEnd) { + splitText.push({ + text: text.slice(lastMatchIndexEnd, matchIndexStart), + isEmoji: false, + }); + } + + splitText.push({ + text: text.slice(matchIndexStart, matchIndexEnd), + isEmoji: true, + }); + + lastMatchIndexEnd = matchIndexEnd; + } + } while (regexResult !== null); + + if (lastMatchIndexEnd < text.length) { + splitText.push({ + text: text.slice(lastMatchIndexEnd, text.length), + isEmoji: false, + }); + } + + return splitText; +} + +function getProcessedText(processedTextArray: TextWithEmoji[], style: StyleProp): Array { + return processedTextArray.map(({text, isEmoji}, index) => + isEmoji ? ( + + {text} + + ) : ( + text + ), + ); +} + export type {HeaderIndice, EmojiPickerList, EmojiSpacer, EmojiPickerListItem}; export { @@ -605,6 +681,7 @@ export { findEmojiByCode, getEmojiName, getLocalizedEmojiName, + getProcessedText, getHeaderEmojis, mergeEmojisWithFrequentlyUsedEmojis, containsOnlyEmojis, @@ -623,4 +700,5 @@ export { hasAccountIDEmojiReacted, getRemovedSkinToneEmoji, getSpacersIndexes, + splitTextWithEmojis, }; diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts index 204e78aa5458..23c7b360dc63 100644 --- a/src/libs/Environment/Environment.ts +++ b/src/libs/Environment/Environment.ts @@ -2,6 +2,7 @@ import Config from 'react-native-config'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import getEnvironment from './getEnvironment'; +import type Environment from './getEnvironment/types'; const ENVIRONMENT_URLS = { [CONST.ENVIRONMENT.DEV]: CONST.DEV_NEW_EXPENSIFY_URL + CONFIG.DEV_PORT, @@ -47,6 +48,13 @@ function getEnvironmentURL(): Promise { }); } +/** + * Given the environment get the corresponding oldDot URL + */ +function getOldDotURLFromEnvironment(environment: Environment): string { + return OLDDOT_ENVIRONMENT_URLS[environment]; +} + /** * Get the corresponding oldDot URL based on the environment we are in */ @@ -54,4 +62,4 @@ function getOldDotEnvironmentURL(): Promise { return getEnvironment().then((environment) => OLDDOT_ENVIRONMENT_URLS[environment]); } -export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL}; +export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getOldDotURLFromEnvironment}; diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index bfa8183ac03b..251609d1254c 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -15,7 +15,7 @@ type PagedResource = OnyxValues[TResourc type PaginationCommonConfig = { resourceCollectionKey: TResourceKey; pageCollectionKey: TPageKey; - sortItems: (items: OnyxValues[TResourceKey]) => Array>; + sortItems: (items: OnyxValues[TResourceKey], reportID: string) => Array>; getItemID: (item: PagedResource) => string; }; @@ -96,7 +96,7 @@ const Pagination: Middleware = (requestResponse, request) => { // Create a new page based on the response const pageItems = (response.onyxData.find((data) => data.key === resourceKey)?.value ?? {}) as OnyxValues[typeof resourceCollectionKey]; - const sortedPageItems = sortItems(pageItems); + const sortedPageItems = sortItems(pageItems, resourceID); if (sortedPageItems.length === 0) { // Must have at least 1 action to create a page. Log.hmmm(`[Pagination] Did not receive any items in the response to ${request.command}`); @@ -115,7 +115,7 @@ const Pagination: Middleware = (requestResponse, request) => { const resourceCollections = resources.get(resourceCollectionKey) ?? {}; const existingItems = resourceCollections[resourceKey] ?? {}; const allItems = fastMerge(existingItems, pageItems, true); - const sortedAllItems = sortItems(allItems); + const sortedAllItems = sortItems(allItems, resourceID); const pagesCollections = pages.get(pageCollectionKey) ?? {}; const existingPages = pagesCollections[pageKey] ?? []; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 137debc3b35a..40b4742ca5de 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -1,6 +1,6 @@ import {findFocusedRoute} from '@react-navigation/native'; import React, {memo, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {NativeModules, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx, {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -276,13 +276,16 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie setDidPusherInit(true); }); - // If we are on this screen then we are "logged in", but the user might not have "just logged in". They could be reopening the app - // or returning from background. If so, we'll assume they have some app data already and we can call reconnectApp() instead of openApp(). - if (SessionUtils.didUserLogInDuringSession()) { - App.openApp(); - } else { - Log.info('[AuthScreens] Sending ReconnectApp'); - App.reconnectApp(initialLastUpdateIDAppliedToClient); + // In Hybrid App we decide to call one of those method when booting ND and we don't want to duplicate calls + if (!NativeModules.HybridAppModule) { + // If we are on this screen then we are "logged in", but the user might not have "just logged in". They could be reopening the app + // or returning from background. If so, we'll assume they have some app data already and we can call reconnectApp() instead of openApp(). + if (SessionUtils.didUserLogInDuringSession()) { + App.openApp(); + } else { + Log.info('[AuthScreens] Sending ReconnectApp'); + App.reconnectApp(initialLastUpdateIDAppliedToClient); + } } PriorityMode.autoSwitchToFocusMode(); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 8c0d45e8c313..2b6d0b84b460 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -666,6 +666,9 @@ const DebugModalStackNavigator = createModalStackNavigator({ [SCREENS.DEBUG.REPORT_ACTION_CREATE]: () => require('../../../../pages/Debug/ReportAction/DebugReportActionCreatePage').default, [SCREENS.DEBUG.DETAILS_CONSTANT_PICKER_PAGE]: () => require('../../../../pages/Debug/DebugDetailsConstantPickerPage').default, [SCREENS.DEBUG.DETAILS_DATE_TIME_PICKER_PAGE]: () => require('../../../../pages/Debug/DebugDetailsDateTimePickerPage').default, + [SCREENS.DEBUG.TRANSACTION]: () => require('../../../../pages/Debug/Transaction/DebugTransactionPage').default, + [SCREENS.DEBUG.TRANSACTION_VIOLATION_CREATE]: () => require('../../../../pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage').default, + [SCREENS.DEBUG.TRANSACTION_VIOLATION]: () => require('../../../../pages/Debug/TransactionViolation/DebugTransactionViolationPage').default, }); export { diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index e0cd018086bd..8473b8fa49c2 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1410,6 +1410,42 @@ const config: LinkingOptions['config'] = { path: ROUTES.DETAILS_DATE_TIME_PICKER_PAGE.route, exact: true, }, + [SCREENS.DEBUG.TRANSACTION]: { + path: ROUTES.DEBUG_TRANSACTION.route, + exact: true, + screens: { + details: { + path: ROUTES.DEBUG_TRANSACTION_TAB_DETAILS.route, + exact: true, + }, + json: { + path: ROUTES.DEBUG_TRANSACTION_TAB_JSON.route, + exact: true, + }, + violations: { + path: ROUTES.DEBUG_TRANSACTION_TAB_VIOLATIONS.route, + exact: true, + }, + }, + }, + [SCREENS.DEBUG.TRANSACTION_VIOLATION_CREATE]: { + path: ROUTES.DEBUG_TRANSACTION_VIOLATION_CREATE.route, + exact: true, + }, + [SCREENS.DEBUG.TRANSACTION_VIOLATION]: { + path: ROUTES.DEBUG_TRANSACTION_VIOLATION.route, + exact: true, + screens: { + details: { + path: ROUTES.DEBUG_TRANSACTION_VIOLATION_TAB_DETAILS.route, + exact: true, + }, + json: { + path: ROUTES.DEBUG_TRANSACTION_VIOLATION_TAB_JSON.route, + exact: true, + }, + }, + }, }, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 798e77d86ecc..9abd3f78a3f9 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1626,8 +1626,10 @@ type DebugParamList = { reportID: string; }; [SCREENS.DEBUG.DETAILS_CONSTANT_PICKER_PAGE]: { + formType: string; fieldName: string; fieldValue?: string; + policyID?: string; backTo?: string; }; [SCREENS.DEBUG.DETAILS_DATE_TIME_PICKER_PAGE]: { @@ -1635,6 +1637,16 @@ type DebugParamList = { fieldValue?: string; backTo?: string; }; + [SCREENS.DEBUG.TRANSACTION]: { + transactionID: string; + }; + [SCREENS.DEBUG.TRANSACTION_VIOLATION_CREATE]: { + transactionID: string; + }; + [SCREENS.DEBUG.TRANSACTION_VIOLATION]: { + transactionID: string; + index: string; + }; }; type RootStackParamList = PublicScreensParamList & AuthScreensParamList & LeftModalNavigatorParamList; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 3308658fa735..bb2c6a6e92fb 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -283,11 +283,14 @@ Onyx.connect({ lastReportActions[reportID] = firstReportAction; } + const report = ReportUtils.getReport(reportID); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); + // The report is only visible if it is the last action not deleted that // does not match a closed or created state. const reportActionsForDisplay = sortedReportActions.filter( (reportAction, actionKey) => - ReportActionUtils.shouldReportActionBeVisible(reportAction, actionKey) && + ReportActionUtils.shouldReportActionBeVisible(reportAction, actionKey, canUserPerformWriteAction) && !ReportActionUtils.isWhisperAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && @@ -545,7 +548,7 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails const iouReport = ReportUtils.getReportOrDraftReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction)); const lastIOUMoneyReportAction = allSortedReportActions[iouReport?.reportID ?? '-1']?.find( (reportAction, key): reportAction is ReportAction => - ReportActionUtils.shouldReportActionBeVisible(reportAction, key) && + ReportActionUtils.shouldReportActionBeVisible(reportAction, key, ReportUtils.canUserPerformWriteAction(report)) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && ReportActionUtils.isMoneyRequestAction(reportAction), ); diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index a354ea3d5444..8d828f457ece 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -635,7 +635,7 @@ const supportedActionTypes: ReportActionName[] = [...Object.values(otherActionTy * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid * and supported type, it's not deleted and also not closed. */ -function shouldReportActionBeVisible(reportAction: OnyxEntry, key: string | number): boolean { +function shouldReportActionBeVisible(reportAction: OnyxEntry, key: string | number, canUserPerformWriteAction?: boolean): boolean { if (!reportAction) { return false; } @@ -668,6 +668,13 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } + if ( + (isActionableReportMentionWhisper(reportAction) || isActionableJoinRequestPendingReportAction(reportAction) || isActionableMentionWhisper(reportAction)) && + !canUserPerformWriteAction + ) { + return false; + } + if (isTripPreview(reportAction)) { return true; } @@ -711,7 +718,7 @@ function isResolvedActionTrackExpense(reportAction: OnyxEntry): bo * Checks if a reportAction is fit for display as report last action, meaning that * it satisfies shouldReportActionBeVisible, it's not whisper action and not deleted. */ -function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry): boolean { +function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry, canUserPerformWriteAction?: boolean): boolean { if (!reportAction) { return false; } @@ -723,7 +730,7 @@ function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry< // If a whisper action is the REPORT_PREVIEW action, we are displaying it. // If the action's message text is empty and it is not a deleted parent with visible child actions, hide it. Else, consider the action to be displayable. return ( - shouldReportActionBeVisible(reportAction, reportAction.reportActionID) && + shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction) && !(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) && !(isDeletedAction(reportAction) && !isDeletedParentAction(reportAction)) && !isResolvedActionTrackExpense(reportAction) @@ -756,7 +763,7 @@ function replaceBaseURLInPolicyChangeLogAction(reportAction: ReportAction): Repo return updatedReportAction; } -function getLastVisibleAction(reportID: string, actionsToMerge: Record | null> = {}): OnyxEntry { +function getLastVisibleAction(reportID: string, canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}): OnyxEntry { let reportActions: Array = []; if (!isEmpty(actionsToMerge)) { reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge ?? {}, true)) as Array< @@ -765,7 +772,7 @@ function getLastVisibleAction(reportID: string, actionsToMerge: Record shouldReportActionBeVisibleAsLastAction(action)); + const visibleReportActions = reportActions.filter((action): action is ReportAction => shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction)); const sortedReportActions = getSortedReportActions(visibleReportActions, true); if (sortedReportActions.length === 0) { return undefined; @@ -787,10 +794,11 @@ function formatLastMessageText(lastMessageText: string) { function getLastVisibleMessage( reportID: string, + canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}, reportAction: OnyxInputOrEntry | undefined = undefined, ): LastVisibleMessage { - const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, actionsToMerge); + const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, canUserPerformWriteAction, actionsToMerge); const message = getReportActionMessage(lastVisibleAction); if (message && isReportMessageAttachment(message)) { @@ -831,7 +839,11 @@ function filterOutDeprecatedReportActions(reportActions: OnyxEntry | ReportAction[], shouldIncludeInvisibleActions = false): ReportAction[] { +function getSortedReportActionsForDisplay( + reportActions: OnyxEntry | ReportAction[], + canUserPerformWriteAction?: boolean, + shouldIncludeInvisibleActions = false, +): ReportAction[] { let filteredReportActions: ReportAction[] = []; if (!reportActions) { return []; @@ -841,7 +853,7 @@ function getSortedReportActionsForDisplay(reportActions: OnyxEntry shouldReportActionBeVisible(reportAction, key)) + .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key, canUserPerformWriteAction)) .map(([, reportAction]) => reportAction); } @@ -1090,9 +1102,9 @@ function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEn * When we delete certain reports, we want to check whether there are any visible actions left to display. * If there are no visible actions left (including system messages), we can hide the report from view entirely */ -function doesReportHaveVisibleActions(reportID: string, actionsToMerge: ReportActions = {}): boolean { +function doesReportHaveVisibleActions(reportID: string, canUserPerformWriteAction?: boolean, actionsToMerge: ReportActions = {}): boolean { const reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge, true)); - const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => shouldReportActionBeVisibleAsLastAction(action)); + const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction)); // Exclude the task system message and the created message const visibleReportActionsWithoutTaskSystemMessage = visibleReportActions.filter((action) => !isTaskAction(action) && !isCreatedAction(action)); @@ -1485,11 +1497,12 @@ function isActionableJoinRequest(reportAction: OnyxEntry): reportA return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST); } -function getActionableJoinRequestPendingReportAction(reportID: string): OnyxEntry { - const findPendingRequest = Object.values(getAllReportActions(reportID)).find( - (reportActionItem) => isActionableJoinRequest(reportActionItem) && getOriginalMessage(reportActionItem)?.choice === ('' as JoinWorkspaceResolution), - ); +function isActionableJoinRequestPendingReportAction(reportAction: OnyxEntry): boolean { + return isActionableJoinRequest(reportAction) && getOriginalMessage(reportAction)?.choice === ('' as JoinWorkspaceResolution); +} +function getActionableJoinRequestPendingReportAction(reportID: string): OnyxEntry { + const findPendingRequest = Object.values(getAllReportActions(reportID)).find((reportActionItem) => isActionableJoinRequestPendingReportAction(reportActionItem)); return findPendingRequest; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 373a861f7c2e..118eeeb785ee 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -327,7 +327,6 @@ type OptimisticChatReport = Pick< | 'description' | 'writeCapability' | 'avatarUrl' - | 'avatarFileName' | 'invoiceReceiver' | 'isHidden' > & { @@ -441,7 +440,6 @@ type TransactionDetails = { type OptimisticIOUReport = Pick< Report, - | 'cachedTotal' | 'type' | 'chatReportID' | 'currency' @@ -1601,13 +1599,6 @@ function isWorkspaceThread(report: OnyxEntry): boolean { return isThread(report) && isChatReport(report) && CONST.WORKSPACE_ROOM_TYPES.some((type) => chatType === type); } -/** - * Returns true if reportAction is the first chat preview of a Thread - */ -function isThreadFirstChat(reportAction: OnyxInputOrEntry, reportID: string): boolean { - return reportAction?.childReportID?.toString() === reportID; -} - /** * Checks if a report is a child report. */ @@ -1853,10 +1844,9 @@ function canDeleteReportAction(reportAction: OnyxInputOrEntry, rep return false; } - const linkedReport = isThreadFirstChat(reportAction, reportID) ? getReportOrDraftReport(report?.parentReportID) : report; if (isActionOwner) { - if (!isEmptyObject(linkedReport) && (isMoneyRequestReport(linkedReport) || isInvoiceReport(linkedReport))) { - return canDeleteTransaction(linkedReport); + if (!isEmptyObject(report) && (isMoneyRequestReport(report) || isInvoiceReport(report))) { + return canDeleteTransaction(report); } return true; } @@ -2699,7 +2689,7 @@ function buildOptimisticCancelPaymentReportAction(expenseReportID: string, amoun */ function getLastVisibleMessage(reportID: string | undefined, actionsToMerge: ReportActions = {}): LastVisibleMessage { const report = getReportOrDraftReport(reportID); - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID ?? '-1', actionsToMerge); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID ?? '-1', canUserPerformWriteAction(report), actionsToMerge); // For Chat Report with deleted parent actions, let us fetch the correct message if (ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && !isEmptyObject(report) && isChatReport(report)) { @@ -2710,7 +2700,7 @@ function getLastVisibleMessage(reportID: string | undefined, actionsToMerge: Rep } // Fetch the last visible message for report represented by reportID and based on actions to merge. - return ReportActionsUtils.getLastVisibleMessage(reportID ?? '-1', actionsToMerge); + return ReportActionsUtils.getLastVisibleMessage(reportID ?? '-1', canUserPerformWriteAction(report), actionsToMerge); } /** @@ -4553,7 +4543,6 @@ function buildOptimisticIOUReport( return { type: CONST.REPORT.TYPE.IOU, - cachedTotal: formattedTotal, chatReportID, currency, managerID: payerAccountID, @@ -5427,7 +5416,6 @@ function buildOptimisticChatReport( parentReportID = '', description = '', avatarUrl = '', - avatarFileName = '', optimisticReportID = '', ): OptimisticChatReport { const isWorkspaceChatType = chatType && isWorkspaceChat(chatType); @@ -5468,7 +5456,6 @@ function buildOptimisticChatReport( description, writeCapability, avatarUrl, - avatarFileName, }; if (chatType === CONST.REPORT.CHAT_TYPE.INVOICE) { @@ -5486,7 +5473,6 @@ function buildOptimisticGroupChatReport( participantAccountIDs: number[], reportName: string, avatarUri: string, - avatarFilename: string, optimisticReportID?: string, notificationPreference?: NotificationPreference, ) { @@ -5505,7 +5491,6 @@ function buildOptimisticGroupChatReport( undefined, undefined, avatarUri, - avatarFilename, optimisticReportID, ); } @@ -6030,7 +6015,6 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp undefined, undefined, undefined, - undefined, expenseReportId, ); const expenseChatReportID = expenseChatData.reportID; @@ -7278,10 +7262,9 @@ function getOriginalReportID(reportID: string, reportAction: OnyxInputOrEntry, reportID: string): boolean { +function shouldDisplayThreadReplies(reportAction: OnyxInputOrEntry, isThreadReportParentAction: boolean): boolean { const hasReplies = (reportAction?.childVisibleActionCount ?? 0) > 0; - return hasReplies && !!reportAction?.childCommenterCount && !isThreadFirstChat(reportAction, reportID); + return hasReplies && !!reportAction?.childCommenterCount && !isThreadReportParentAction; } /** @@ -7809,7 +7792,7 @@ function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry * - The action is a whisper action and it's neither a report preview nor IOU action * - The action is the thread's first chat */ -function shouldDisableThread(reportAction: OnyxInputOrEntry, reportID: string): boolean { +function shouldDisableThread(reportAction: OnyxInputOrEntry, reportID: string, isThreadReportParentAction: boolean): boolean { const isSplitBillAction = ReportActionsUtils.isSplitBillAction(reportAction); const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); @@ -7824,7 +7807,7 @@ function shouldDisableThread(reportAction: OnyxInputOrEntry, repor (isDeletedAction && !reportAction?.childVisibleActionCount) || (isArchivedReport && !reportAction?.childVisibleActionCount) || (isWhisperAction && !isReportPreviewAction && !isIOUAction) || - isThreadFirstChat(reportAction, reportID) + isThreadReportParentAction ); } @@ -8327,6 +8310,7 @@ function findPolicyExpenseChatByPolicyID(policyID: string): OnyxEntry { * A function to get the report last message. This is usually used to restore the report message preview in LHN after report actions change. * @param reportID * @param actionsToMerge + * @param canUserPerformWriteActionInReport * @returns containing the calculated message preview data of the report */ function getReportLastMessage(reportID: string, actionsToMerge?: ReportActions) { @@ -8339,7 +8323,8 @@ function getReportLastMessage(reportID: string, actionsToMerge?: ReportActions) const {lastMessageText = '', lastMessageTranslationKey = ''} = getLastVisibleMessage(reportID, actionsToMerge); if (lastMessageText || lastMessageTranslationKey) { - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, actionsToMerge); + const report = getReport(reportID); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, canUserPerformWriteAction(report), actionsToMerge); const lastVisibleActionCreated = lastVisibleAction?.created; const lastActorAccountID = lastVisibleAction?.actorAccountID; result = { @@ -8678,7 +8663,6 @@ export { isSystemChat, isTaskReport, isThread, - isThreadFirstChat, isTrackExpenseReport, isUnread, isUnreadWithMention, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 806cebd9bf7f..f4979f942363 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -41,13 +41,15 @@ Onyx.connect({ return; } const reportID = CollectionUtils.extractCollectionItemID(key); - + const report = ReportUtils.getReport(reportID); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); const actionsArray: ReportAction[] = ReportActionsUtils.getSortedReportActions(Object.values(actions)); // The report is only visible if it is the last action not deleted that // does not match a closed or created state. const reportActionsForDisplay = actionsArray.filter( - (reportAction) => ReportActionsUtils.shouldReportActionBeVisibleAsLastAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, + (reportAction) => + ReportActionsUtils.shouldReportActionBeVisibleAsLastAction(reportAction, canUserPerformWriteAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, ); const reportAction = reportActionsForDisplay.at(-1); @@ -481,7 +483,7 @@ function getOptionData({ result.alternateText = lastMessageTextFromReport.length > 0 ? ReportUtils.formatReportLastMessageText(Parser.htmlToText(lastMessageText)) - : ReportActionsUtils.getLastVisibleMessage(report.reportID, {}, lastAction)?.lastMessageText; + : ReportActionsUtils.getLastVisibleMessage(report.reportID, result.isAllowedToComment, {}, lastAction)?.lastMessageText; if (!result.alternateText) { result.alternateText = ReportUtils.formatReportLastMessageText(getWelcomeMessage(report, policy).messageText ?? Localize.translateLocal('report.noActivityYet')); } diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index f42c0252644d..fac02bd2b4ca 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -49,7 +49,7 @@ function isValidAddress(value: FormValue): boolean { return false; } - if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.EMOJIS)) { + if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.ALL_EMOJIS)) { return false; } @@ -343,7 +343,7 @@ function isValidRoutingNumber(routingNumber: string): boolean { * Checks that the provided name doesn't contain any emojis */ function isValidCompanyName(name: string) { - return !name.match(CONST.REGEX.EMOJIS); + return !name.match(CONST.REGEX.ALL_EMOJIS); } function isValidReportName(name: string) { diff --git a/src/libs/actions/AppUpdate/updateApp/index.android.ts b/src/libs/actions/AppUpdate/updateApp/index.android.ts index 3f2cde77f466..aac98a1928aa 100644 --- a/src/libs/actions/AppUpdate/updateApp/index.android.ts +++ b/src/libs/actions/AppUpdate/updateApp/index.android.ts @@ -1,6 +1,10 @@ import {Linking, NativeModules} from 'react-native'; import CONST from '@src/CONST'; -export default function updateApp() { +export default function updateApp(isProduction: boolean) { + if (isProduction) { + Linking.openURL(CONST.APP_DOWNLOAD_LINKS.OLD_DOT_ANDROID); + return; + } Linking.openURL(NativeModules.HybridAppModule ? CONST.APP_DOWNLOAD_LINKS.OLD_DOT_ANDROID : CONST.APP_DOWNLOAD_LINKS.ANDROID); } diff --git a/src/libs/actions/AppUpdate/updateApp/index.desktop.ts b/src/libs/actions/AppUpdate/updateApp/index.desktop.ts index 5c1ecbe05742..cbd961ff653b 100644 --- a/src/libs/actions/AppUpdate/updateApp/index.desktop.ts +++ b/src/libs/actions/AppUpdate/updateApp/index.desktop.ts @@ -1,5 +1,6 @@ import ELECTRON_EVENTS from '@desktop/ELECTRON_EVENTS'; -export default function updateApp() { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function updateApp(isProduction: boolean) { window.electron.send(ELECTRON_EVENTS.SILENT_UPDATE); } diff --git a/src/libs/actions/AppUpdate/updateApp/index.ios.ts b/src/libs/actions/AppUpdate/updateApp/index.ios.ts index 930a57881128..608c7ab028ca 100644 --- a/src/libs/actions/AppUpdate/updateApp/index.ios.ts +++ b/src/libs/actions/AppUpdate/updateApp/index.ios.ts @@ -1,6 +1,10 @@ import {Linking, NativeModules} from 'react-native'; import CONST from '@src/CONST'; -export default function updateApp() { +export default function updateApp(isProduction: boolean) { + if (isProduction) { + Linking.openURL(CONST.APP_DOWNLOAD_LINKS.OLD_DOT_IOS); + return; + } Linking.openURL(NativeModules.HybridAppModule ? CONST.APP_DOWNLOAD_LINKS.OLD_DOT_IOS : CONST.APP_DOWNLOAD_LINKS.IOS); } diff --git a/src/libs/actions/AppUpdate/updateApp/index.ts b/src/libs/actions/AppUpdate/updateApp/index.ts index 8c2b191029a2..3b6d9e666bfa 100644 --- a/src/libs/actions/AppUpdate/updateApp/index.ts +++ b/src/libs/actions/AppUpdate/updateApp/index.ts @@ -1,6 +1,7 @@ /** * On web or mWeb we can simply refresh the page and the user should have the new version of the app downloaded. */ -export default function updateApp() { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function updateApp(isProduction: boolean) { window.location.reload(); } diff --git a/src/libs/actions/Debug.ts b/src/libs/actions/Debug.ts index 5047ab063b7e..4c3479ee9741 100644 --- a/src/libs/actions/Debug.ts +++ b/src/libs/actions/Debug.ts @@ -7,11 +7,11 @@ function resetDebugDetailsDraftForm() { Onyx.set(ONYXKEYS.FORMS.DEBUG_DETAILS_FORM_DRAFT, null); } -function mergeDebugData(onyxKey: TKey, onyxValue: OnyxMergeInput) { - Onyx.merge(onyxKey, onyxValue); +function setDebugData(onyxKey: TKey, onyxValue: OnyxMergeInput) { + Onyx.set(onyxKey, onyxValue); } export default { resetDebugDetailsDraftForm, - mergeDebugData, + setDebugData, }; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index e9eba8d57ef4..dd6686b9ff7d 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1717,8 +1717,12 @@ function getDeleteTrackExpenseInformation( }, ...(actionableWhisperReportActionID && {[actionableWhisperReportActionID]: {originalMessage: {resolution}}}), } as OnyxTypes.ReportActions; - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(chatReport?.reportID ?? '-1', updatedReportAction); - const {lastMessageText = '', lastMessageHtml = ''} = ReportActionsUtils.getLastVisibleMessage(chatReport?.reportID ?? '-1', updatedReportAction); + let canUserPerformWriteAction = true; + if (chatReport) { + canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(chatReport); + } + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(chatReport?.reportID ?? '-1', canUserPerformWriteAction, updatedReportAction); + const {lastMessageText = '', lastMessageHtml = ''} = ReportActionsUtils.getLastVisibleMessage(chatReport?.reportID ?? '-1', canUserPerformWriteAction, updatedReportAction); // STEP 4: Build Onyx data const optimisticData: OnyxUpdate[] = []; @@ -2613,10 +2617,6 @@ function getUpdateMoneyRequestParams( updatedMoneyRequestReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true); } - if (updatedMoneyRequestReport) { - updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency); - } - optimisticData.push( { onyxMethod: Onyx.METHOD.MERGE, @@ -3389,8 +3389,6 @@ function categorizeTrackedExpense( billable?: boolean, receipt?: Receipt, createdWorkspaceParams?: CreateWorkspaceParams, - waypoints?: string, - customUnitRateID?: string, ) { const {optimisticData, successData, failureData} = onyxData ?? {}; @@ -3437,8 +3435,6 @@ function categorizeTrackedExpense( policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, - waypoints, - customUnitRateID, }; API.write(WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); @@ -3474,8 +3470,6 @@ function shareTrackedExpense( billable?: boolean, receipt?: Receipt, createdWorkspaceParams?: CreateWorkspaceParams, - waypoints?: string, - customUnitRateID?: string, ) { const {optimisticData, successData, failureData} = onyxData ?? {}; @@ -3522,8 +3516,6 @@ function shareTrackedExpense( policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, - waypoints, - customUnitRateID, }; API.write(WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); @@ -3827,8 +3819,6 @@ function trackExpense( value: recentServerValidatedWaypoints, }); - const waypoints = validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined; - switch (action) { case CONST.IOU.ACTION.CATEGORIZE: { if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { @@ -3859,8 +3849,6 @@ function trackExpense( billable, trackedReceipt, createdWorkspaceParams, - waypoints, - customUnitRateID, ); break; } @@ -3892,8 +3880,6 @@ function trackExpense( billable, trackedReceipt, createdWorkspaceParams, - waypoints, - customUnitRateID, ); break; } @@ -3922,7 +3908,7 @@ function trackExpense( receiptGpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined, transactionThreadReportID: transactionThreadReportID ?? '-1', createdReportActionIDForThread: createdReportActionIDForThread ?? '-1', - waypoints, + waypoints: validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined, customUnitRateID, }; if (actionableWhisperReportActionIDParam) { @@ -5417,8 +5403,12 @@ function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxT }, } as Record>; - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(iouReport?.reportID ?? '-1', updatedReportAction); - const iouReportLastMessageText = ReportActionsUtils.getLastVisibleMessage(iouReport?.reportID ?? '-1', updatedReportAction).lastMessageText; + let canUserPerformWriteAction = true; + if (chatReport) { + canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(chatReport); + } + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(iouReport?.reportID ?? '-1', canUserPerformWriteAction, updatedReportAction); + const iouReportLastMessageText = ReportActionsUtils.getLastVisibleMessage(iouReport?.reportID ?? '-1', canUserPerformWriteAction, updatedReportAction).lastMessageText; const shouldDeleteIOUReport = iouReportLastMessageText.length === 0 && !ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && (!transactionThreadID || shouldDeleteTransactionThread); @@ -5611,6 +5601,10 @@ function cleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repo } if (shouldDeleteIOUReport) { + let canUserPerformWriteAction = true; + if (chatReport) { + canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(chatReport); + } onyxUpdates.push( { onyxMethod: Onyx.METHOD.MERGE, @@ -5618,8 +5612,12 @@ function cleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repo value: { hasOutstandingChildRequest: false, iouReportID: null, - lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.lastMessageText, - lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.created, + lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', canUserPerformWriteAction, { + [reportPreviewAction?.reportActionID ?? '-1']: null, + })?.lastMessageText, + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', canUserPerformWriteAction, { + [reportPreviewAction?.reportActionID ?? '-1']: null, + })?.created, }, }, { @@ -5727,14 +5725,21 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor } if (shouldDeleteIOUReport) { + let canUserPerformWriteAction = true; + if (chatReport) { + canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(chatReport); + } optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, value: { hasOutstandingChildRequest: false, iouReportID: null, - lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.lastMessageText, - lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.created, + lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', canUserPerformWriteAction, {[reportPreviewAction?.reportActionID ?? '-1']: null}) + ?.lastMessageText, + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', canUserPerformWriteAction, { + [reportPreviewAction?.reportActionID ?? '-1']: null, + })?.created, }, }); optimisticData.push({ @@ -6781,7 +6786,6 @@ function canIOUBePaid( policy: OnyxTypes.OnyxInputOrEntry, transactions?: OnyxTypes.Transaction[], onlyShowPayElsewhere = false, - shouldCheckApprovedState = true, ) { const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); const reportNameValuePairs = ReportUtils.getReportNameValuePairs(chatReport?.reportID); @@ -6835,7 +6839,7 @@ function canIOUBePaid( reimbursableSpend !== 0 && !isChatReportArchived && !isAutoReimbursable && - (!shouldBeApproved || !shouldCheckApprovedState) && + !shouldBeApproved && !isPayAtEndExpenseReport ); } diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 81f7116b3da7..2cace722dbdc 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -318,7 +318,11 @@ registerPaginationConfig({ nextCommand: READ_COMMANDS.GET_NEWER_ACTIONS, resourceCollectionKey: ONYXKEYS.COLLECTION.REPORT_ACTIONS, pageCollectionKey: ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES, - sortItems: (reportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, true), + sortItems: (reportActions, reportID) => { + const report = ReportUtils.getReport(reportID); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); + return ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, canUserPerformWriteAction, true); + }, getItemID: (reportAction) => reportAction.reportActionID, }); @@ -728,7 +732,6 @@ function updateGroupChatAvatar(reportID: string, file?: File | CustomRNImageMani key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { avatarUrl: file ? file?.uri ?? '' : null, - avatarFileName: file ? file?.name ?? '' : null, pendingFields: { avatar: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, @@ -746,7 +749,6 @@ function updateGroupChatAvatar(reportID: string, file?: File | CustomRNImageMani key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { avatarUrl: fetchedReport?.avatarUrl ?? null, - avatarFileName: fetchedReport?.avatarFileName ?? null, pendingFields: { avatar: null, }, @@ -1087,14 +1089,7 @@ function navigateToAndOpenReport( if (isEmptyObject(chat)) { if (isGroupChat) { // If we are creating a group chat then participantAccountIDs is expected to contain currentUserAccountID - newChat = ReportUtils.buildOptimisticGroupChatReport( - participantAccountIDs, - reportName ?? '', - avatarUri ?? '', - avatarFile?.name ?? '', - optimisticReportID, - CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, - ); + newChat = ReportUtils.buildOptimisticGroupChatReport(participantAccountIDs, reportName ?? '', avatarUri ?? '', optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS); } else { newChat = ReportUtils.buildOptimisticChatReport( [...participantAccountIDs, currentUserAccountID], @@ -1525,8 +1520,10 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) { lastVisibleActionCreated: '', }; const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportUtils.getLastVisibleMessage(originalReportID, optimisticReportActions as ReportActions); + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); if (lastMessageText || lastMessageTranslationKey) { - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, optimisticReportActions as ReportActions); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, canUserPerformWriteAction, optimisticReportActions as ReportActions); const lastVisibleActionCreated = lastVisibleAction?.created; const lastActorAccountID = lastVisibleAction?.actorAccountID; optimisticReport = { @@ -1536,7 +1533,6 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) { lastActorAccountID, }; } - const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const didCommentMentionCurrentUser = ReportActionsUtils.didMessageMentionCurrentUser(reportAction); if (didCommentMentionCurrentUser && reportAction.created === report?.lastMentionedTime) { const reportActionsForReport = allReportActions?.[reportID]; @@ -1667,6 +1663,8 @@ function handleUserDeletedLinksInHtml(newCommentText: string, originalCommentMar /** Saves a new message for a comment. Marks the comment as edited, which will be reflected in the UI. */ function editReportComment(reportID: string, originalReportAction: OnyxEntry, textForNewComment: string, videoAttributeCache?: Record) { const originalReportID = ReportUtils.getOriginalReportID(reportID, originalReportAction); + const report = ReportUtils.getReport(originalReportID ?? '-1'); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); if (!originalReportID || !originalReportAction) { return; @@ -1732,7 +1730,7 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry { + return new Promise((resolve) => { + Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (value) => resolve(value ?? 0), + }); + }); +} + function signInAfterTransitionFromOldDot(transitionURL: string) { const [route, queryParams] = transitionURL.split('?'); @@ -528,7 +537,14 @@ function signInAfterTransitionFromOldDot(transitionURL: string) { [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}}, }), ) - .then(App.openApp) + .then(() => { + if (clearOnyxOnStart === 'true') { + return App.openApp(); + } + return getLastUpdateIDAppliedToClient().then((lastUpdateId) => { + return App.reconnectApp(lastUpdateId); + }); + }) .catch((error) => { Log.hmmm('[HybridApp] Initialization of HybridApp has failed. Forcing transition', {error}); }) diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 664bdb3779a6..aec6c4bd9d30 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -971,9 +971,10 @@ function deleteTask(report: OnyxEntry) { const optimisticReportActionID = optimisticCancelReportAction.reportActionID; const parentReportAction = getParentReportAction(report); const parentReport = getParentReport(report); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // If the task report is the last visible action in the parent report, we should navigate back to the parent report - const shouldDeleteTaskReport = !ReportActionsUtils.doesReportHaveVisibleActions(report.reportID ?? '-1'); + const shouldDeleteTaskReport = !ReportActionsUtils.doesReportHaveVisibleActions(report.reportID ?? '-1', canUserPerformWriteAction); const optimisticReportAction: Partial = { pendingAction: shouldDeleteTaskReport ? CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, previousMessage: parentReportAction?.message, @@ -1010,8 +1011,14 @@ function deleteTask(report: OnyxEntry) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${parentReport?.reportID}`, value: { - lastMessageText: ReportActionsUtils.getLastVisibleMessage(parentReport?.reportID ?? '-1', optimisticReportActions as OnyxTypes.ReportActions).lastMessageText ?? '', - lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(parentReport?.reportID ?? '-1', optimisticReportActions as OnyxTypes.ReportActions)?.created, + lastMessageText: + ReportActionsUtils.getLastVisibleMessage(parentReport?.reportID ?? '-1', canUserPerformWriteAction, optimisticReportActions as OnyxTypes.ReportActions).lastMessageText ?? + '', + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction( + parentReport?.reportID ?? '-1', + canUserPerformWriteAction, + optimisticReportActions as OnyxTypes.ReportActions, + )?.created, hasOutstandingChildTask, }, }, diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 46f17e76c083..40e2a6094ac7 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -1,53 +1,44 @@ import {useFocusEffect} from '@react-navigation/native'; -import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import type {AuthScreensParamList} from '@libs/Navigation/types'; import * as App from '@userActions/App'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import type {Session} from '@src/types/onyx'; - -type ConciergePageOnyxProps = { - /** Session info for the currently logged in user. */ - session: OnyxEntry; -}; - -type ConciergePageProps = ConciergePageOnyxProps & StackScreenProps; /* * This is a "utility page", that does this: * - If the user is authenticated, find their concierge chat and re-route to it * - Else re-route to the login page */ -function ConciergePage({session}: ConciergePageProps) { +function ConciergePage() { const styles = useThemeStyles(); const isUnmounted = useRef(false); const {shouldUseNarrowLayout} = useResponsiveLayout(); - - useFocusEffect(() => { - if (session && 'authToken' in session) { - App.confirmReadyToOpenApp(); - // Pop the concierge loading page before opening the concierge report. - Navigation.isNavigationReady().then(() => { - if (isUnmounted.current) { - return; - } - Report.navigateToConciergeChat(true, () => !isUnmounted.current); - }); - } else { - Navigation.navigate(); - } - }); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); + + useFocusEffect( + useCallback(() => { + if (session && 'authToken' in session) { + App.confirmReadyToOpenApp(); + Navigation.isNavigationReady().then(() => { + if (isUnmounted.current || isLoadingReportData === undefined || !!isLoadingReportData) { + return; + } + Report.navigateToConciergeChat(true, () => !isUnmounted.current); + }); + } else { + Navigation.navigate(); + } + }, [session, isLoadingReportData]), + ); useEffect(() => { isUnmounted.current = false; @@ -68,8 +59,4 @@ function ConciergePage({session}: ConciergePageProps) { ConciergePage.displayName = 'ConciergePage'; -export default withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, -})(ConciergePage); +export default ConciergePage; diff --git a/src/pages/Debug/ConstantPicker.tsx b/src/pages/Debug/ConstantPicker.tsx new file mode 100644 index 000000000000..564b2ea3d710 --- /dev/null +++ b/src/pages/Debug/ConstantPicker.tsx @@ -0,0 +1,69 @@ +import isObject from 'lodash/isObject'; +import React, {useMemo, useState} from 'react'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import type {DebugForms} from './const'; +import {DETAILS_CONSTANT_FIELDS} from './const'; + +type ConstantPickerProps = { + formType: string; + /** The form to object the constant list of options */ + + /** Constant name to get list of options */ + fieldName: string; + + /** Current selected constant */ + fieldValue?: string; + + /** Callback to submit the selected constant */ + onSubmit: (item: ListItem) => void; +}; + +function ConstantPicker({formType, fieldName, fieldValue, onSubmit}: ConstantPickerProps) { + const {translate} = useLocalize(); + const [searchValue, setSearchValue] = useState(''); + const sections: ListItem[] = useMemo( + () => + Object.entries(DETAILS_CONSTANT_FIELDS[formType as DebugForms].find((field) => field.fieldName === fieldName)?.options ?? {}) + .reduce((acc: Array<[string, string]>, [key, value]) => { + // Option has multiple constants, so we need to flatten these into separate options + if (isObject(value)) { + acc.push(...Object.entries(value)); + return acc; + } + acc.push([key, String(value)]); + return acc; + }, []) + .map( + ([key, value]) => + ({ + text: value, + keyForList: key, + isSelected: value === fieldValue, + searchText: value, + } satisfies ListItem), + ) + .filter(({searchText}) => searchText.toLowerCase().includes(searchValue.toLowerCase())), + [fieldName, fieldValue, formType, searchValue], + ); + const selectedOptionKey = useMemo(() => sections.filter((option) => option.searchText === fieldValue).at(0)?.keyForList, [sections, fieldValue]); + + return ( + + ); +} + +ConstantPicker.default = 'ConstantPicker'; + +export default ConstantPicker; diff --git a/src/pages/Debug/ConstantSelector.tsx b/src/pages/Debug/ConstantSelector.tsx index d6a3c0cfb4b1..c2df1f3e3e2a 100644 --- a/src/pages/Debug/ConstantSelector.tsx +++ b/src/pages/Debug/ConstantSelector.tsx @@ -1,5 +1,6 @@ import {useRoute} from '@react-navigation/native'; import React, {useEffect} from 'react'; +import type {ValueOf} from 'type-fest'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; @@ -21,9 +22,14 @@ type ConstantSelectorProps = { /** inputID used by the Form component */ // eslint-disable-next-line react/no-unused-prop-types inputID: string; + + /** Type of debug form - required to access constant field options for a specific form */ + formType: ValueOf; + + policyID?: string; }; -function ConstantSelector({errorText = '', name, value, onInputChange}: ConstantSelectorProps) { +function ConstantSelector({formType, policyID, errorText = '', name, value, onInputChange}: ConstantSelectorProps) { const fieldValue = (useRoute().params as Record | undefined)?.[name]; useEffect(() => { @@ -49,7 +55,7 @@ function ConstantSelector({errorText = '', name, value, onInputChange}: Constant brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={errorText} onPress={() => { - Navigation.navigate(ROUTES.DETAILS_CONSTANT_PICKER_PAGE.getRoute(name, value, Navigation.getActiveRoute())); + Navigation.navigate(ROUTES.DETAILS_CONSTANT_PICKER_PAGE.getRoute(formType, name, value, policyID, Navigation.getActiveRoute())); }} shouldShowRightIcon /> diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx index 6ee14660dbe9..60126ef1937a 100644 --- a/src/pages/Debug/DebugDetails.tsx +++ b/src/pages/Debug/DebugDetails.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useMemo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; import FormProvider from '@components/Form/FormProvider'; @@ -14,18 +15,24 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ObjectType, OnyxDataType} from '@libs/DebugUtils'; import DebugUtils from '@libs/DebugUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils'; import Debug from '@userActions/Debug'; +import type CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction} from '@src/types/onyx'; -import type {DetailsConstantFieldsKeys, DetailsDatetimeFieldsKeys, DetailsDisabledKeys} from './const'; +import TRANSACTION_FORM_INPUT_IDS from '@src/types/form/DebugTransactionForm'; +import type {Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; import {DETAILS_CONSTANT_FIELDS, DETAILS_DATETIME_FIELDS, DETAILS_DISABLED_KEYS} from './const'; import ConstantSelector from './ConstantSelector'; import DateTimeSelector from './DateTimeSelector'; type DebugDetailsProps = { + /** Type of debug form - required to access constant field options for a specific form */ + formType: ValueOf; + /** The report or report action data to be displayed and editted. */ - data: OnyxEntry | OnyxEntry; + data: OnyxEntry | OnyxEntry | OnyxEntry | OnyxEntry; children?: React.ReactNode; @@ -40,10 +47,13 @@ type DebugDetailsProps = { validate: (key: any, value: string) => void; }; -function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetailsProps) { +function DebugDetails({formType, data, children, onSave, onDelete, validate}: DebugDetailsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [formDraftData] = useOnyx(ONYXKEYS.FORMS.DEBUG_DETAILS_FORM_DRAFT); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${(data as OnyxEntry)?.reportID ?? ''}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); const booleanFields = useMemo( () => Object.entries(data ?? {}) @@ -54,9 +64,15 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails const constantFields = useMemo( () => Object.entries(data ?? {}) - .filter((entry): entry is [string, string] => DETAILS_CONSTANT_FIELDS.includes(entry[0] as DetailsConstantFieldsKeys)) + .filter((entry): entry is [string, string] => { + // Tag picker needs to be hidden when the policy has no tags available to pick + if (entry[0] === TRANSACTION_FORM_INPUT_IDS.TAG && !TagsOptionsListUtils.hasEnabledTags(policyTagLists)) { + return false; + } + return DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]); + }) .sort((a, b) => a[0].localeCompare(b[0])), - [data], + [data, formType, policyTagLists], ); const numberFields = useMemo( () => @@ -69,19 +85,16 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails () => Object.entries(data ?? {}) .filter( - (entry): entry is [string, string | ObjectType] => + (entry): entry is [string, string | ObjectType>] => (typeof entry[1] === 'string' || typeof entry[1] === 'object') && - !DETAILS_CONSTANT_FIELDS.includes(entry[0] as DetailsConstantFieldsKeys) && - !DETAILS_DATETIME_FIELDS.includes(entry[0] as DetailsDatetimeFieldsKeys), + !DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]) && + !DETAILS_DATETIME_FIELDS.includes(entry[0]), ) .map(([key, value]) => [key, DebugUtils.onyxDataToString(value)]) .sort((a, b) => (a.at(0) ?? '').localeCompare(b.at(0) ?? '')), - [data], - ); - const dateTimeFields = useMemo( - () => Object.entries(data ?? {}).filter((entry): entry is [string, string] => DETAILS_DATETIME_FIELDS.includes(entry[0] as DetailsDatetimeFieldsKeys)), - [data], + [data, formType], ); + const dateTimeFields = useMemo(() => Object.entries(data ?? {}).filter((entry): entry is [string, string] => DETAILS_DATETIME_FIELDS.includes(entry[0])), [data]); const validator = useCallback( (values: FormOnyxValues): FormInputErrors => { @@ -161,7 +174,7 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails numberOfLines={numberOfLines} multiline={numberOfLines > 1} defaultValue={value} - disabled={DETAILS_DISABLED_KEYS.includes(key as DetailsDisabledKeys)} + disabled={DETAILS_DISABLED_KEYS.includes(key)} shouldInterceptSwipe /> ); @@ -179,11 +192,11 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails forceActiveLabel label={key} defaultValue={String(value)} - disabled={DETAILS_DISABLED_KEYS.includes(key as DetailsDisabledKeys)} + disabled={DETAILS_DISABLED_KEYS.includes(key)} shouldInterceptSwipe /> ))} - {numberFields.length === 0 && {translate('debug.none')}} + {numberFields.length === 0 && {translate('debug.none')}} {translate('debug.constantFields')} @@ -192,9 +205,11 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails key={key} InputComponent={ConstantSelector} inputID={key} + formType={formType} name={key} shouldSaveDraft defaultValue={String(value)} + policyID={report?.policyID} /> ))} {constantFields.length === 0 && {translate('debug.none')}} @@ -225,7 +240,7 @@ function DebugDetails({data, children, onSave, onDelete, validate}: DebugDetails defaultValue={value} /> ))} - {booleanFields.length === 0 && {translate('debug.none')}} + {booleanFields.length === 0 && {translate('debug.none')}} {translate('debug.hint')} diff --git a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx index a98ef9963542..fca11799fd5d 100644 --- a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx +++ b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx @@ -1,86 +1,98 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import isObject from 'lodash/isObject'; -import React, {useMemo, useState} from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; +import CategoryPicker from '@components/CategoryPicker'; +import CurrencySelectionList from '@components/CurrencySelectionList'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; -import RadioListItem from '@components/SelectionList/RadioListItem'; import type {ListItem} from '@components/SelectionList/types'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {DebugParamList} from '@libs/Navigation/types'; import {appendParam} from '@libs/Url'; +import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; -import {DETAILS_CONSTANT_OPTIONS} from './const'; +import TRANSACTION_FORM_INPUT_IDS from '@src/types/form/DebugTransactionForm'; +import ConstantPicker from './ConstantPicker'; +import DebugTagPicker from './DebugTagPicker'; type DebugDetailsConstantPickerPageProps = StackScreenProps; function DebugDetailsConstantPickerPage({ route: { - params: {fieldName, fieldValue, backTo = ''}, + params: {formType, fieldName, fieldValue, policyID = '', backTo = ''}, }, navigation, }: DebugDetailsConstantPickerPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const [searchValue, setSearchValue] = useState(''); - const sections: ListItem[] = useMemo( - () => - Object.entries(DETAILS_CONSTANT_OPTIONS[fieldName as keyof typeof DETAILS_CONSTANT_OPTIONS]) - .reduce((acc: Array<[string, string]>, [key, value]) => { - // Option has multiple constants, so we need to flatten these into separate options - if (isObject(value)) { - acc.push(...Object.entries(value)); - return acc; - } - acc.push([key, value as string]); - return acc; - }, []) - .map( - ([key, value]) => - ({ - text: value, - keyForList: key, - isSelected: value === fieldValue, - searchText: value, - } satisfies ListItem), - ) - .filter(({searchText}) => searchText.toLowerCase().includes(searchValue.toLowerCase())), - [fieldName, fieldValue, searchValue], + const onSubmit = useCallback( + (item: ListItem) => { + const value = item.text === fieldValue ? '' : item.text ?? ''; + // Check the navigation state and "backTo" parameter to decide navigation behavior + if (navigation.getState().routes.length === 1 && !backTo) { + // If there is only one route and "backTo" is empty, go back in navigation + Navigation.goBack(); + } else if (!!backTo && navigation.getState().routes.length === 1) { + // If "backTo" is not empty and there is only one route, go back to the specific route defined in "backTo" with a country parameter + Navigation.goBack(appendParam(backTo, fieldName, value)); + } else { + // Otherwise, navigate to the specific route defined in "backTo" with a country parameter + Navigation.navigate(appendParam(backTo, fieldName, value)); + } + }, + [backTo, fieldName, fieldValue, navigation], ); - const onSubmit = (item: ListItem) => { - const value = item.text === fieldValue ? '' : item.text ?? ''; - // Check the navigation state and "backTo" parameter to decide navigation behavior - if (navigation.getState().routes.length === 1 && !backTo) { - // If there is only one route and "backTo" is empty, go back in navigation - Navigation.goBack(); - } else if (!!backTo && navigation.getState().routes.length === 1) { - // If "backTo" is not empty and there is only one route, go back to the specific route defined in "backTo" with a country parameter - Navigation.goBack(appendParam(backTo, fieldName, value)); - } else { - // Otherwise, navigate to the specific route defined in "backTo" with a country parameter - Navigation.navigate(appendParam(backTo, fieldName, value)); + + const renderPicker = useCallback(() => { + if (([TRANSACTION_FORM_INPUT_IDS.CURRENCY, TRANSACTION_FORM_INPUT_IDS.MODIFIED_CURRENCY, TRANSACTION_FORM_INPUT_IDS.ORIGINAL_CURRENCY] as string[]).includes(fieldName)) { + return ( + + onSubmit({ + text: currencyCode, + }) + } + searchInputLabel={translate('common.search')} + /> + ); } - }; - const selectedOptionKey = useMemo(() => sections.filter((option) => option.searchText === fieldValue).at(0)?.keyForList, [sections, fieldValue]); + if (formType === CONST.DEBUG.FORMS.TRANSACTION) { + if (fieldName === TRANSACTION_FORM_INPUT_IDS.CATEGORY) { + return ( + + ); + } + if (fieldName === TRANSACTION_FORM_INPUT_IDS.TAG) { + return ( + + ); + } + } + + return ( + + ); + }, [fieldName, fieldValue, formType, onSubmit, policyID, translate]); return ( - - - + {renderPicker()} ); } diff --git a/src/pages/Debug/DebugTagPicker.tsx b/src/pages/Debug/DebugTagPicker.tsx new file mode 100644 index 000000000000..1aa24d359a3a --- /dev/null +++ b/src/pages/Debug/DebugTagPicker.tsx @@ -0,0 +1,82 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import type {ListItem} from '@components/SelectionList/types'; +import TagPicker from '@components/TagPicker'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as IOUUtils from '@libs/IOUUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type DebugTagPickerProps = { + /** The policyID we are getting tags for */ + policyID: string; + + /** Current tag name */ + tagName?: string; + + /** Callback to submit the selected tag */ + onSubmit: (item: ListItem) => void; +}; + +function DebugTagPicker({policyID, tagName = '', onSubmit}: DebugTagPickerProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [newTagName, setNewTagName] = useState(tagName); + const selectedTags = useMemo(() => TransactionUtils.getTagArrayFromName(newTagName), [newTagName]); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); + + const updateTagName = useCallback( + (index: number) => + ({text}: ListItem) => { + const newTag = text === selectedTags.at(index) ? undefined : text; + const updatedTagName = IOUUtils.insertTagIntoTransactionTagsString(newTagName, newTag ?? '', index); + if (policyTagLists.length === 1) { + return onSubmit({text: updatedTagName}); + } + setNewTagName(updatedTagName); + }, + [newTagName, onSubmit, policyTagLists.length, selectedTags], + ); + + const submitTag = useCallback(() => { + onSubmit({text: newTagName}); + }, [newTagName, onSubmit]); + + return ( + + + {policyTagLists.map(({name}, index) => ( + + {policyTagLists.length > 1 && {name}} + + + ))} + + {policyTagLists.length > 1 && ( + +