diff --git a/android/app/build.gradle b/android/app/build.gradle
index ed9f5c677615..9acfedaec6b1 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -90,8 +90,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001037511
- versionName "1.3.75-11"
+ versionCode 1001037603
+ versionName "1.3.76-3"
}
flavorDimensions "default"
diff --git a/docs/_config.yml b/docs/_config.yml
index 4a0ce8c053c5..dc134d0d2c19 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -1,7 +1,7 @@
title: Expensify Help
tagline: Expensify Help - all your Expensify questions answered in one place.
description: Got a question about receipts, expenses, corporate cards, or anything else in the spend management universe? Get answers at help.expensify.com.
-url: help.expensify.com
+url: https://help.expensify.com
author: Expensify
logo: /assets/images/expensify-help.svg
open_url: true
diff --git a/docs/articles/expensify-classic/exports/Default-Export-Templates.md b/docs/articles/expensify-classic/exports/Default-Export-Templates.md
index 7650cff38946..f6043aaea2eb 100644
--- a/docs/articles/expensify-classic/exports/Default-Export-Templates.md
+++ b/docs/articles/expensify-classic/exports/Default-Export-Templates.md
@@ -2,4 +2,29 @@
title: Default Export Templates
description: Default Export Templates
---
-## Resource Coming Soon!
+# Overview
+Use default export templates for exporting report data to a CSV format, for data analysis, or uploading to an accounting software.
+Below is a breakdown of the available default templates.
+# How to use default export templates
+- **All Data - Expense Level Export** - This export prints a line for each expense with all of the data associated with the expenses. This is useful if you want to see all of the data stored in Expensify for each expense.
+- **All Data - Report Level Export** - This export prints a line per report, giving a summary of the report data.
+- **Basic Export** - A simpler expense level export without as much detail. This exports the data visible on the PDF of the report. Basics such as date, amount, merchant, category, tag, reimbursable state, description, receipt URL, and original expense currency and amount.
+- **Canadian Multiple Tax Export** - Exports a line per expense with all available info on the taxes applied to the expenses on your report(s). This is useful if you need to see the tax spend.
+- **Category Export** - Exports category names with the total amount attributed to each category on the report. While you can also access this information on the Insights page, it can be convenient to export to a CSV to run further analysis in your favorite spreadsheet program.
+- **Per Diem Export** - This exports basic expense details only for the per diem expenses on the report. Useful for reviewing employee Per Diem spend.
+- **Tag Export** - Exports tag names into columns with the total amount attributed to each tag on the report.
+
+# How to export using a default template
+1. Navigate to your Reports page
+2. Select the reports you want to export (you can use the filters to help you find the reports you’re after)
+3. Click the **Export to** in the top right corner
+4. Select the export template you’d like to use
+
+# FAQ
+## Why are my numbers exporting in a weird format?
+Do your numbers look something like this: 1.7976931348623157e+308? This means that your spreadsheet program is formatting long numbers in an exponential or scientific format. If that happens, you can correct it by changing the data to Plain Text or a Number in your spreadsheet program.
+## Why are my leading zeros missing?
+Is the export showing “1” instead of “01”? This means that your spreadsheet program is cutting off the leading zero. This is a common issue with viewing exported data in Excel. Unfortunately, we don’t have a good solution for this. We recommend checking your spreadsheet program’s help documents for suggestions for formatting.
+## I want a report that is not in the default list, how can I build that?
+For a guide on building your own custom template check out Exports > Custom Exports in the Help pages!
+
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Gusto.md b/docs/articles/expensify-classic/integrations/HR-integrations/Gusto.md
index 3ee1c8656b4b..f7a5127c9c0e 100644
--- a/docs/articles/expensify-classic/integrations/HR-integrations/Gusto.md
+++ b/docs/articles/expensify-classic/integrations/HR-integrations/Gusto.md
@@ -1,5 +1,55 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Gusto Integration
+description: Automatically sync employees between Gusto and Expensify
---
-## Resource Coming Soon!
+
+# Overview
+
+Expensify's direct integration with Gusto will automatically:
+
+- **Create new Expensify accounts** for full-time, active employees when they're hired
+- **Update the approval workflow in Expensify** based on any changes in Gusto
+- **Deprovision an employee's Expensify account** upon Gusto termination date
+
+# How to connect the Gusto integration
+## Before connecting Expensify with Gusto, please review the prerequisites:
+
+- You must be an admin in both Gusto and in Expensify to establish the integration
+- You must have a paid group workspace in Expensify (i.e. a Control or Collect workspace)
+- Every employee record in Gusto must have an email address, since that’s how each employee will sign into Expensify. We recommend that each employee's Gusto record use their work email address.
+- Gusto will add all employees to one Expensify workspace, so if you have more than one workspace, you'll need to choose one to connect to Gusto
+
+## To connect your Expensify workspace to Gusto:
+
+1. Navigate to **Settings > Workspaces > _[Workspace Name]_ > Connections**
+2. Scroll down to HR Integrations, click the **Connect with Gusto** radio button, then click the **Connect with Gusto** button
+3. Login to your Gusto account using your Gusto admin credentials and authorize Expensify to access your Gusto account
+
+## To configure the connection:
+
+1. Select the Approval Workflow that works best for your team
+ a. **Basic Approval** - Each employee will submit expense reports to one final approver. By default, the final approver is the workspace's Billing Owner in Expensify.
+ b. **Manager Approval** - Expense reports will first be submitted to each employee's direct manager as listed in Gusto, and then forwarded to one final approver (the Expensify workspace's Billing Owner by default). This option is only available on the Control workspace plan.
+ c. **Configure Manually** - Use the Members table to manually configure how employees submit reports. In this case, you're choosing to not import employee managers, and you will need to manually set and update the approval workflow for each employee. This option is only available on the Control workspace plan.
+2. Click **Save** in the bottom right corner to sync employees into Expensify
+3. If the connection is successful, you'll see a summary of how many employees were synced. If any employees were skipped, we'll tell you why.
+
+# FAQ
+## Can I import different sets of employees into different Expensify workspaces?
+
+No - Gusto will add all employees to one Expensify workspace, so if you have more than one workspace, you'll need to choose when connecting.
+
+## Can I change the Approval Workflow mode after connecting?
+
+Yes! You can change the Approval Workflow mode in two ways:
+
+1. Go to **Settings > Workspaces > _[Workspace Name]_ > Members**, then scroll down to Approval Mode below the list of workspace members
+2. Go to **Settings > Workspaces > _[Workspace Name]_ > Connections**, click Configure under Gusto, then select the desired Approval Mode and **Save**
+
+
+## Why do my employees have duplicate Expensify accounts after I set up the Gusto integration?
+
+If your employees are set up in Expensify with their company emails, but with their personal emails in Gusto, then they will end up with duplicate Expensify accounts after you connect the two systems. The Gusto integration imports users from Gusto using the emails entered in Gusto - if it's a different email from an existing account in Expensify, then a new, separate account will be created.
+
+To resolve this, you can ask each affected employee to merge their existing Expensify account with the new Expensify account by navigating to **Settings > Account > Account Details** and scrolling down to **Merge Accounts**.
+
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md
index 3ee1c8656b4b..e9077fc40a50 100644
--- a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md
+++ b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md
@@ -1,5 +1,104 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Workday Integration
+description: Automatically sync employees between Workday and Expensify
---
-## Resource Coming Soon!
+
+# Overview
+By leveraging Expensify's [Employee Updater API](https://integrations.expensify.com/Integration-Server/doc/employeeUpdater/), you can set up a fully customizable integration between Workday and Expensify. This integration can:
+
+- **Provision new employees in Expensify:** Employees are automatically invited to the correct Expensify workspace on their start date based on data in Workday.
+- **Update employees and approval workflows:** Any changes to employee email and manager are automatically updated in Expensify.
+- **Deprovision employees:** Employees can optionally be removed from their primary Expensify workspace on their termination date.
+Please note that while your Account Manager can help advise on setting up the Workday integration, the Expensify API is a self-serve tool.
+
+# How to set up an Advanced Custom Report in Workday
+The first step to integrating Workday with Expensify is to create an advanced custom report in Workday. This report can:
+- Map Workday column data to an Expensify Workspace for import.
+- Import employee names, email addresses and manager emails into the Expensify Workspace.
+- Set the employee’s **Submits To** column in the Expensify Members table.
+- Set the employee's Expensify **Custom Field 1 & 2**, typically used for Employee ID, Cost Center and/or Legal Entity.
+- Add employees to different Expensify Domain Groups.
+- Auto-assign Expensify Cards.
+
+In order to complete the steps below, you'll need a Workday System Administrator to create an **Integration System User** and **Integration System Security Group**.
+
+## Create an Integration System User
+1. Search "create user" and click **Create Integration System User**.
+2. Add a password, leave **Require New Password at Next Sign In** unchecked, set **Session Timeout Minutes** to 0, and check **Do Not Allow UI Sessions**.
+3. Click **OK**.
+
+## Create a Security Group
+1. Search "create security group", then click **Create Security Group**.
+2. Create a **Constrained** security group and specify the **Organizations** you'd like to sync data from.
+3. Add the **Integration System User** you created to your **Security Group**.
+4. Search and select "security group membership and access".
+5. Search for the security group you just created.
+6. Click the ellipsis, then **Security Group > Maintain Domain Permissions for Security Group**.
+7. Under **Integration Permissions**, add "External Account Provisioning" to **Domain Security Workspaces permitting Put access** and "Worker Data: Workers" to **Domain Security Workspaces permitting Get access**.
+8. Click **OK** and **Done**.
+9. Search **Activate Pending Security Workspace Changes** and complete the task for activating the security workspace change, adding a comment if required and checking the **Confirmed** check-box.
+
+## Create the Advanced Custom Report
+Before completing the steps below, you will need Workday Report Writer access to create an Advanced Custom Report in Workday and enable it as a RAAS (Report as a Service).
+
+1. Search “Create Custom Report” and click **Create Custom Report**.
+2. Enter the report details:
+ - Give the report a **Name**.
+ - Set the **Report Type** to **Advanced**.
+ - Check **Enable As Web Service**.
+ - Uncheck **Optimized for Performance**.
+ - For **Data Source**, search and select **All Active and Terminated Employees**.
+ - Click **OK**.
+3. Select the **Column Data** you’d like to sync with Expensify. Typical fields synced with Expensify from Workday are as follows (Required fields are marked with \*):
+ - First Name
+ - Last Name
+ - Primary Work Email\*
+ - Employee ID\*
+ - Expensify Workspace ID\*
+ - Worker’s Manager [Primary Work Email]\*
+ - Domain Group ID (If you want to specify a Domain Group in Expensify, please work with your Account Manager to get your Domain Group IDs)
+ - Cost Center
+ - Entity ID (sometimes called Legal Entity)
+ - Active/Inactive
+ - Termination Date
+ - Note: _if there is field data you want to import that is not listed above, or you have any special requests, let your Expensify Account Manager know and we will work with you to accommodate the request._
+4. Rename the columns so they match Expensify's API key names (The full list of names are found here):
+ - employeeID
+ - firstName
+ - lastName
+ - employeeEmail
+ - managerEmail
+ - workspaceID
+ - domainGroupID
+ - approvesTo
+Switch to the **Share** tab, and share the report with your **Integration System User** and **Security Group**.
+
+## Enable your report as a Report as a Service (RAAS)
+
+1. In your Workday tenant, search “view custom report” and select it. On the **View Custom Report** screen, click **My Reports**.
+2. Select the report you have created and click **OK**.
+3. Click **Actions > Web Service > View URLs** and click **OK**.
+4. Scroll to the **JSON** section, right-click **JSON**, then select **Copy URL**.
+
+## Activate the Workday Integration
+
+If you would like to enable and run the API job that performs a recurring sync, you can do so by following Expensify’s API reference documentation [here](https://integrations.expensify.com/Integration-Server/doc/employeeUpdater/#api-principles).
+
+If you would like Expensify to perform the sync on your behalf, please follow the steps below.
+
+1. To generate your **Expensify API Credentials**, log into Expensify with an account that has both Workspace Admin and Domain Admin access, then head to https://www.expensify.com/tools/integrations/ where you will find your partnerUserID and partnerUserSecret.
+2. Go to **Settings > Workspaces > Group > _[Workspace Name]_ > Connections > HR Integrations** and click **Connect to Workday**.
+3. In the form, supply the following details:
+ - partnerUserID
+ - partnerUserSecret
+ - Workday ISU Username (e.g. ISU_Expensify)
+ - Workday password
+ - Workday REST Web Services URL
+ - Preferred go live date (e.g. YYYY/MM/DD, or leave blank)
+ - Expensify Card Auto-Assignment? (Y/N)
+ - Note: If using Expensify Cards, card auto-assignment occurs when a Smart Limit for a Group is enabled.
+ - Deprovision Users? (Y/N)
+
+After you submit the form, the request is sent to your Expensify Account Manager. Your Account Manager will create a recurring sync that will retrieve the data columns from your Workday Web Services URL and apply the rule mappings you have specified above.
+
+If we have any questions, we will reach out to you via direct message.
diff --git a/docs/assets/images/add-australian-deposit-only-account-modal.png b/docs/assets/images/add-australian-deposit-only-account-modal.png
new file mode 100644
index 000000000000..1196a57c8f8f
Binary files /dev/null and b/docs/assets/images/add-australian-deposit-only-account-modal.png differ
diff --git a/docs/assets/images/add-australian-deposit-only-account.png b/docs/assets/images/add-australian-deposit-only-account.png
new file mode 100644
index 000000000000..4cea4fb11757
Binary files /dev/null and b/docs/assets/images/add-australian-deposit-only-account.png differ
diff --git a/docs/assets/images/add-vba-australian-account-modal.png b/docs/assets/images/add-vba-australian-account-modal.png
new file mode 100644
index 000000000000..ee624eca3814
Binary files /dev/null and b/docs/assets/images/add-vba-australian-account-modal.png differ
diff --git a/docs/assets/images/add-vba-australian-account.png b/docs/assets/images/add-vba-australian-account.png
new file mode 100644
index 000000000000..f064225e176a
Binary files /dev/null and b/docs/assets/images/add-vba-australian-account.png differ
diff --git a/docs/assets/images/delete-australian-bank-account.png b/docs/assets/images/delete-australian-bank-account.png
new file mode 100644
index 000000000000..2148973e5a6c
Binary files /dev/null and b/docs/assets/images/delete-australian-bank-account.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 23e9a98a9629..761f2d21dd77 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.3.75
+ 1.3.76
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.75.11
+ 1.3.76.3
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 884394a03de4..6018aeaaeeb6 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.3.75
+ 1.3.76
CFBundleSignature
????
CFBundleVersion
- 1.3.75.11
+ 1.3.76.3
diff --git a/package-lock.json b/package-lock.json
index 9737202667f7..80e0580b227e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.75-11",
+ "version": "1.3.76-3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.75-11",
+ "version": "1.3.76-3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 21d64eb02859..1dddee3d7bd2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.75-11",
+ "version": "1.3.76-3",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index 14d13a63eec3..83a410b7f0b0 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -208,14 +208,15 @@ function AddressSearch(props) {
// Autocomplete returns any additional valid address fragments (e.g. Apt #) as subpremise.
street2: subpremise,
-
+ // Make sure country is updated first, since city and state will be reset if the country changes
+ country: '',
// When locality is not returned, many countries return the city as postalTown (e.g. 5 New Street
// Square, London), otherwise as sublocality (e.g. 384 Court Street Brooklyn). If postalTown is
// returned, the sublocality will be a city subdivision so shouldn't take precedence (e.g.
// Salagatan, Upssala, Sweden).
city: locality || postalTown || sublocality || cityAutocompleteFallback,
zipCode,
- country: '',
+
state: state || stateAutoCompleteFallback,
lat: lodashGet(details, 'geometry.location.lat', 0),
lng: lodashGet(details, 'geometry.location.lng', 0),
diff --git a/src/components/Checkbox.js b/src/components/Checkbox.js
index 1bb5824f612a..394e73951c09 100644
--- a/src/components/Checkbox.js
+++ b/src/components/Checkbox.js
@@ -91,7 +91,7 @@ function Checkbox(props) {
onPress={firePressHandlerOnClick}
onMouseDown={props.onMouseDown}
ref={props.forwardedRef}
- style={[props.style, styles.checkboxPressable]}
+ style={[props.style, StyleUtils.getCheckboxPressableStyle(props.containerBorderRadius + 2)]} // to align outline on focus, border-radius of pressable should be 2px more than Checkbox
onKeyDown={handleSpaceKey}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
accessibilityState={{checked: props.isChecked}}
diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js
index f39c44b278ae..38ea64952a2c 100644
--- a/src/components/Hoverable/index.js
+++ b/src/components/Hoverable/index.js
@@ -1,73 +1,103 @@
import _ from 'underscore';
-import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react';
+import React, {Component} from 'react';
import {DeviceEventEmitter} from 'react-native';
import {propTypes, defaultProps} from './hoverablePropTypes';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
import CONST from '../../CONST';
-function mapChildren(children, callbackParam) {
- if (_.isArray(children) && children.length === 1) {
- return children[0];
- }
-
- if (_.isFunction(children)) {
- return children(callbackParam);
- }
-
- return children;
-}
-
/**
* It is necessary to create a Hoverable component instead of relying solely on Pressable support for hover state,
* because nesting Pressables causes issues where the hovered state of the child cannot be easily propagated to the
* parent. https://github.com/necolas/react-native-web/issues/1875
*/
+class Hoverable extends Component {
+ constructor(props) {
+ super(props);
-function InnerHoverable({disabled, onHoverIn, onHoverOut, children, shouldHandleScroll}, outerRef) {
- const [isHovered, setIsHovered] = useState(false);
-
- const isScrolling = useRef(false);
- const isHoveredRef = useRef(false);
- const ref = useRef(null);
+ this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
+ this.checkHover = this.checkHover.bind(this);
- const updateIsHoveredOnScrolling = useCallback(
- (hovered) => {
- if (disabled) {
- return;
- }
+ this.state = {
+ isHovered: false,
+ };
- isHoveredRef.current = hovered;
+ this.isHoveredRef = false;
+ this.isScrollingRef = false;
+ this.wrapperView = null;
+ }
- if (shouldHandleScroll && isScrolling.current) {
- return;
- }
- setIsHovered(hovered);
- },
- [disabled, shouldHandleScroll],
- );
+ componentDidMount() {
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
+ document.addEventListener('mouseover', this.checkHover);
+
+ /**
+ * Only add the scrolling listener if the shouldHandleScroll prop is true
+ * and the scrollingListener is not already set.
+ */
+ if (!this.scrollingListener && this.props.shouldHandleScroll) {
+ this.scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
+ /**
+ * If user has stopped scrolling and the isHoveredRef is true, then we should update the hover state.
+ */
+ if (!scrolling && this.isHoveredRef) {
+ this.setState({isHovered: this.isHoveredRef}, this.props.onHoverIn);
+ } else if (scrolling && this.isHoveredRef) {
+ /**
+ * If the user has started scrolling and the isHoveredRef is true, then we should set the hover state to false.
+ * This is to hide the existing hover and reaction bar.
+ */
+ this.setState({isHovered: false}, this.props.onHoverOut);
+ }
+ this.isScrollingRef = scrolling;
+ });
+ }
+ }
- useEffect(() => {
- const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false);
+ componentDidUpdate(prevProps) {
+ if (prevProps.disabled === this.props.disabled) {
+ return;
+ }
- document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
+ if (this.props.disabled && this.state.isHovered) {
+ this.setState({isHovered: false});
+ }
+ }
- return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
- }, []);
+ componentWillUnmount() {
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
+ document.removeEventListener('mouseover', this.checkHover);
+ if (this.scrollingListener) {
+ this.scrollingListener.remove();
+ }
+ }
- useEffect(() => {
- if (!shouldHandleScroll) {
+ /**
+ * Sets the hover state of this component to true and execute the onHoverIn callback.
+ *
+ * @param {Boolean} isHovered - Whether or not this component is hovered.
+ */
+ setIsHovered(isHovered) {
+ if (this.props.disabled) {
return;
}
- const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
- isScrolling.current = scrolling;
- if (!scrolling) {
- setIsHovered(isHoveredRef.current);
- }
- });
+ /**
+ * Capture whther or not the user is hovering over the component.
+ * We will use this to determine if we should update the hover state when the user has stopped scrolling.
+ */
+ this.isHoveredRef = isHovered;
- return () => scrollingListener.remove();
- }, [shouldHandleScroll]);
+ /**
+ * If the isScrollingRef is true, then the user is scrolling and we should not update the hover state.
+ */
+ if (this.isScrollingRef && this.props.shouldHandleScroll && !this.state.isHovered) {
+ return;
+ }
+
+ if (isHovered !== this.state.isHovered) {
+ this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut);
+ }
+ }
/**
* Checks the hover state of a component and updates it based on the event target.
@@ -75,108 +105,85 @@ function InnerHoverable({disabled, onHoverIn, onHoverOut, children, shouldHandle
* such as when an element is removed before the mouseleave event is triggered.
* @param {Event} e - The hover event object.
*/
- const unsetHoveredIfOutside = useCallback(
- (e) => {
- if (!ref.current || !isHovered) {
- return;
- }
-
- if (ref.current.contains(e.target)) {
- return;
- }
-
- setIsHovered(false);
- },
- [isHovered],
- );
-
- useEffect(() => {
- if (!DeviceCapabilities.hasHoverSupport()) {
+ checkHover(e) {
+ if (!this.wrapperView || !this.state.isHovered) {
return;
}
- document.addEventListener('mouseover', unsetHoveredIfOutside);
+ if (this.wrapperView.contains(e.target)) {
+ return;
+ }
- return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
- }, [unsetHoveredIfOutside]);
+ this.setIsHovered(false);
+ }
- useEffect(() => {
- if (!disabled || !isHovered) {
+ handleVisibilityChange() {
+ if (document.visibilityState !== 'hidden') {
return;
}
- setIsHovered(false);
- }, [disabled, isHovered]);
- useEffect(() => {
- if (disabled) {
- return;
+ this.setIsHovered(false);
+ }
+
+ render() {
+ let child = this.props.children;
+ if (_.isArray(this.props.children) && this.props.children.length === 1) {
+ child = this.props.children[0];
}
- if (onHoverIn && isHovered) {
- return onHoverIn();
+
+ if (_.isFunction(child)) {
+ child = child(this.state.isHovered);
}
- if (onHoverOut && !isHovered) {
- return onHoverOut();
+
+ if (!DeviceCapabilities.hasHoverSupport()) {
+ return child;
}
- }, [disabled, isHovered, onHoverIn, onHoverOut]);
-
- // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child.
- useImperativeHandle(outerRef, () => ref.current, []);
-
- const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]);
-
- const onMouseEnter = useCallback(
- (el) => {
- updateIsHoveredOnScrolling(true);
-
- if (_.isFunction(child.props.onMouseEnter)) {
- child.props.onMouseEnter(el);
- }
- },
- [child.props, updateIsHoveredOnScrolling],
- );
-
- const onMouseLeave = useCallback(
- (el) => {
- updateIsHoveredOnScrolling(false);
-
- if (_.isFunction(child.props.onMouseLeave)) {
- child.props.onMouseLeave(el);
- }
- },
- [child.props, updateIsHoveredOnScrolling],
- );
-
- const onBlur = useCallback(
- (el) => {
- // Check if the blur event occurred due to clicking outside the element
- // and the wrapperView contains the element that caused the blur and reset isHovered
- if (!ref.current.contains(el.target) && !ref.current.contains(el.relatedTarget)) {
- setIsHovered(false);
- }
-
- if (_.isFunction(child.props.onBlur)) {
- child.props.onBlur(el);
- }
- },
- [child.props],
- );
-
- if (!DeviceCapabilities.hasHoverSupport()) {
- return child;
- }
- return React.cloneElement(child, {
- ref,
- onMouseEnter,
- onMouseLeave,
- onBlur,
- });
+ return React.cloneElement(React.Children.only(child), {
+ ref: (el) => {
+ this.wrapperView = el;
+
+ // Call the original ref, if any
+ const {ref} = child;
+ if (_.isFunction(ref)) {
+ ref(el);
+ return;
+ }
+
+ if (_.isObject(ref)) {
+ ref.current = el;
+ }
+ },
+ onMouseEnter: (el) => {
+ this.setIsHovered(true);
+
+ if (_.isFunction(child.props.onMouseEnter)) {
+ child.props.onMouseEnter(el);
+ }
+ },
+ onMouseLeave: (el) => {
+ this.setIsHovered(false);
+
+ if (_.isFunction(child.props.onMouseLeave)) {
+ child.props.onMouseLeave(el);
+ }
+ },
+ onBlur: (el) => {
+ // Check if the blur event occurred due to clicking outside the element
+ // and the wrapperView contains the element that caused the blur and reset isHovered
+ if (!this.wrapperView.contains(el.target) && !this.wrapperView.contains(el.relatedTarget)) {
+ this.setIsHovered(false);
+ }
+
+ if (_.isFunction(child.props.onBlur)) {
+ child.props.onBlur(el);
+ }
+ },
+ });
+ }
}
-const Hoverable = React.forwardRef(InnerHoverable);
-
Hoverable.propTypes = propTypes;
Hoverable.defaultProps = defaultProps;
-Hoverable.displayName = 'Hoverable';
export default Hoverable;
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.js b/src/components/InvertedFlatList/BaseInvertedFlatList.js
index 0bfffb733052..10248697394f 100644
--- a/src/components/InvertedFlatList/BaseInvertedFlatList.js
+++ b/src/components/InvertedFlatList/BaseInvertedFlatList.js
@@ -1,8 +1,7 @@
-/* eslint-disable react/jsx-props-no-multi-spaces */
+import React, {forwardRef, useCallback, useRef} from 'react';
+import {View, FlatList as NativeFlatlist} from 'react-native';
import _ from 'underscore';
-import React, {forwardRef, Component} from 'react';
import PropTypes from 'prop-types';
-import {View, FlatList as NativeFlatlist} from 'react-native';
import * as CollectionUtils from '../../libs/CollectionUtils';
import FlatList from '../FlatList';
@@ -29,19 +28,14 @@ const defaultProps = {
shouldMeasureItems: false,
};
-class BaseInvertedFlatList extends Component {
- constructor(props) {
- super(props);
-
- this.renderItem = this.renderItem.bind(this);
- this.getItemLayout = this.getItemLayout.bind(this);
+function BaseInvertedFlatList(props) {
+ const {initialRowHeight, shouldMeasureItems, innerRef, renderItem} = props;
- // Stores each item's computed height after it renders
- // once and is then referenced for the life of this component.
- // This is essential to getting FlatList inverted to work on web
- // and also enables more predictable scrolling on native platforms.
- this.sizeMap = {};
- }
+ // Stores each item's computed height after it renders
+ // once and is then referenced for the life of this component.
+ // This is essential to getting FlatList inverted to work on web
+ // and also enables more predictable scrolling on native platforms.
+ const sizeMap = useRef({});
/**
* Return default or previously cached height for
@@ -52,8 +46,8 @@ class BaseInvertedFlatList extends Component {
*
* @return {Object}
*/
- getItemLayout(data, index) {
- const size = this.sizeMap[index];
+ const getItemLayout = (data, index) => {
+ const size = sizeMap.current[index];
if (size) {
return {
@@ -66,19 +60,19 @@ class BaseInvertedFlatList extends Component {
// If we don't have a size yet means we haven't measured this
// item yet. However, we can still calculate the offset by looking
// at the last size we have recorded (if any)
- const lastMeasuredItem = CollectionUtils.lastItem(this.sizeMap);
+ const lastMeasuredItem = CollectionUtils.lastItem(sizeMap.current);
return {
// We haven't measured this so we must return the minimum row height
- length: this.props.initialRowHeight,
+ length: initialRowHeight,
// Offset will either be based on the lastMeasuredItem or the index +
// initialRowHeight since we can only assume that all previous items
// have not yet been measured
- offset: _.isUndefined(lastMeasuredItem) ? this.props.initialRowHeight * index : lastMeasuredItem.offset + this.props.initialRowHeight,
+ offset: _.isUndefined(lastMeasuredItem) ? initialRowHeight * index : lastMeasuredItem.offset + initialRowHeight,
index,
};
- }
+ };
/**
* Measure item and cache the returned length (a.k.a. height)
@@ -86,26 +80,26 @@ class BaseInvertedFlatList extends Component {
* @param {React.NativeSyntheticEvent} nativeEvent
* @param {Number} index
*/
- measureItemLayout(nativeEvent, index) {
+ const measureItemLayout = useCallback((nativeEvent, index) => {
const computedHeight = nativeEvent.layout.height;
// We've already measured this item so we don't need to
// measure it again.
- if (this.sizeMap[index]) {
+ if (sizeMap.current[index]) {
return;
}
- const previousItem = this.sizeMap[index - 1] || {};
+ const previousItem = sizeMap.current[index - 1] || {};
// If there is no previousItem this can mean we haven't yet measured
// the previous item or that we are at index 0 and there is no previousItem
const previousLength = previousItem.length || 0;
const previousOffset = previousItem.offset || 0;
- this.sizeMap[index] = {
+ sizeMap.current[index] = {
length: computedHeight,
offset: previousLength + previousOffset,
};
- }
+ }, []);
/**
* Render item method wraps the prop renderItem to render in a
@@ -118,36 +112,34 @@ class BaseInvertedFlatList extends Component {
*
* @return {React.Component}
*/
- renderItem({item, index}) {
- if (this.props.shouldMeasureItems) {
- return this.measureItemLayout(nativeEvent, index)}>{this.props.renderItem({item, index})};
- }
-
- return this.props.renderItem({item, index});
- }
-
- render() {
- return (
-
- );
- }
+ const renderItemFromProp = useCallback(
+ ({item, index}) => {
+ if (shouldMeasureItems) {
+ return measureItemLayout(nativeEvent, index)}>{renderItem({item, index})};
+ }
+
+ return renderItem({item, index});
+ },
+ [shouldMeasureItems, measureItemLayout, renderItem],
+ );
+
+ return (
+
+ );
}
BaseInvertedFlatList.propTypes = propTypes;
BaseInvertedFlatList.defaultProps = defaultProps;
+BaseInvertedFlatList.displayName = 'BaseInvertedFlatList';
export default forwardRef((props, ref) => (
(({accessToken, style, ma
centerCoordinate: waypoints[0].coordinate,
});
} else {
- const {southWest, northEast} = utils.getBounds(waypoints.map((waypoint) => waypoint.coordinate));
+ const {southWest, northEast} = utils.getBounds(
+ waypoints.map((waypoint) => waypoint.coordinate),
+ directionCoordinates,
+ );
cameraRef.current?.fitBounds(northEast, southWest, mapPadding, 1000);
}
}
return () => {
setIsIdle(false);
};
- }, [mapPadding, waypoints, isIdle]),
+ }, [mapPadding, waypoints, isIdle, directionCoordinates]),
);
useEffect(() => {
diff --git a/src/components/MapView/MapView.web.tsx b/src/components/MapView/MapView.web.tsx
index 52f953e0d3bd..78c5a9175594 100644
--- a/src/components/MapView/MapView.web.tsx
+++ b/src/components/MapView/MapView.web.tsx
@@ -43,9 +43,12 @@ const MapView = forwardRef(
const map = mapRef.getMap();
- const {northEast, southWest} = utils.getBounds(waypoints.map((waypoint) => waypoint.coordinate));
+ const {northEast, southWest} = utils.getBounds(
+ waypoints.map((waypoint) => waypoint.coordinate),
+ directionCoordinates,
+ );
map.fitBounds([northEast, southWest], {padding: mapPadding});
- }, [waypoints, mapRef, mapPadding]);
+ }, [waypoints, mapRef, mapPadding, directionCoordinates]);
useEffect(() => {
if (!mapRef) {
diff --git a/src/components/MapView/utils.ts b/src/components/MapView/utils.ts
index c37d272296e5..b2c8fcad787b 100644
--- a/src/components/MapView/utils.ts
+++ b/src/components/MapView/utils.ts
@@ -1,6 +1,10 @@
-function getBounds(waypoints: Array<[number, number]>): {southWest: [number, number]; northEast: [number, number]} {
+function getBounds(waypoints: Array<[number, number]>, directionCoordinates: undefined | Array<[number, number]>): {southWest: [number, number]; northEast: [number, number]} {
const lngs = waypoints.map((waypoint) => waypoint[0]);
const lats = waypoints.map((waypoint) => waypoint[1]);
+ if (directionCoordinates) {
+ lngs.push(...directionCoordinates.map((coordinate) => coordinate[0]));
+ lats.push(...directionCoordinates.map((coordinate) => coordinate[1]));
+ }
return {
southWest: [Math.min(...lngs), Math.min(...lats)],
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 39e79d594f1d..71ec9d17ac70 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -396,7 +396,8 @@ function MoneyRequestConfirmationList(props) {
onSendMoney(paymentMethod);
} else {
// validate the amount for distance requests
- if (props.isDistanceRequest && !isDistanceRequestWithoutRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount))) {
+ const decimals = CurrencyUtils.getCurrencyDecimals(props.iouCurrencyCode);
+ if (props.isDistanceRequest && !isDistanceRequestWithoutRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) {
setFormError('common.error.invalidAmount');
return;
}
@@ -405,7 +406,7 @@ function MoneyRequestConfirmationList(props) {
onConfirm(selectedParticipants);
}
},
- [selectedParticipants, onSendMoney, onConfirm, props.iouType, props.isDistanceRequest, isDistanceRequestWithoutRoute, props.iouAmount],
+ [selectedParticipants, onSendMoney, onConfirm, props.iouType, props.isDistanceRequest, isDistanceRequestWithoutRoute, props.iouCurrencyCode, props.iouAmount],
);
const footerContent = useMemo(() => {
@@ -439,6 +440,7 @@ function MoneyRequestConfirmationList(props) {
onPress={(_event, value) => confirm(value)}
options={splitOrRequestOptions}
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
+ style={[styles.mt2]}
/>
);
@@ -502,7 +504,7 @@ function MoneyRequestConfirmationList(props) {
title={props.iouComment}
description={translate('common.description')}
onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION.getRoute(props.iouType, props.reportID))}
- style={[styles.moneyRequestMenuItem, styles.mb2]}
+ style={[styles.moneyRequestMenuItem]}
titleStyle={styles.flex1}
disabled={didConfirm || props.isReadOnly}
numberOfLinesTitle={2}
@@ -528,7 +530,7 @@ function MoneyRequestConfirmationList(props) {
shouldShowRightIcon={!props.isReadOnly && isTypeRequest}
title={props.iouCreated || format(new Date(), CONST.DATE.FNS_FORMAT_STRING)}
description={translate('common.date')}
- style={[styles.moneyRequestMenuItem, styles.mb2]}
+ style={[styles.moneyRequestMenuItem]}
titleStyle={styles.flex1}
onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_DATE.getRoute(props.iouType, props.reportID))}
disabled={didConfirm || props.isReadOnly || !isTypeRequest}
@@ -538,7 +540,7 @@ function MoneyRequestConfirmationList(props) {
shouldShowRightIcon={!props.isReadOnly && isTypeRequest}
title={props.iouMerchant}
description={translate('common.distance')}
- style={[styles.moneyRequestMenuItem, styles.mb2]}
+ style={[styles.moneyRequestMenuItem]}
titleStyle={styles.flex1}
onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))}
disabled={didConfirm || props.isReadOnly || !isTypeRequest}
@@ -548,7 +550,7 @@ function MoneyRequestConfirmationList(props) {
shouldShowRightIcon={!props.isReadOnly && isTypeRequest}
title={props.iouMerchant}
description={translate('common.merchant')}
- style={[styles.moneyRequestMenuItem, styles.mb2]}
+ style={[styles.moneyRequestMenuItem]}
titleStyle={styles.flex1}
onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_MERCHANT.getRoute(props.iouType, props.reportID))}
disabled={didConfirm || props.isReadOnly || !isTypeRequest}
@@ -560,7 +562,7 @@ function MoneyRequestConfirmationList(props) {
title={props.iouCategory}
description={translate('common.category')}
onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_CATEGORY.getRoute(props.iouType, props.reportID))}
- style={[styles.moneyRequestMenuItem, styles.mb2]}
+ style={[styles.moneyRequestMenuItem]}
disabled={didConfirm || props.isReadOnly}
/>
)}
@@ -570,12 +572,12 @@ function MoneyRequestConfirmationList(props) {
title={props.iouTag}
description={policyTagListName}
onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID))}
- style={[styles.moneyRequestMenuItem, styles.mb2]}
+ style={[styles.moneyRequestMenuItem]}
disabled={didConfirm || props.isReadOnly}
/>
)}
{shouldShowBillable && (
-
+
{translate('common.billable')}
gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS,
onPanResponderRelease: toggleTestToolsModal,
- });
+ }),
+ ).current;
- this.keyboardDissmissPanResponder = PanResponder.create({
+ const keyboardDissmissPanResponder = useRef(
+ PanResponder.create({
onMoveShouldSetPanResponderCapture: (e, gestureState) => {
const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy);
- const shouldDismissKeyboard = this.props.shouldDismissKeyboardBeforeClose && this.props.isKeyboardShown && Browser.isMobile();
+ const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && isKeyboardShown && Browser.isMobile();
+
return isHorizontalSwipe && shouldDismissKeyboard;
},
onPanResponderGrant: Keyboard.dismiss,
- });
+ }),
+ ).current;
- this.state = {
- didScreenTransitionEnd: false,
- };
- }
-
- componentDidMount() {
- this.unsubscribeTransitionEnd = this.props.navigation.addListener('transitionEnd', (event) => {
+ useEffect(() => {
+ const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', (event) => {
// Prevent firing the prop callback when user is exiting the page.
if (lodashGet(event, 'data.closing')) {
return;
}
- this.setState({didScreenTransitionEnd: true});
- this.props.onEntryTransitionEnd();
+
+ setDidScreenTransitionEnd(true);
+ onEntryTransitionEnd();
});
// We need to have this prop to remove keyboard before going away from the screen, to avoid previous screen look weird for a brief moment,
// also we need to have generic control in future - to prevent closing keyboard for some rare cases in which beforeRemove has limitations
// described here https://reactnavigation.org/docs/preventing-going-back/#limitations
- if (this.props.shouldDismissKeyboardBeforeClose) {
- this.beforeRemoveSubscription = this.props.navigation.addListener('beforeRemove', () => {
- if (!this.props.isKeyboardShown) {
- return;
- }
- Keyboard.dismiss();
- });
- }
- }
-
- /**
- * We explicitly want to ignore if props.modal changes, and only want to rerender if
- * any of the other props **used for the rendering output** is changed.
- * @param {Object} nextProps
- * @param {Object} nextState
- * @returns {boolean}
- */
- shouldComponentUpdate(nextProps, nextState) {
- return !_.isEqual(this.state, nextState) || !_.isEqual(_.omit(this.props, 'modal'), _.omit(nextProps, 'modal'));
- }
+ const beforeRemoveSubscription = shouldDismissKeyboardBeforeClose
+ ? navigation.addListener('beforeRemove', () => {
+ if (!isKeyboardShown) {
+ return;
+ }
+ Keyboard.dismiss();
+ })
+ : undefined;
- componentWillUnmount() {
- if (this.unsubscribeTransitionEnd) {
- this.unsubscribeTransitionEnd();
- }
- if (this.beforeRemoveSubscription) {
- this.beforeRemoveSubscription();
- }
- }
+ return () => {
+ unsubscribeTransitionEnd();
- render() {
- const maxHeight = this.props.shouldEnableMaxHeight ? this.props.windowHeight : undefined;
+ if (beforeRemoveSubscription) {
+ beforeRemoveSubscription();
+ }
+ };
+ // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
- return (
-
- {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => {
- const paddingStyle = {};
+ return (
+
+ {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => {
+ const paddingStyle = {};
- if (this.props.includePaddingTop) {
- paddingStyle.paddingTop = paddingTop;
- }
+ if (includePaddingTop) {
+ paddingStyle.paddingTop = paddingTop;
+ }
- // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked.
- if (this.props.includeSafeAreaPaddingBottom || this.props.network.isOffline) {
- paddingStyle.paddingBottom = paddingBottom;
- }
+ // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked.
+ if (includeSafeAreaPaddingBottom || isOffline) {
+ paddingStyle.paddingBottom = paddingBottom;
+ }
- return (
+ return (
+
-
-
-
-
- {this.props.environment === CONST.ENVIRONMENT.DEV && }
- {this.props.environment === CONST.ENVIRONMENT.DEV && }
- {
- // If props.children is a function, call it to provide the insets to the children.
- _.isFunction(this.props.children)
- ? this.props.children({
- insets,
- safeAreaPaddingBottomStyle,
- didScreenTransitionEnd: this.state.didScreenTransitionEnd,
- })
- : this.props.children
- }
- {this.props.isSmallScreenWidth && this.props.shouldShowOfflineIndicator && }
-
-
-
+
+ {isDevelopment && }
+ {isDevelopment && }
+ {
+ // If props.children is a function, call it to provide the insets to the children.
+ _.isFunction(children)
+ ? children({
+ insets,
+ safeAreaPaddingBottomStyle,
+ didScreenTransitionEnd,
+ })
+ : children
+ }
+ {isSmallScreenWidth && shouldShowOfflineIndicator && }
+
+
- );
- }}
-
- );
- }
+
+ );
+ }}
+
+ );
}
+ScreenWrapper.displayName = 'ScreenWrapper';
ScreenWrapper.propTypes = propTypes;
ScreenWrapper.defaultProps = defaultProps;
-export default compose(withNavigation, withEnvironment, withWindowDimensions, withKeyboardState, withNetwork())(ScreenWrapper);
+export default ScreenWrapper;
diff --git a/src/components/ScreenWrapper/propTypes.js b/src/components/ScreenWrapper/propTypes.js
index 9434402cc20f..848aa28cde43 100644
--- a/src/components/ScreenWrapper/propTypes.js
+++ b/src/components/ScreenWrapper/propTypes.js
@@ -1,7 +1,5 @@
import PropTypes from 'prop-types';
import stylePropTypes from '../../styles/stylePropTypes';
-import {windowDimensionsPropTypes} from '../withWindowDimensions';
-import {environmentPropTypes} from '../withEnvironment';
const propTypes = {
/** Array of additional styles to add */
@@ -42,10 +40,6 @@ const propTypes = {
/** Array of additional styles for header gap */
headerGapStyles: PropTypes.arrayOf(PropTypes.object),
- ...windowDimensionsPropTypes,
-
- ...environmentPropTypes,
-
/** Whether to show offline indicator */
shouldShowOfflineIndicator: PropTypes.bool,
diff --git a/src/components/SelectionList/BaseListItem.js b/src/components/SelectionList/BaseListItem.js
index d198979fdf8e..8a42c84ffc67 100644
--- a/src/components/SelectionList/BaseListItem.js
+++ b/src/components/SelectionList/BaseListItem.js
@@ -44,10 +44,10 @@ function BaseListItem({item, isFocused = false, isDisabled = false, showTooltip,
]}
>
{canSelectMultiple && (
-
+
{
+ onSelectAll();
+ if (shouldShowTextInput && shouldFocusOnSelectRow && textInputRef.current) {
+ textInputRef.current.focus();
+ }
};
const selectFocusedOption = () => {
@@ -349,7 +361,13 @@ function BaseSelectionList({
{shouldShowTextInput && (
{
+ if (inputRef) {
+ // eslint-disable-next-line no-param-reassign
+ inputRef.current = el;
+ }
+ textInputRef.current = el;
+ }}
label={textInputLabel}
accessibilityLabel={textInputLabel}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
@@ -376,7 +394,7 @@ function BaseSelectionList({
{!headerMessage && canSelectMultiple && shouldShowSelectAll && (
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
index 96c2f63eb09a..0d7a60e78a1f 100644
--- a/src/components/SelectionList/selectionListPropTypes.js
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -167,6 +167,12 @@ const propTypes = {
/** Whether to show the default confirm button */
showConfirmButton: PropTypes.bool,
+ /** Whether to focus the textinput after an option is selected */
+ shouldFocusOnSelectRow: PropTypes.bool,
+
+ /** A ref to forward to the TextInput */
+ inputRef: PropTypes.oneOfType([PropTypes.object]),
+
/** Custom content to display in the footer */
footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
};
diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js
index d89c9bc7a953..f052116697b3 100755
--- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js
+++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js
@@ -118,6 +118,7 @@ function BaseVideoChatButtonAndMenu(props) {
left: videoChatIconPosition.x - 150,
top: videoChatIconPosition.y + 40,
}}
+ shouldSetModalVisibility={false}
withoutOverlay
anchorRef={videoChatButtonRef}
>
diff --git a/src/hooks/useEnvironment.js b/src/hooks/useEnvironment.js
index 7e2f18796119..e29e60a563b2 100644
--- a/src/hooks/useEnvironment.js
+++ b/src/hooks/useEnvironment.js
@@ -8,5 +8,6 @@ export default function useEnvironment() {
environment,
environmentURL,
isProduction: environment === CONST.ENVIRONMENT.PRODUCTION,
+ isDevelopment: environment === CONST.ENVIRONMENT.DEV,
};
}
diff --git a/src/hooks/useFlipper/index.js b/src/hooks/useFlipper/index.js
new file mode 100644
index 000000000000..2d1ec238274a
--- /dev/null
+++ b/src/hooks/useFlipper/index.js
@@ -0,0 +1 @@
+export default () => {};
diff --git a/src/hooks/useFlipper/index.native.js b/src/hooks/useFlipper/index.native.js
new file mode 100644
index 000000000000..90779d5b8a14
--- /dev/null
+++ b/src/hooks/useFlipper/index.native.js
@@ -0,0 +1,3 @@
+import {useFlipper} from '@react-navigation/devtools';
+
+export default useFlipper;
diff --git a/src/hooks/useSingleExecution.js b/src/hooks/useSingleExecution.js
index 6c28087e933c..0b466252ed58 100644
--- a/src/hooks/useSingleExecution.js
+++ b/src/hooks/useSingleExecution.js
@@ -22,7 +22,7 @@ export default function useSingleExecution() {
setIsExecuting(true);
isExecutingRef.current = true;
- const execution = action(params);
+ const execution = action(...params);
InteractionManager.runAfterInteractions(() => {
if (!(execution instanceof Promise)) {
setIsExecuting(false);
diff --git a/src/languages/en.ts b/src/languages/en.ts
index abba4cfd71a3..142a4ca4d3ba 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -68,6 +68,8 @@ import type {
ManagerApprovedParams,
SetTheRequestParams,
UpdatedTheRequestParams,
+ SetTheDistanceParams,
+ UpdatedTheDistanceParams,
RemovedTheRequestParams,
FormattedMaxLengthParams,
RequestedAmountMessageParams,
@@ -482,8 +484,7 @@ export default {
dragReceiptBeforeEmail: 'Drag a receipt onto this page, forward a receipt to ',
dragReceiptAfterEmail: ' or choose a file to upload below.',
chooseReceipt: 'Choose a receipt to upload or forward a receipt to ',
- chooseFile: 'Choose File',
- givePermission: 'Give permission',
+ chooseFile: 'Choose file',
takePhoto: 'Take a photo',
cameraAccess: 'Camera access is required to take pictures of receipts.',
cameraErrorTitle: 'Camera Error',
@@ -541,9 +542,12 @@ export default {
pendingConversionMessage: "Total will update when you're back online",
changedTheRequest: 'changed the request',
setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `set the ${valueName} to ${newValueToDisplay}`,
+ setTheDistance: ({newDistanceToDisplay, newAmountToDisplay}: SetTheDistanceParams) => `set the distance to ${newDistanceToDisplay}, which set the amount to ${newAmountToDisplay}`,
removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) => `removed the ${valueName} (previously ${oldValueToDisplay})`,
updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) =>
`changed the ${valueName} to ${newValueToDisplay} (previously ${oldValueToDisplay})`,
+ updatedTheDistance: ({newDistanceToDisplay, oldDistanceToDisplay, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceParams) =>
+ `changed the distance to ${newDistanceToDisplay} (previously ${oldDistanceToDisplay}), which updated the amount to ${newAmountToDisplay} (previously ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`,
tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money`,
diff --git a/src/languages/es.ts b/src/languages/es.ts
index fde0d22f6ec2..fa97556d8096 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -67,7 +67,9 @@ import type {
ParentNavigationSummaryParams,
ManagerApprovedParams,
SetTheRequestParams,
+ SetTheDistanceParams,
UpdatedTheRequestParams,
+ UpdatedTheDistanceParams,
RemovedTheRequestParams,
FormattedMaxLengthParams,
RequestedAmountMessageParams,
@@ -475,7 +477,6 @@ export default {
dragReceiptAfterEmail: ' o elije un archivo para subir a continuación.',
chooseReceipt: 'Elige un recibo para subir o reenvía un recibo a ',
chooseFile: 'Elegir archivo',
- givePermission: 'Permitir',
takePhoto: 'Haz una foto',
cameraAccess: 'Se requiere acceso a la cámara para hacer fotos de los recibos.',
cameraErrorTitle: 'Error en la cámara',
@@ -533,10 +534,14 @@ export default {
pendingConversionMessage: 'El total se actualizará cuando estés online',
changedTheRequest: 'cambió la solicitud',
setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `estableció ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay}`,
+ setTheDistance: ({newDistanceToDisplay, newAmountToDisplay}: SetTheDistanceParams) =>
+ `estableció la distancia a ${newDistanceToDisplay}, lo que estableció el importe a ${newAmountToDisplay}`,
removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) =>
`eliminó ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} (previamente ${oldValueToDisplay})`,
updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) =>
- `cambío ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay} (previamente ${oldValueToDisplay})`,
+ `cambió ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay} (previamente ${oldValueToDisplay})`,
+ updatedTheDistance: ({newDistanceToDisplay, oldDistanceToDisplay, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceParams) =>
+ `cambió la distancia a ${newDistanceToDisplay} (previamente ${oldDistanceToDisplay}), lo que cambió el importe a ${newAmountToDisplay} (previamente ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`,
tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero`,
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 52f2df8b3765..3ee504ccddd7 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -186,10 +186,14 @@ type ParentNavigationSummaryParams = {rootReportName: string; workspaceName: str
type SetTheRequestParams = {valueName: string; newValueToDisplay: string};
+type SetTheDistanceParams = {newDistanceToDisplay: string; newAmountToDisplay: string};
+
type RemovedTheRequestParams = {valueName: string; oldValueToDisplay: string};
type UpdatedTheRequestParams = {valueName: string; newValueToDisplay: string; oldValueToDisplay: string};
+type UpdatedTheDistanceParams = {newDistanceToDisplay: string; oldDistanceToDisplay: string; newAmountToDisplay: string; oldAmountToDisplay: string};
+
type FormattedMaxLengthParams = {formattedMaxLength: string};
type TagSelectionParams = {tagName: string};
@@ -309,5 +313,7 @@ export type {
RemovedTheRequestParams,
FormattedMaxLengthParams,
TagSelectionParams,
+ SetTheDistanceParams,
+ UpdatedTheDistanceParams,
WalletProgramParams,
};
diff --git a/src/libs/API.js b/src/libs/API.js
index 491503f07381..2ad1f32347d9 100644
--- a/src/libs/API.js
+++ b/src/libs/API.js
@@ -21,6 +21,9 @@ Request.use(Middleware.RecheckConnection);
// Reauthentication - Handles jsonCode 407 which indicates an expired authToken. We need to reauthenticate and get a new authToken with our stored credentials.
Request.use(Middleware.Reauthentication);
+// If an optimistic ID is not used by the server, this will update the remaining serialized requests using that optimistic ID to use the correct ID instead.
+Request.use(Middleware.HandleUnusedOptimisticID);
+
// SaveResponseInOnyx - Merges either the successData or failureData into Onyx depending on if the call was successful or not. This needs to be the LAST middleware we use, don't add any
// middlewares after this, because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state.
Request.use(Middleware.SaveResponseInOnyx);
diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js
index c83f59e66ef2..a9ec4a3fd35a 100644
--- a/src/libs/DateUtils.js
+++ b/src/libs/DateUtils.js
@@ -46,12 +46,6 @@ Onyx.connect({
},
});
-let networkTimeSkew = 0;
-Onyx.connect({
- key: ONYXKEYS.NETWORK,
- callback: (val) => (networkTimeSkew = lodashGet(val, 'timeSkew', 0)),
-});
-
/**
* Gets the locale string and setting default locale for date-fns
*
@@ -313,15 +307,6 @@ function getDBTime(timestamp = '') {
return datetime.toISOString().replace('T', ' ').replace('Z', '');
}
-/**
- * Returns the current time plus skew in milliseconds in the format expected by the database
- *
- * @returns {String}
- */
-function getDBTimeWithSkew() {
- return getDBTime(new Date().valueOf() + networkTimeSkew);
-}
-
/**
* @param {String} dateTime
* @param {Number} milliseconds
@@ -398,7 +383,6 @@ const DateUtils = {
setTimezoneUpdated,
getMicroseconds,
getDBTime,
- getDBTimeWithSkew,
subtractMillisecondsFromDateTime,
getDateStringFromISOTimestamp,
getStatusUntilDate,
diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.js
index 465d22760837..5a8185a03038 100644
--- a/src/libs/HttpUtils.js
+++ b/src/libs/HttpUtils.js
@@ -5,7 +5,6 @@ import ONYXKEYS from '../ONYXKEYS';
import HttpsError from './Errors/HttpsError';
import * as ApiUtils from './ApiUtils';
import alert from '../components/Alert';
-import * as NetworkActions from './actions/Network';
let shouldFailAllRequests = false;
let shouldForceOffline = false;
@@ -23,16 +22,6 @@ Onyx.connect({
// We use the AbortController API to terminate pending request in `cancelPendingRequests`
let cancellationController = new AbortController();
-/**
- * The API commands that require the skew calculation
- */
-const addSkewList = ['OpenReport', 'ReconnectApp', 'OpenApp'];
-
-/**
- * Regex to get API command from the command
- */
-const regex = /[?&]command=([^&]+)/;
-
/**
* Send an HTTP request, and attempt to resolve the json response.
* If there is a network error, we'll set the application offline.
@@ -44,25 +33,12 @@ const regex = /[?&]command=([^&]+)/;
* @returns {Promise}
*/
function processHTTPRequest(url, method = 'get', body = null, canCancel = true) {
- const startTime = new Date().valueOf();
-
return fetch(url, {
// We hook requests to the same Controller signal, so we can cancel them all at once
signal: canCancel ? cancellationController.signal : undefined,
method,
body,
})
- .then((response) => {
- const match = url.match(regex)[1];
- if (addSkewList.includes(match) && response.headers) {
- const serverTime = new Date(response.headers.get('Date')).valueOf();
- const endTime = new Date().valueOf();
- const latency = (endTime - startTime) / 2;
- const skew = serverTime - startTime + latency;
- NetworkActions.setTimeSkew(skew);
- }
- return response;
- })
.then((response) => {
// Test mode where all requests will succeed in the server, but fail to return a response
if (shouldFailAllRequests || shouldForceOffline) {
diff --git a/src/libs/Middleware/HandleUnusedOptimisticID.ts b/src/libs/Middleware/HandleUnusedOptimisticID.ts
new file mode 100644
index 000000000000..a7e90accd3fc
--- /dev/null
+++ b/src/libs/Middleware/HandleUnusedOptimisticID.ts
@@ -0,0 +1,39 @@
+import _ from 'lodash';
+import ONYXKEYS from '../../ONYXKEYS';
+import Report from '../../types/onyx/Report';
+import {Middleware} from '../Request';
+import * as PersistedRequests from '../actions/PersistedRequests';
+import deepReplaceKeysAndValues from '../deepReplaceKeysAndValues';
+
+const handleUnusedOptimisticID: Middleware = (requestResponse, request, isFromSequentialQueue) =>
+ requestResponse.then((response) => {
+ const responseOnyxData = response?.onyxData ?? [];
+ responseOnyxData.forEach((onyxData) => {
+ const key = onyxData.key;
+ if (!key.startsWith(ONYXKEYS.COLLECTION.REPORT)) {
+ return;
+ }
+
+ if (!onyxData.value) {
+ return;
+ }
+
+ const report: Report = onyxData.value as Report;
+ const preexistingReportID = report.preexistingReportID;
+ if (!preexistingReportID) {
+ return;
+ }
+ const oldReportID = request.data?.reportID;
+ const offset = isFromSequentialQueue ? 1 : 0;
+ PersistedRequests.getAll()
+ .slice(offset)
+ .forEach((persistedRequest, index) => {
+ const persistedRequestClone = _.clone(persistedRequest);
+ persistedRequestClone.data = deepReplaceKeysAndValues(persistedRequest.data, oldReportID as string, preexistingReportID);
+ PersistedRequests.update(index + offset, persistedRequestClone);
+ });
+ });
+ return response;
+ });
+
+export default handleUnusedOptimisticID;
diff --git a/src/libs/Middleware/index.js b/src/libs/Middleware/index.js
index 3859fb9c3498..3b1790b3cda5 100644
--- a/src/libs/Middleware/index.js
+++ b/src/libs/Middleware/index.js
@@ -1,6 +1,7 @@
+import HandleUnusedOptimisticID from './HandleUnusedOptimisticID';
import Logging from './Logging';
import Reauthentication from './Reauthentication';
import RecheckConnection from './RecheckConnection';
import SaveResponseInOnyx from './SaveResponseInOnyx';
-export {Logging, Reauthentication, RecheckConnection, SaveResponseInOnyx};
+export {HandleUnusedOptimisticID, Logging, Reauthentication, RecheckConnection, SaveResponseInOnyx};
diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js
index 0dda39517d3c..86f716e7ab22 100644
--- a/src/libs/Navigation/NavigationRoot.js
+++ b/src/libs/Navigation/NavigationRoot.js
@@ -1,8 +1,8 @@
import React, {useRef, useEffect} from 'react';
import PropTypes from 'prop-types';
import {NavigationContainer, DefaultTheme, getPathFromState} from '@react-navigation/native';
-import {useFlipper} from '@react-navigation/devtools';
import {useSharedValue, useAnimatedReaction, interpolateColor, withTiming, withDelay, Easing, runOnJS} from 'react-native-reanimated';
+import useFlipper from '../../hooks/useFlipper';
import Navigation, {navigationRef} from './Navigation';
import linkingConfig from './linkingConfig';
import AppNavigator from './AppNavigator';
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index c03858cb15f3..9b0f989983b9 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -657,7 +657,7 @@ function isDM(report) {
* @returns {Boolean}
*/
function hasSingleParticipant(report) {
- return report.participantAccountIDs && report.participantAccountIDs.length === 1;
+ return report && report.participantAccountIDs && report.participantAccountIDs.length === 1;
}
/**
@@ -1565,6 +1565,28 @@ function getProperSchemaForModifiedExpenseMessage(newValue, oldValue, valueName,
return Localize.translateLocal('iou.updatedTheRequest', {valueName: displayValueName, newValueToDisplay, oldValueToDisplay});
}
+/**
+ * Get the proper message schema for modified distance message.
+ *
+ * @param {String} newDistance
+ * @param {String} oldDistance
+ * @param {String} newAmount
+ * @param {String} oldAmount
+ * @returns {String}
+ */
+
+function getProperSchemaForModifiedDistanceMessage(newDistance, oldDistance, newAmount, oldAmount) {
+ if (!oldDistance) {
+ return Localize.translateLocal('iou.setTheDistance', {newDistanceToDisplay: newDistance, newAmountToDisplay: newAmount});
+ }
+ return Localize.translateLocal('iou.updatedTheDistance', {
+ newDistanceToDisplay: newDistance,
+ oldDistanceToDisplay: oldDistance,
+ newAmountToDisplay: newAmount,
+ oldAmountToDisplay: oldAmount,
+ });
+}
+
/**
* Get the report action message when expense has been modified.
*
@@ -1585,6 +1607,8 @@ function getModifiedExpenseMessage(reportAction) {
_.has(reportActionOriginalMessage, 'oldCurrency') &&
_.has(reportActionOriginalMessage, 'amount') &&
_.has(reportActionOriginalMessage, 'currency');
+
+ const hasModifiedMerchant = _.has(reportActionOriginalMessage, 'oldMerchant') && _.has(reportActionOriginalMessage, 'merchant');
if (hasModifiedAmount) {
const oldCurrency = reportActionOriginalMessage.oldCurrency;
const oldAmount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage.oldAmount, oldCurrency);
@@ -1592,6 +1616,12 @@ function getModifiedExpenseMessage(reportAction) {
const currency = reportActionOriginalMessage.currency;
const amount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage.amount, currency);
+ // Only Distance edits should modify amount and merchant (which stores distance) in a single transaction.
+ // We check the merchant is in distance format (includes @) as a sanity check
+ if (hasModifiedMerchant && reportActionOriginalMessage.merchant.includes('@')) {
+ return getProperSchemaForModifiedDistanceMessage(reportActionOriginalMessage.merchant, reportActionOriginalMessage.oldMerchant, amount, oldAmount);
+ }
+
return getProperSchemaForModifiedExpenseMessage(amount, oldAmount, Localize.translateLocal('iou.amount'), false);
}
@@ -1608,7 +1638,6 @@ function getModifiedExpenseMessage(reportAction) {
return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.created, formattedOldCreated, Localize.translateLocal('common.date'), false);
}
- const hasModifiedMerchant = _.has(reportActionOriginalMessage, 'oldMerchant') && _.has(reportActionOriginalMessage, 'merchant');
if (hasModifiedMerchant) {
return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.merchant, reportActionOriginalMessage.oldMerchant, Localize.translateLocal('common.merchant'), true);
}
@@ -1939,7 +1968,7 @@ function buildOptimisticAddCommentReportAction(text, file) {
],
automatic: false,
avatar: lodashGet(allPersonalDetails, [currentUserAccountID, 'avatar'], UserUtils.getDefaultAvatarURL(currentUserAccountID)),
- created: DateUtils.getDBTimeWithSkew(),
+ created: DateUtils.getDBTime(),
message: [
{
translationKey: isAttachment ? CONST.TRANSLATION_KEYS.ATTACHMENT : '',
@@ -2082,6 +2111,7 @@ function buildOptimisticIOUReport(payeeAccountID, payerAccountID, total, chatRep
currency,
managerID: payerAccountID,
ownerAccountID: payeeAccountID,
+ participantAccountIDs: [payeeAccountID, payerAccountID],
reportID: generateReportID(),
state: CONST.REPORT.STATE.SUBMITTED,
stateNum: isSendingMoney ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.PROCESSING,
@@ -3224,20 +3254,67 @@ function hasIOUWaitingOnCurrentUserBankAccount(chatReport) {
}
/**
- * Users can request money in policy expense chats only if they are in a role of a member in the chat (in other words, if it's their policy expense chat)
+ * Users can request money:
+ * - in policy expense chats only if they are in a role of a member in the chat (in other words, if it's their policy expense chat)
+ * - in an open or submitted expense report tied to a policy expense chat the user owns
+ * - employee can request money in submitted expense report only if the policy has Instant Submit settings turned on
+ * - in an IOU report, which is not settled yet
+ * - in DM chat
*
* @param {Object} report
+ * @param {Array} participants
* @returns {Boolean}
*/
-function canRequestMoney(report) {
- // Prevent requesting money if pending iou waiting for their bank account already exists.
+function canRequestMoney(report, participants) {
+ // User cannot request money in chat thread or in task report
+ if (isChatThread(report) || isTaskReport(report)) {
+ return false;
+ }
+
+ // Prevent requesting money if pending IOU report waiting for their bank account already exists
if (hasIOUWaitingOnCurrentUserBankAccount(report)) {
return false;
}
- return !isPolicyExpenseChat(report) || report.isOwnPolicyExpenseChat;
+
+ // In case of expense reports, we have to look at the parent workspace chat to get the isOwnPolicyExpenseChat property
+ let isOwnPolicyExpenseChat = report.isOwnPolicyExpenseChat || false;
+ if (isExpenseReport(report) && getParentReport(report)) {
+ isOwnPolicyExpenseChat = getParentReport(report).isOwnPolicyExpenseChat;
+ }
+
+ // In case there are no other participants than the current user and it's not user's own policy expense chat, they can't request money from such report
+ if (participants.length === 0 && !isOwnPolicyExpenseChat) {
+ return false;
+ }
+
+ // User can request money in any IOU report, unless paid, but user can only request money in an expense report
+ // which is tied to their workspace chat.
+ if (isMoneyRequestReport(report)) {
+ return ((isExpenseReport(report) && isOwnPolicyExpenseChat) || isIOUReport(report)) && !isReportApproved(report) && !isSettled(report.reportID);
+ }
+
+ // In case of policy expense chat, users can only request money from their own policy expense chat
+ return !isPolicyExpenseChat(report) || isOwnPolicyExpenseChat;
}
/**
+ * Helper method to define what money request options we want to show for particular method.
+ * There are 3 money request options: Request, Split and Send:
+ * - Request option should show for:
+ * - DMs
+ * - own policy expense chats
+ * - open and processing expense reports tied to own policy expense chat
+ * - unsettled IOU reports
+ * - Send option should show for:
+ * - DMs
+ * - Split options should show for:
+ * - chat/ policy rooms with more than 1 participants
+ * - groups chats with 3 and more participants
+ * - corporate workspace chats
+ *
+ * None of the options should show in chat threads or if there is some special Expensify account
+ * as a participant of the report.
+ *
* @param {Object} report
* @param {Array} reportParticipants
* @param {Array} betas
@@ -3251,31 +3328,28 @@ function getMoneyRequestOptions(report, reportParticipants, betas) {
const participants = _.filter(reportParticipants, (accountID) => currentUserPersonalDetails.accountID !== accountID);
+ // Verify if there is any of the expensify accounts amongst the participants in which case user cannot take IOU actions on such report
const hasExcludedIOUAccountIDs = lodashIntersection(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS).length > 0;
const hasSingleParticipantInReport = participants.length === 1;
const hasMultipleParticipants = participants.length > 1;
- if (hasExcludedIOUAccountIDs || (participants.length === 0 && !report.isOwnPolicyExpenseChat)) {
- return [];
- }
-
- // Additional requests should be blocked for money request reports if it is approved or reimbursed
- if (isMoneyRequestReport(report) && (isReportApproved(report) || isSettled(report.reportID))) {
+ if (hasExcludedIOUAccountIDs) {
return [];
}
// User created policy rooms and default rooms like #admins or #announce will always have the Split Bill option
// unless there are no participants at all (e.g. #admins room for a policy with only 1 admin)
// DM chats will have the Split Bill option only when there are at least 3 people in the chat.
- // There is no Split Bill option for Workspace chats
- if (isChatRoom(report) || (hasMultipleParticipants && !isPolicyExpenseChat(report)) || isControlPolicyExpenseChat(report)) {
+ // There is no Split Bill option for Workspace chats, IOU or Expense reports which are threads
+ if ((isChatRoom(report) && participants.length > 0) || (hasMultipleParticipants && !isPolicyExpenseChat(report) && !isMoneyRequestReport(report)) || isControlPolicyExpenseChat(report)) {
return [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT];
}
// DM chats that only have 2 people will see the Send / Request money options.
- // Workspace chats should only see the Request money option, as "easy overages" is not available.
+ // IOU and open or processing expense reports should show the Request option.
+ // Workspace chats should only see the Request money option or Split option in case of Control policies
return [
- ...(canRequestMoney(report) ? [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST] : []),
+ ...(canRequestMoney(report, participants) ? [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST] : []),
// Send money option should be visible only in DMs
...(Permissions.canUseIOUSend(betas) && isChatReport(report) && !isPolicyExpenseChat(report) && hasSingleParticipantInReport ? [CONST.IOU.MONEY_REQUEST_TYPE.SEND] : []),
@@ -3382,7 +3456,16 @@ function shouldReportShowSubscript(report) {
* @returns {Boolean}
*/
function isReportDataReady() {
- return !_.isEmpty(allReports) && _.some(_.keys(allReports), (key) => allReports[key].reportID);
+ return !_.isEmpty(allReports) && _.some(_.keys(allReports), (key) => allReports[key] && allReports[key].reportID);
+}
+
+/**
+ * Return true if reportID from path is valid
+ * @param {String} reportIDFromPath
+ * @returns {Boolean}
+ */
+function isValidReportIDFromPath(reportIDFromPath) {
+ return typeof reportIDFromPath === 'string' && !['', 'null', '0'].includes(reportIDFromPath);
}
/**
@@ -3764,6 +3847,7 @@ export {
isChildReport,
shouldReportShowSubscript,
isReportDataReady,
+ isValidReportIDFromPath,
isSettled,
isAllowedToComment,
getBankAccountRoute,
diff --git a/src/libs/Request.ts b/src/libs/Request.ts
index 459deaf89e1e..903e70358da9 100644
--- a/src/libs/Request.ts
+++ b/src/libs/Request.ts
@@ -2,12 +2,13 @@ import HttpUtils from './HttpUtils';
import enhanceParameters from './Network/enhanceParameters';
import * as NetworkStore from './Network/NetworkStore';
import Request from '../types/onyx/Request';
+import Response from '../types/onyx/Response';
-type Middleware = (response: unknown, request: Request, isFromSequentialQueue: boolean) => Promise;
+type Middleware = (response: Promise, request: Request, isFromSequentialQueue: boolean) => Promise;
let middlewares: Middleware[] = [];
-function makeXHR(request: Request): Promise {
+function makeXHR(request: Request): Promise {
const finalParameters = enhanceParameters(request.command, request?.data ?? {});
return NetworkStore.hasReadRequiredDataFromStorage().then(() => {
// If we're using the Supportal token and this is not a Supportal request
@@ -16,10 +17,10 @@ function makeXHR(request: Request): Promise {
return new Promise((resolve) => resolve());
}
return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure);
- });
+ }) as Promise;
}
-function processWithMiddleware(request: Request, isFromSequentialQueue = false): Promise {
+function processWithMiddleware(request: Request, isFromSequentialQueue = false): Promise {
return middlewares.reduce((last, middleware) => middleware(last, request, isFromSequentialQueue), makeXHR(request));
}
@@ -32,3 +33,4 @@ function clearMiddlewares() {
}
export {clearMiddlewares, processWithMiddleware, use};
+export type {Middleware};
diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts
index 751ca5b69609..15bf3c0f1029 100644
--- a/src/libs/UserUtils.ts
+++ b/src/libs/UserUtils.ts
@@ -31,9 +31,8 @@ type LoginListIndicator = ValueOf | ''
* }
* }}
*/
-function hasLoginListError(loginList: Login): boolean {
- const errorFields = loginList?.errorFields ?? {};
- return Object.values(errorFields).some((field) => Object.keys(field ?? {}).length > 0);
+function hasLoginListError(loginList: Record): boolean {
+ return Object.values(loginList).some((loginData) => Object.values(loginData.errorFields ?? {}).some((field) => Object.keys(field ?? {}).length > 0));
}
/**
@@ -41,15 +40,15 @@ function hasLoginListError(loginList: Login): boolean {
* an Info brick road status indicator. Currently this only applies if the user
* has an unvalidated contact method.
*/
-function hasLoginListInfo(loginList: Login): boolean {
- return !loginList.validatedDate;
+function hasLoginListInfo(loginList: Record): boolean {
+ return !Object.values(loginList).every((field) => field.validatedDate);
}
/**
* Gets the appropriate brick road indicator status for a given loginList.
* Error status is higher priority, so we check for that first.
*/
-function getLoginListBrickRoadIndicator(loginList: Login): LoginListIndicator {
+function getLoginListBrickRoadIndicator(loginList: Record): LoginListIndicator {
if (hasLoginListError(loginList)) {
return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
}
diff --git a/src/libs/actions/Network.ts b/src/libs/actions/Network.ts
index fc83d23ac4a7..212e44f6782d 100644
--- a/src/libs/actions/Network.ts
+++ b/src/libs/actions/Network.ts
@@ -5,10 +5,6 @@ function setIsOffline(isOffline: boolean) {
Onyx.merge(ONYXKEYS.NETWORK, {isOffline});
}
-function setTimeSkew(skew: number) {
- Onyx.merge(ONYXKEYS.NETWORK, {timeSkew: skew});
-}
-
function setShouldForceOffline(shouldForceOffline: boolean) {
Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline});
}
@@ -20,4 +16,4 @@ function setShouldFailAllRequests(shouldFailAllRequests: boolean) {
Onyx.merge(ONYXKEYS.NETWORK, {shouldFailAllRequests});
}
-export {setIsOffline, setShouldForceOffline, setShouldFailAllRequests, setTimeSkew};
+export {setIsOffline, setShouldForceOffline, setShouldFailAllRequests};
diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts
index 040c7d3d87a8..92a3b817959d 100644
--- a/src/libs/actions/PersistedRequests.ts
+++ b/src/libs/actions/PersistedRequests.ts
@@ -31,18 +31,21 @@ function remove(requestToRemove: Request) {
* We only remove the first matching request because the order of requests matters.
* If we were to remove all matching requests, we can end up with a final state that is different than what the user intended.
*/
- const requests = [...persistedRequests];
- const index = requests.findIndex((persistedRequest) => isEqual(persistedRequest, requestToRemove));
- if (index !== -1) {
- requests.splice(index, 1);
+ const index = persistedRequests.findIndex((persistedRequest) => isEqual(persistedRequest, requestToRemove));
+ if (index === -1) {
+ return;
}
+ persistedRequests.splice(index, 1);
+ Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests);
+}
- persistedRequests = requests;
- Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests);
+function update(oldRequestIndex: number, newRequest: Request) {
+ persistedRequests.splice(oldRequestIndex, 1, newRequest);
+ Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests);
}
function getAll(): Request[] {
return persistedRequests;
}
-export {clear, save, getAll, remove};
+export {clear, save, getAll, remove, update};
diff --git a/src/libs/actions/Plaid.js b/src/libs/actions/Plaid.ts
similarity index 81%
rename from src/libs/actions/Plaid.js
rename to src/libs/actions/Plaid.ts
index 53763980d285..410a8c57d176 100644
--- a/src/libs/actions/Plaid.js
+++ b/src/libs/actions/Plaid.ts
@@ -6,13 +6,15 @@ import * as PlaidDataProps from '../../pages/ReimbursementAccount/plaidDataPropT
/**
* Gets the Plaid Link token used to initialize the Plaid SDK
- * @param {Boolean} allowDebit
- * @param {Number} bankAccountID
*/
-function openPlaidBankLogin(allowDebit, bankAccountID) {
- const params = getPlaidLinkTokenParameters();
- params.allowDebit = allowDebit;
- params.bankAccountID = bankAccountID;
+function openPlaidBankLogin(allowDebit: boolean, bankAccountID: number) {
+ // redirect_uri needs to be in kebab case convention because that's how it's passed to the backend
+ const {redirectURI} = getPlaidLinkTokenParameters();
+ const params = {
+ redirectURI,
+ allowDebit,
+ bankAccountID,
+ };
const optimisticData = [
{
onyxMethod: Onyx.METHOD.SET,
@@ -36,13 +38,7 @@ function openPlaidBankLogin(allowDebit, bankAccountID) {
API.read('OpenPlaidBankLogin', params, {optimisticData});
}
-/**
- * @param {String} publicToken
- * @param {String} bankName
- * @param {Boolean} allowDebit
- * @param {Number} bankAccountID
- */
-function openPlaidBankAccountSelector(publicToken, bankName, allowDebit, bankAccountID) {
+function openPlaidBankAccountSelector(publicToken: string, bankName: string, allowDebit: boolean, bankAccountID: number) {
API.read(
'OpenPlaidBankAccountSelector',
{
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index 7d6d2b0949c1..e21d9fdd75c6 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -301,7 +301,7 @@ function addActions(reportID, text = '', file) {
// Always prefer the file as the last action over text
const lastAction = attachmentAction || reportCommentAction;
- const currentTime = DateUtils.getDBTimeWithSkew();
+ const currentTime = DateUtils.getDBTime();
const lastCommentText = ReportUtils.formatReportLastMessageText(lastAction.message[0].text);
diff --git a/src/libs/deepReplaceKeysAndValues.ts b/src/libs/deepReplaceKeysAndValues.ts
new file mode 100644
index 000000000000..58a96a58a2c0
--- /dev/null
+++ b/src/libs/deepReplaceKeysAndValues.ts
@@ -0,0 +1,50 @@
+type ReplaceableValue = Record | unknown[] | string | number | boolean | undefined | null;
+
+/**
+ * @param target the object or value to transform
+ * @param oldVal the value to search for
+ * @param newVal the replacement value
+ */
+function deepReplaceKeysAndValues(target: T, oldVal: string, newVal: string): T {
+ if (!target) {
+ return target;
+ }
+
+ if (typeof target === 'string') {
+ return target.replace(oldVal, newVal) as T;
+ }
+
+ if (typeof target !== 'object') {
+ return target;
+ }
+
+ if (Array.isArray(target)) {
+ return target.map((item) => deepReplaceKeysAndValues(item as T, oldVal, newVal)) as T;
+ }
+
+ const newObj: Record = {};
+ Object.entries(target).forEach(([key, val]) => {
+ const newKey = key.replace(oldVal, newVal);
+
+ if (typeof val === 'object') {
+ newObj[newKey] = deepReplaceKeysAndValues(val as T, oldVal, newVal);
+ return;
+ }
+
+ if (val === oldVal) {
+ newObj[newKey] = newVal;
+ return;
+ }
+
+ if (typeof val === 'string') {
+ newObj[newKey] = val.replace(oldVal, newVal);
+ return;
+ }
+
+ newObj[newKey] = val;
+ });
+
+ return newObj as T;
+}
+
+export default deepReplaceKeysAndValues;
diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js
index 289f8913b534..d96a37d95457 100644
--- a/src/libs/migrateOnyx.js
+++ b/src/libs/migrateOnyx.js
@@ -1,7 +1,6 @@
import _ from 'underscore';
import Log from './Log';
import RenamePriorityModeKey from './migrations/RenamePriorityModeKey';
-import AddLastVisibleActionCreated from './migrations/AddLastVisibleActionCreated';
import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID';
import RenameReceiptFilename from './migrations/RenameReceiptFilename';
@@ -11,7 +10,7 @@ export default function () {
return new Promise((resolve) => {
// Add all migrations to an array so they are executed in order
- const migrationPromises = [RenamePriorityModeKey, AddLastVisibleActionCreated, PersonalDetailsByAccountID, RenameReceiptFilename];
+ const migrationPromises = [RenamePriorityModeKey, PersonalDetailsByAccountID, RenameReceiptFilename];
// Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the
// previous promise to finish before moving onto the next one.
diff --git a/src/libs/migrations/AddLastVisibleActionCreated.js b/src/libs/migrations/AddLastVisibleActionCreated.js
deleted file mode 100644
index e720ab5d8bda..000000000000
--- a/src/libs/migrations/AddLastVisibleActionCreated.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import _ from 'underscore';
-import Onyx from 'react-native-onyx';
-import Log from '../Log';
-import ONYXKEYS from '../../ONYXKEYS';
-
-/**
- * This migration adds lastVisibleActionCreated to all reports in Onyx, using the value of lastMessageTimestamp
- *
- * @returns {Promise}
- */
-export default function () {
- return new Promise((resolve) => {
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT,
- waitForCollectionCallback: true,
- callback: (allReports) => {
- Onyx.disconnect(connectionID);
- const reportsToUpdate = {};
- _.each(allReports, (report, key) => {
- if (_.has(report, 'lastVisibleActionCreated')) {
- return;
- }
-
- if (!_.has(report, 'lastActionCreated')) {
- return;
- }
-
- reportsToUpdate[key] = report;
- reportsToUpdate[key].lastVisibleActionCreated = report.lastActionCreated;
- reportsToUpdate[key].lastActionCreated = null;
- });
-
- if (_.isEmpty(reportsToUpdate)) {
- Log.info('[Migrate Onyx] Skipped migration AddLastVisibleActionCreated');
- return resolve();
- }
-
- Log.info(`[Migrate Onyx] Adding lastVisibleActionCreated field to ${_.keys(reportsToUpdate).length} reports`);
- // eslint-disable-next-line rulesdir/prefer-actions-set-data
- return Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, reportsToUpdate).then(resolve);
- },
- });
- });
-}
diff --git a/src/pages/EditRequestReceiptPage.js b/src/pages/EditRequestReceiptPage.js
index c5dd69624159..6744f027b404 100644
--- a/src/pages/EditRequestReceiptPage.js
+++ b/src/pages/EditRequestReceiptPage.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {useState} from 'react';
import PropTypes from 'prop-types';
import ScreenWrapper from '../components/ScreenWrapper';
import HeaderWithBackButton from '../components/HeaderWithBackButton';
@@ -6,6 +6,7 @@ import Navigation from '../libs/Navigation/Navigation';
import useLocalize from '../hooks/useLocalize';
import ReceiptSelector from './iou/ReceiptSelector';
import DragAndDropProvider from '../components/DragAndDrop/Provider';
+import styles from '../styles/styles';
const propTypes = {
/** React Navigation route */
@@ -30,14 +31,16 @@ const defaultProps = {
function EditRequestReceiptPage({route, transactionID}) {
const {translate} = useLocalize();
+ const [isDraggingOver, setIsDraggingOver] = useState(false);
return (
-
+
diff --git a/src/pages/EnablePayments/IdologyQuestions.js b/src/pages/EnablePayments/IdologyQuestions.js
index f6bfca437ac1..b5f30f93361b 100644
--- a/src/pages/EnablePayments/IdologyQuestions.js
+++ b/src/pages/EnablePayments/IdologyQuestions.js
@@ -1,28 +1,25 @@
import _ from 'underscore';
-import React from 'react';
+import React, {useRef, useState} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import RadioButtons from '../../components/RadioButtons';
-import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import styles from '../../styles/styles';
+import ONYXKEYS from '../../ONYXKEYS';
+import * as ErrorUtils from '../../libs/ErrorUtils';
+import useLocalize from '../../hooks/useLocalize';
+import RadioButtons from '../../components/RadioButtons';
import * as BankAccounts from '../../libs/actions/BankAccounts';
import Text from '../../components/Text';
import TextLink from '../../components/TextLink';
import FormScrollView from '../../components/FormScrollView';
import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
-import compose from '../../libs/compose';
-import ONYXKEYS from '../../ONYXKEYS';
import OfflineIndicator from '../../components/OfflineIndicator';
-import * as ErrorUtils from '../../libs/ErrorUtils';
import FixedFooter from '../../components/FixedFooter';
const MAX_SKIP = 1;
const SKIP_QUESTION_TEXT = 'Skip Question';
const propTypes = {
- ...withLocalizePropTypes,
-
/** Questions returned by Idology */
/** example: [{"answer":["1251","6253","113","None of the above","Skip Question"],"prompt":"Which number goes with your address on MASONIC AVE?","type":"street.number.b"}, ...] */
questions: PropTypes.arrayOf(
@@ -54,149 +51,122 @@ const defaultProps = {
walletAdditionalDetails: {},
};
-class IdologyQuestions extends React.Component {
- constructor(props) {
- super(props);
- this.submitAnswers = this.submitAnswers.bind(this);
-
- this.state = {
- /** Current question index to display. */
- questionNumber: 0,
+function IdologyQuestions({questions, walletAdditionalDetails, idNumber}) {
+ const formRef = useRef();
+ const {translate} = useLocalize();
- /** Should we hide the "Skip question" answer? Yes if the user already skipped MAX_SKIP questions. */
- hideSkip: false,
+ const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
+ const [shouldHideSkipAnswer, setShouldHideSkipAnswer] = useState(false);
+ const [userAnswers, setUserAnswers] = useState([]);
+ const [error, setError] = useState('');
- /** Answers from the user */
- answers: [],
+ const currentQuestion = questions[currentQuestionIndex] || {};
+ const possibleAnswers = _.filter(
+ _.map(currentQuestion.answer, (answer) => {
+ if (shouldHideSkipAnswer && answer === SKIP_QUESTION_TEXT) {
+ return;
+ }
- /** Any error message */
- errorMessage: '',
- };
- }
+ return {
+ label: answer,
+ value: answer,
+ };
+ }),
+ );
+ const errorMessage = ErrorUtils.getLatestErrorMessage(walletAdditionalDetails) || error;
/**
* Put question answer in the state.
- * @param {Number} questionIndex
* @param {String} answer
*/
- chooseAnswer(questionIndex, answer) {
- this.setState((prevState) => {
- const answers = prevState.answers;
- const question = this.props.questions[questionIndex];
- answers[questionIndex] = {question: question.type, answer};
- return {
- answers,
- errorMessage: '',
- };
- });
- }
+ const chooseAnswer = (answer) => {
+ const tempAnswers = _.map(userAnswers, _.clone);
+
+ tempAnswers[currentQuestionIndex] = {question: currentQuestion.type, answer};
+
+ setUserAnswers(tempAnswers);
+ setError('');
+ };
/**
* Show next question or send all answers for Idology verifications when we've answered enough
*/
- submitAnswers() {
- this.setState((prevState) => {
- // User must pick an answer
- if (!prevState.answers[prevState.questionNumber]) {
- return {
- errorMessage: this.props.translate('additionalDetailsStep.selectAnswer'),
- };
- }
-
+ const submitAnswers = () => {
+ if (!userAnswers[currentQuestionIndex]) {
+ setError(translate('additionalDetailsStep.selectAnswer'));
+ } else {
// Get the number of questions that were skipped by the user.
- const skippedQuestionsCount = _.filter(prevState.answers, (answer) => answer.answer === SKIP_QUESTION_TEXT).length;
+ const skippedQuestionsCount = _.filter(userAnswers, (answer) => answer.answer === SKIP_QUESTION_TEXT).length;
// We have enough answers, let's call expectID KBA to verify them
- if (prevState.answers.length - skippedQuestionsCount >= this.props.questions.length - MAX_SKIP) {
- const answers = prevState.answers;
+ if (userAnswers.length - skippedQuestionsCount >= questions.length - MAX_SKIP) {
+ const tempAnswers = _.map(userAnswers, _.clone);
// Auto skip any remaining questions
- if (answers.length < this.props.questions.length) {
- for (let i = answers.length; i < this.props.questions.length; i++) {
- answers[i] = {question: this.props.questions[i].type, answer: SKIP_QUESTION_TEXT};
+ if (tempAnswers.length < questions.length) {
+ for (let i = tempAnswers.length; i < questions.length; i++) {
+ tempAnswers[i] = {question: questions[i].type, answer: SKIP_QUESTION_TEXT};
}
}
- BankAccounts.answerQuestionsForWallet(answers, this.props.idNumber);
- return {answers};
+ BankAccounts.answerQuestionsForWallet(tempAnswers, idNumber);
+ setUserAnswers(tempAnswers);
+ } else {
+ // Else, show next question
+ setCurrentQuestionIndex(currentQuestionIndex + 1);
+ setShouldHideSkipAnswer(skippedQuestionsCount >= MAX_SKIP);
}
-
- // Else, show next question
- return {
- questionNumber: prevState.questionNumber + 1,
- hideSkip: skippedQuestionsCount >= MAX_SKIP,
- };
- });
- }
-
- render() {
- const questionIndex = this.state.questionNumber;
- const question = this.props.questions[questionIndex] || {};
- const possibleAnswers = _.filter(
- _.map(question.answer, (answer) => {
- if (this.state.hideSkip && answer === SKIP_QUESTION_TEXT) {
- return;
- }
-
- return {
- label: answer,
- value: answer,
- };
- }),
- );
-
- const errorMessage = ErrorUtils.getLatestErrorMessage(this.props.walletAdditionalDetails) || this.state.errorMessage;
-
- return (
-
-
- {this.props.translate('additionalDetailsStep.helpTextIdologyQuestions')}
-
- {this.props.translate('additionalDetailsStep.helpLink')}
-
-
- (this.form = el)}>
-
- {question.prompt}
- this.chooseAnswer(questionIndex, answer)}
- />
-
-
-
- {
- this.form.scrollTo({y: 0, animated: true});
- }}
- message={errorMessage}
- isLoading={this.props.walletAdditionalDetails.isLoading}
- buttonText={this.props.translate('common.saveAndContinue')}
- containerStyles={[styles.mh0, styles.mv0, styles.mb0]}
- />
-
-
+ }
+ };
+
+ return (
+
+
+ {translate('additionalDetailsStep.helpTextIdologyQuestions')}
+
+ {translate('additionalDetailsStep.helpLink')}
+
- );
- }
+
+
+ {currentQuestion.prompt}
+
+
+
+
+ {
+ formRef.current.scrollTo({y: 0, animated: true});
+ }}
+ message={errorMessage}
+ isLoading={walletAdditionalDetails.isLoading}
+ buttonText={translate('common.saveAndContinue')}
+ containerStyles={[styles.mh0, styles.mv0, styles.mb0]}
+ />
+
+
+
+ );
}
+IdologyQuestions.displayName = 'IdologyQuestions';
IdologyQuestions.propTypes = propTypes;
IdologyQuestions.defaultProps = defaultProps;
-export default compose(
- withLocalize,
- withOnyx({
- walletAdditionalDetails: {
- key: ONYXKEYS.WALLET_ADDITIONAL_DETAILS,
- },
- }),
-)(IdologyQuestions);
+
+export default withOnyx({
+ walletAdditionalDetails: {
+ key: ONYXKEYS.WALLET_ADDITIONAL_DETAILS,
+ },
+})(IdologyQuestions);
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 635be1fc77c5..7a979ae18783 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -230,7 +230,7 @@ function ReportScreen({
// Report ID will be empty when the reports collection is empty.
// This could happen when we are loading the collection for the first time after logging in.
- if (!reportIDFromPath) {
+ if (!ReportUtils.isValidReportIDFromPath(reportIDFromPath)) {
return;
}
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
index 46153bda15e6..dedccc65a390 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
@@ -348,7 +348,7 @@ function ReportActionCompose({
reportID={reportID}
report={report}
reportParticipantIDs={reportParticipantIDs}
- isFullComposerAvailable={isFullComposerAvailable && !isCommentEmpty}
+ isFullComposerAvailable={isFullComposerAvailable}
isComposerFullSize={isComposerFullSize}
updateShouldShowSuggestionMenuToFalse={updateShouldShowSuggestionMenuToFalse}
isBlockedFromConcierge={isBlockedFromConcierge}
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index 2585481748f6..0412a1cbfe38 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -511,9 +511,15 @@ function ReportActionItem(props) {
};
if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) {
+ let content = (
+
+ );
const parentReportAction = ReportActionsUtils.getParentReportAction(props.report);
if (ReportActionsUtils.isTransactionThread(parentReportAction)) {
- return (
+ content = (
);
}
- return (
-
- );
+
+ const isNormalCreatedAction =
+ !ReportActionsUtils.isTransactionThread(parentReportAction) &&
+ !ReportUtils.isTaskReport(props.report) &&
+ !ReportUtils.isExpenseReport(props.report) &&
+ !ReportUtils.isIOUReport(props.report);
+ return {content};
}
if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
return ;
diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js
index 9e46b1d2d7a2..c8e3aebaa0b3 100644
--- a/src/pages/iou/MoneyRequestSelectorPage.js
+++ b/src/pages/iou/MoneyRequestSelectorPage.js
@@ -21,7 +21,6 @@ import OnyxTabNavigator, {TopTab} from '../../libs/Navigation/OnyxTabNavigator';
import NewRequestAmountPage from './steps/NewRequestAmountPage';
import reportPropTypes from '../reportPropTypes';
import * as ReportUtils from '../../libs/ReportUtils';
-import themeColors from '../../styles/themes/default';
import usePrevious from '../../hooks/usePrevious';
const propTypes = {
@@ -86,15 +85,7 @@ function MoneyRequestSelectorPage(props) {
{({safeAreaPaddingBottomStyle}) => (
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
index 128782093718..bc62920c61c4 100644
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -248,8 +248,8 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator})
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index 86eff304df9b..d81c9d057174 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -96,13 +96,15 @@ const propTypes = {
walletTerms: walletTermsPropTypes,
/** Login list for the user that is signed in */
- loginList: PropTypes.shape({
- /** Date login was validated, used to show brickroad info status */
- validatedDate: PropTypes.string,
+ loginList: PropTypes.objectOf(
+ PropTypes.shape({
+ /** Date login was validated, used to show brickroad info status */
+ validatedDate: PropTypes.string,
- /** Field-specific server side errors keyed by microtime */
- errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
- }),
+ /** Field-specific server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+ }),
+ ),
/** Members keyed by accountID for all policies */
allPolicyMembers: PropTypes.objectOf(PropTypes.objectOf(policyMemberPropType)),
diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
index 391516e3d63b..f7766756bf22 100644
--- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
@@ -80,6 +80,7 @@ function AddressPage({privatePersonalDetails, route}) {
const isLoadingPersonalDetails = lodashGet(privatePersonalDetails, 'isLoading', true);
const [street1, street2] = (address.street || '').split('\n');
const [state, setState] = useState(address.state);
+ const [city, setCity] = useState(address.city);
useEffect(() => {
if (!address) {
@@ -135,15 +136,20 @@ function AddressPage({privatePersonalDetails, route}) {
}, []);
const handleAddressChange = useCallback((value, key) => {
- if (key !== 'country' && key !== 'state') {
+ if (key !== 'country' && key !== 'state' && key !== 'city') {
return;
}
if (key === 'country') {
setCurrentCountry(value);
setState('');
+ setCity('');
return;
}
- setState(value);
+ if (key === 'state') {
+ setState(value);
+ return;
+ }
+ setCity(value);
}, []);
useEffect(() => {
@@ -235,9 +241,10 @@ function AddressPage({privatePersonalDetails, route}) {
label={translate('common.city')}
accessibilityLabel={translate('common.city')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
- defaultValue={address.city || ''}
+ value={city || ''}
maxLength={CONST.FORM_CHARACTER_LIMIT}
spellCheck={false}
+ onValueChange={handleAddressChange}
/>
Navigation.goBack(ROUTES.SETTINGS)}
shouldShowBackButton
- shouldShowCloseButton
illustration={LottieAnimations.Safe}
backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.SECURITY]}
>
diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js
index 03b286727afb..1f70364c307b 100644
--- a/src/pages/signin/SignInPageLayout/index.js
+++ b/src/pages/signin/SignInPageLayout/index.js
@@ -39,7 +39,7 @@ const propTypes = {
innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
/** Whether or not the sign in page is being rendered in the RHP modal */
- isInModal: PropTypes.bool.isRequired,
+ isInModal: PropTypes.bool,
/** Override the green headline copy */
customHeadline: PropTypes.string,
@@ -53,6 +53,7 @@ const propTypes = {
const defaultProps = {
innerRef: () => {},
+ isInModal: false,
customHeadline: '',
customHeroBody: '',
};
diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js
index 319099a9e1c8..2e896f61afda 100644
--- a/src/pages/workspace/WorkspaceMembersPage.js
+++ b/src/pages/workspace/WorkspaceMembersPage.js
@@ -1,7 +1,7 @@
-import React, {useCallback, useEffect, useState, useMemo} from 'react';
+import React, {useCallback, useEffect, useState, useMemo, useRef} from 'react';
import _ from 'underscore';
import lodashGet from 'lodash/get';
-import {View} from 'react-native';
+import {InteractionManager, View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import styles from '../../styles/styles';
@@ -31,6 +31,7 @@ import Log from '../../libs/Log';
import * as PersonalDetailsUtils from '../../libs/PersonalDetailsUtils';
import SelectionList from '../../components/SelectionList';
import Text from '../../components/Text';
+import * as Browser from '../../libs/Browser';
const propTypes = {
/** All personal details asssociated with user */
@@ -77,6 +78,7 @@ function WorkspaceMembersPage(props) {
const prevIsOffline = usePrevious(props.network.isOffline);
const accountIDs = useMemo(() => _.keys(props.policyMembers), [props.policyMembers]);
const prevAccountIDs = usePrevious(accountIDs);
+ const textInputRef = useRef(null);
/**
* Get members for the current workspace
@@ -314,7 +316,11 @@ function WorkspaceMembersPage(props) {
keyForList: accountID,
accountID: Number(accountID),
isSelected: _.contains(selectedEmployees, Number(accountID)),
- isDisabled: accountID === props.session.accountID || details.login === props.policy.owner || policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ isDisabled:
+ accountID === props.session.accountID ||
+ details.login === props.policy.owner ||
+ policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ||
+ !_.isEmpty(policyMember.errors),
text: props.formatPhoneNumber(details.displayName),
alternateText: props.formatPhoneNumber(details.login),
rightElement: isAdmin ? (
@@ -372,6 +378,14 @@ function WorkspaceMembersPage(props) {
prompt={props.translate('workspace.people.removeMembersPrompt')}
confirmText={props.translate('common.remove')}
cancelText={props.translate('common.cancel')}
+ onModalHide={() =>
+ InteractionManager.runAfterInteractions(() => {
+ if (!textInputRef.current) {
+ return;
+ }
+ textInputRef.current.focus();
+ })
+ }
/>
@@ -403,6 +417,8 @@ function WorkspaceMembersPage(props) {
onDismissError={dismissError}
showLoadingPlaceholder={!OptionsListUtils.isPersonalDetailsReady(props.personalDetails) || _.isEmpty(props.policyMembers)}
showScrollIndicator
+ shouldFocusOnSelectRow={!Browser.isMobile()}
+ inputRef={textInputRef}
/>
diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts
index 0832b6a3978c..85e64614737b 100644
--- a/src/styles/StyleUtils.ts
+++ b/src/styles/StyleUtils.ts
@@ -1147,10 +1147,23 @@ function getDisabledLinkStyles(isDisabled = false): ViewStyle | CSSProperties {
};
}
+/**
+ * Returns the checkbox pressable style
+ */
+function getCheckboxPressableStyle(borderRadius = 6): ViewStyle | CSSProperties {
+ return {
+ padding: 2,
+ justifyContent: 'center',
+ alignItems: 'center',
+ // eslint-disable-next-line object-shorthand
+ borderRadius: borderRadius,
+ };
+}
+
/**
* Returns the checkbox container style
*/
-function getCheckboxContainerStyle(size: number, borderRadius: number): ViewStyle | CSSProperties {
+function getCheckboxContainerStyle(size: number, borderRadius = 4): ViewStyle | CSSProperties {
return {
backgroundColor: themeColors.componentBG,
height: size,
@@ -1287,6 +1300,7 @@ export {
getWrappingStyle,
getMenuItemTextContainerStyle,
getDisabledLinkStyles,
+ getCheckboxPressableStyle,
getCheckboxContainerStyle,
getDropDownButtonHeight,
getAmountFontSizeAndLineHeight,
diff --git a/src/styles/styles.js b/src/styles/styles.js
index cef87c531972..de9a48fca380 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -1494,7 +1494,8 @@ const styles = (theme) => ({
},
optionsListSectionHeader: {
- height: variables.optionsListSectionHeaderHeight,
+ marginTop: 8,
+ marginBottom: 4,
},
overlayStyles: (current) => ({
@@ -2448,13 +2449,6 @@ const styles = (theme) => ({
alignItems: 'center',
},
- checkboxPressable: {
- borderRadius: 6,
- padding: 2,
- justifyContent: 'center',
- alignItems: 'center',
- },
-
checkedContainer: {
backgroundColor: theme.checkBox,
},
@@ -3637,7 +3631,7 @@ const styles = (theme) => ({
mapView: {
flex: 1,
- borderRadius: 20,
+ borderRadius: 16,
overflow: 'hidden',
},
@@ -3654,7 +3648,8 @@ const styles = (theme) => ({
},
confirmationListMapItem: {
- ...spacing.m5,
+ ...spacing.mv2,
+ ...spacing.mh5,
height: 200,
},
@@ -3704,6 +3699,10 @@ const styles = (theme) => ({
fontSize: variables.fontSizeSmall,
lineHeight: variables.lineHeightLarge,
},
+
+ receiptDropHeaderGap: {
+ backgroundColor: theme.receiptDropUIBG,
+ },
});
// For now we need to export the styles function that takes the theme as an argument
diff --git a/src/styles/utilities/spacing.ts b/src/styles/utilities/spacing.ts
index a3667f05ac06..a47efe504326 100644
--- a/src/styles/utilities/spacing.ts
+++ b/src/styles/utilities/spacing.ts
@@ -55,6 +55,10 @@ export default {
marginHorizontal: -20,
},
+ mv0: {
+ marginVertical: 0,
+ },
+
mv1: {
marginVertical: 4,
},
diff --git a/src/types/onyx/Network.ts b/src/types/onyx/Network.ts
index 32b084bbf2f7..5af4c1170c3f 100644
--- a/src/types/onyx/Network.ts
+++ b/src/types/onyx/Network.ts
@@ -7,9 +7,6 @@ type Network = {
/** Whether we should fail all network requests */
shouldFailAllRequests?: boolean;
-
- /** Skew between the client and server clocks */
- timeSkew?: number;
};
export default Network;
diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
index 46e51fe41238..6b2db2912cd6 100644
--- a/src/types/onyx/Report.ts
+++ b/src/types/onyx/Report.ts
@@ -49,7 +49,7 @@ type Report = {
reportName?: string;
/** ID of the report */
- reportID?: string;
+ reportID: string;
/** The state that the report is currently in */
stateNum?: ValueOf;
@@ -77,6 +77,7 @@ type Report = {
participantAccountIDs?: number[];
total?: number;
currency?: string;
+ preexistingReportID?: string;
};
export default Report;
diff --git a/tests/unit/GooglePlacesUtilsTest.js b/tests/unit/GooglePlacesUtilsTest.js
index 1bb27bdd9f2f..4805e180eedc 100644
--- a/tests/unit/GooglePlacesUtilsTest.js
+++ b/tests/unit/GooglePlacesUtilsTest.js
@@ -166,7 +166,7 @@ describe('GooglePlacesUtilsTest', () => {
});
});
});
- describe('getAddressComponents small data set timing', () => {
+ describe.skip('getAddressComponents small data set timing', () => {
it('should not be slow when executing', () => {
const startTime = performance.now();
for (let i = 100; i > 0; i--) {
@@ -180,7 +180,7 @@ describe('GooglePlacesUtilsTest', () => {
expect(executionTime).toBeLessThan(1.0);
});
});
- describe('getAddressComponents big data set timing', () => {
+ describe.skip('getAddressComponents big data set timing', () => {
it('should not be slow when executing', () => {
const startTime = performance.now();
for (let i = 100; i > 0; i--) {
diff --git a/tests/unit/MiddlewareTest.js b/tests/unit/MiddlewareTest.js
new file mode 100644
index 000000000000..db020ea924ed
--- /dev/null
+++ b/tests/unit/MiddlewareTest.js
@@ -0,0 +1,102 @@
+import Onyx from 'react-native-onyx';
+import * as NetworkStore from '../../src/libs/Network/NetworkStore';
+import * as Request from '../../src/libs/Request';
+import * as SequentialQueue from '../../src/libs/Network/SequentialQueue';
+import * as TestHelper from '../utils/TestHelper';
+import waitForNetworkPromises from '../utils/waitForNetworkPromises';
+import ONYXKEYS from '../../src/ONYXKEYS';
+import * as MainQueue from '../../src/libs/Network/MainQueue';
+import HttpUtils from '../../src/libs/HttpUtils';
+
+Onyx.init({
+ keys: ONYXKEYS,
+});
+
+beforeAll(() => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+});
+
+beforeEach(async () => {
+ SequentialQueue.pause();
+ MainQueue.clear();
+ HttpUtils.cancelPendingRequests();
+ NetworkStore.checkRequiredData();
+ await waitForNetworkPromises();
+ jest.clearAllMocks();
+ Request.clearMiddlewares();
+});
+
+describe('Middleware', () => {
+ describe('HandleUnusedOptimisticID', () => {
+ test('Normal request', async () => {
+ const actual = jest.requireActual('../../src/libs/Middleware/HandleUnusedOptimisticID');
+ const handleUnusedOptimisticID = jest.spyOn(actual, 'default');
+ Request.use(handleUnusedOptimisticID);
+ const requests = [
+ {
+ command: 'OpenReport',
+ data: {authToken: 'testToken', reportID: '1234'},
+ },
+ {
+ command: 'AddComment',
+ data: {authToken: 'testToken', reportID: '1234', reportActionID: '5678', reportComment: 'foo'},
+ },
+ ];
+ for (const request of requests) {
+ SequentialQueue.push(request);
+ }
+ SequentialQueue.unpause();
+ await waitForNetworkPromises();
+
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(global.fetch).toHaveBeenLastCalledWith('https://www.expensify.com.dev/api?command=AddComment', expect.anything());
+ TestHelper.assertFormDataMatchesObject(global.fetch.mock.calls[1][1].body, {reportID: '1234', reportActionID: '5678', reportComment: 'foo'});
+ expect(global.fetch).toHaveBeenNthCalledWith(1, 'https://www.expensify.com.dev/api?command=OpenReport', expect.anything());
+ TestHelper.assertFormDataMatchesObject(global.fetch.mock.calls[0][1].body, {reportID: '1234'});
+ });
+
+ test('Request with preexistingReportID', async () => {
+ const actual = jest.requireActual('../../src/libs/Middleware/HandleUnusedOptimisticID');
+ const handleUnusedOptimisticID = jest.spyOn(actual, 'default');
+ Request.use(handleUnusedOptimisticID);
+ const requests = [
+ {
+ command: 'OpenReport',
+ data: {authToken: 'testToken', reportID: '1234'},
+ },
+ {
+ command: 'AddComment',
+ data: {authToken: 'testToken', reportID: '1234', reportActionID: '5678', reportComment: 'foo'},
+ },
+ ];
+ for (const request of requests) {
+ SequentialQueue.push(request);
+ }
+
+ global.fetch.mockImplementationOnce(async () => ({
+ ok: true,
+ json: async () => ({
+ jsonCode: 200,
+ onyxData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}1234`,
+ value: {
+ preexistingReportID: '5555',
+ },
+ },
+ ],
+ }),
+ }));
+
+ SequentialQueue.unpause();
+ await waitForNetworkPromises();
+
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(global.fetch).toHaveBeenLastCalledWith('https://www.expensify.com.dev/api?command=AddComment', expect.anything());
+ TestHelper.assertFormDataMatchesObject(global.fetch.mock.calls[1][1].body, {reportID: '5555', reportActionID: '5678', reportComment: 'foo'});
+ expect(global.fetch).toHaveBeenNthCalledWith(1, 'https://www.expensify.com.dev/api?command=OpenReport', expect.anything());
+ TestHelper.assertFormDataMatchesObject(global.fetch.mock.calls[0][1].body, {reportID: '1234'});
+ });
+ });
+});
diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js
index 60b4e5ebe813..d0c9752367c5 100644
--- a/tests/unit/MigrationTest.js
+++ b/tests/unit/MigrationTest.js
@@ -2,7 +2,6 @@ import Onyx from 'react-native-onyx';
import _ from 'underscore';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import Log from '../../src/libs/Log';
-import AddLastVisibleActionCreated from '../../src/libs/migrations/AddLastVisibleActionCreated';
import PersonalDetailsByAccountID from '../../src/libs/migrations/PersonalDetailsByAccountID';
import CheckForPreviousReportActionID from '../../src/libs/migrations/CheckForPreviousReportActionID';
import ONYXKEYS from '../../src/ONYXKEYS';
@@ -25,62 +24,6 @@ describe('Migrations', () => {
return waitForBatchedUpdates();
});
- describe('AddLastVisibleActionCreated', () => {
- it('Should add lastVisibleActionCreated wherever lastActionCreated currently is', () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT}1`]: {
- lastActionCreated: '2022-11-16 01:31:13.702',
- },
- [`${ONYXKEYS.COLLECTION.REPORT}2`]: {
- lastActionCreated: '2022-11-16 01:31:54.821',
- },
- })
- .then(AddLastVisibleActionCreated)
- .then(() => {
- expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Adding lastVisibleActionCreated field to 2 reports');
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT,
- waitForCollectionCallback: true,
- callback: (allReports) => {
- Onyx.disconnect(connectionID);
- expect(_.keys(allReports).length).toBe(2);
- _.each(allReports, (report) => {
- expect(_.has(report, 'lastVisibleActionCreated')).toBe(true);
- });
- expect(allReports.report_1.lastVisibleActionCreated).toBe('2022-11-16 01:31:13.702');
- expect(allReports.report_2.lastVisibleActionCreated).toBe('2022-11-16 01:31:54.821');
- },
- });
- }));
-
- it('Should skip if the report data already has the correct fields', () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT}1`]: {
- lastVisibleActionCreated: '2022-11-16 01:31:13.702',
- },
- [`${ONYXKEYS.COLLECTION.REPORT}2`]: {
- lastVisibleActionCreated: '2022-11-16 01:31:54.821',
- },
- })
- .then(AddLastVisibleActionCreated)
- .then(() => {
- expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration AddLastVisibleActionCreated');
- }));
-
- it('Should work even if there is no report data', () =>
- AddLastVisibleActionCreated().then(() => {
- expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration AddLastVisibleActionCreated');
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT,
- waitForCollectionCallback: true,
- callback: (allReports) => {
- Onyx.disconnect(connectionID);
- expect(_.compact(_.values(allReports))).toEqual([]);
- },
- });
- }));
- });
-
describe('PersonalDetailsByAccountID', () => {
const DEPRECATED_ONYX_KEYS = {
// Deprecated personal details object which was keyed by login instead of accountID.
diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.js
index 24397a04a0e9..4704994bd1d2 100644
--- a/tests/unit/ReportUtilsTest.js
+++ b/tests/unit/ReportUtilsTest.js
@@ -434,7 +434,7 @@ describe('ReportUtils', () => {
afterAll(() => Onyx.clear());
describe('return empty iou options if', () => {
- it('participants contains excluded iou emails', () => {
+ it('participants aray contains excluded expensify iou emails', () => {
const allEmpty = _.every(CONST.EXPENSIFY_ACCOUNT_IDS, (accountID) => {
const moneyRequestOptions = ReportUtils.getMoneyRequestOptions({}, [currentUserAccountID, accountID], []);
return moneyRequestOptions.length === 0;
@@ -442,14 +442,74 @@ describe('ReportUtils', () => {
expect(allEmpty).toBe(true);
});
- it('no participants except self', () => {
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions({}, [currentUserAccountID], []);
+ it('it is a room with no participants except self', () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID], []);
+ expect(moneyRequestOptions.length).toBe(0);
+ });
+
+ it('its not your policy expense chat', () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ isOwnPolicyExpenseChat: false,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID], []);
+ expect(moneyRequestOptions.length).toBe(0);
+ });
+
+ it('its paid IOU report', () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ type: CONST.REPORT.TYPE.IOU,
+ statusNum: CONST.REPORT.STATUS.REIMBURSED,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID], []);
+ expect(moneyRequestOptions.length).toBe(0);
+ });
+
+ it('its approved Expense report', () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ type: CONST.REPORT.TYPE.EXPENSE,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS.APPROVED,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID], []);
+ expect(moneyRequestOptions.length).toBe(0);
+ });
+
+ it('its paid Expense report', () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ type: CONST.REPORT.TYPE.EXPENSE,
+ statusNum: CONST.REPORT.STATUS.REIMBURSED,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID], []);
expect(moneyRequestOptions.length).toBe(0);
});
+
+ it('it is an expense report tied to a policy expense chat user does not own', () => {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}100`, {
+ reportID: '100',
+ isOwnPolicyExpenseChat: false,
+ }).then(() => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ parentReportID: '100',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID], [CONST.BETAS.IOU_SEND]);
+ expect(moneyRequestOptions.length).toBe(0);
+ });
+ });
});
describe('return only iou split option if', () => {
- it('a chat room', () => {
+ it('it is a chat room with more than one participant', () => {
const onlyHaveSplitOption = _.every(
[CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, CONST.REPORT.CHAT_TYPE.POLICY_ROOM],
(chatType) => {
@@ -464,21 +524,40 @@ describe('ReportUtils', () => {
expect(onlyHaveSplitOption).toBe(true);
});
- it('has multiple participants exclude self', () => {
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions({}, [currentUserAccountID, ...participantsAccountIDs], []);
+ it('has multiple participants excluding self', () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID, ...participantsAccountIDs], []);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT)).toBe(true);
});
- it(' does not have iou send permission', () => {
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions({}, [currentUserAccountID, ...participantsAccountIDs], []);
+ it('user has send money permission', () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID, ...participantsAccountIDs], [CONST.BETAS.IOU_SEND]);
+ expect(moneyRequestOptions.length).toBe(1);
+ expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT)).toBe(true);
+ });
+
+ it("it's a group chat report", () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ type: CONST.REPORT.TYPE.CHAT,
+ participantsAccountIDs: [currentUserAccountID, ...participantsAccountIDs],
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID, ...participantsAccountIDs], [CONST.BETAS.IOU_SEND]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT)).toBe(true);
});
});
- describe('return only iou request option if', () => {
- it('a policy expense chat', () => {
+ describe('return only money request option if', () => {
+ it("it is user's own policy expense chat", () => {
const report = {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
@@ -488,10 +567,57 @@ describe('ReportUtils', () => {
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)).toBe(true);
});
+
+ it("it is an expense report tied to user's own policy expense chat", () => {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}101`, {
+ reportID: '101',
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ isOwnPolicyExpenseChat: true,
+ }).then(() => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ parentReportID: '101',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID], [CONST.BETAS.IOU_SEND]);
+ expect(moneyRequestOptions.length).toBe(1);
+ expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)).toBe(true);
+ });
+ });
+
+ it('it is an IOU report in submitted state', () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ type: CONST.REPORT.TYPE.IOU,
+ state: CONST.REPORT.STATE.SUBMITTED,
+ stateNum: CONST.REPORT.STATE_NUM.PROCESSING,
+ statusNum: CONST.REPORT.STATUS.SUBMITTED,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID, participantsAccountIDs[0]], []);
+ expect(moneyRequestOptions.length).toBe(1);
+ expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)).toBe(true);
+ });
+
+ it('it is an IOU report in submitted state even with send money permissions', () => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ type: CONST.REPORT.TYPE.IOU,
+ state: CONST.REPORT.STATE.SUBMITTED,
+ stateNum: CONST.REPORT.STATE_NUM.PROCESSING,
+ statusNum: CONST.REPORT.STATUS.SUBMITTED,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID, participantsAccountIDs[0]], [CONST.BETAS.IOU_SEND]);
+ expect(moneyRequestOptions.length).toBe(1);
+ expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)).toBe(true);
+ });
});
it('return both iou send and request money in DM', () => {
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions({type: 'chat'}, [currentUserAccountID, participantsAccountIDs[0]], [CONST.BETAS.IOU_SEND]);
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ type: CONST.REPORT.TYPE.CHAT,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, [currentUserAccountID, participantsAccountIDs[0]], [CONST.BETAS.IOU_SEND]);
expect(moneyRequestOptions.length).toBe(2);
expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)).toBe(true);
expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.SEND)).toBe(true);
diff --git a/tests/unit/deepReplaceKeysAndValuesTest.js b/tests/unit/deepReplaceKeysAndValuesTest.js
new file mode 100644
index 000000000000..ecb918026048
--- /dev/null
+++ b/tests/unit/deepReplaceKeysAndValuesTest.js
@@ -0,0 +1,127 @@
+import deepReplaceKeysAndValues from '../../src/libs/deepReplaceKeysAndValues';
+
+describe('deepReplaceKeysAndValues', () => {
+ test.each([
+ [undefined, undefined],
+ [null, null],
+ [3, 3],
+ [true, true],
+ ['someString', 'someString'],
+ ['oldVal', 'newVal'],
+ ['prefix_oldVal', 'prefix_newVal'],
+ [
+ ['a', 'b', 'oldVal'],
+ ['a', 'b', 'newVal'],
+ ],
+ [
+ ['a', 'oldVal', 'c'],
+ ['a', 'newVal', 'c'],
+ ],
+ [
+ ['a', 'b', 'prefix_oldVal'],
+ ['a', 'b', 'prefix_newVal'],
+ ],
+ [
+ {
+ a: '1',
+ b: 2,
+ c: 'oldVal',
+ },
+ {
+ a: '1',
+ b: 2,
+ c: 'newVal',
+ },
+ ],
+ [
+ {
+ a: '1',
+ b: 2,
+ c: 'prefix_oldVal',
+ },
+ {
+ a: '1',
+ b: 2,
+ c: 'prefix_newVal',
+ },
+ ],
+ [
+ {
+ a: '1',
+ b: ['a', 'oldVal'],
+ },
+ {
+ a: '1',
+ b: ['a', 'newVal'],
+ },
+ ],
+ [
+ {
+ a: '1',
+ b: ['a', 'prefix_oldVal'],
+ },
+ {
+ a: '1',
+ b: ['a', 'prefix_newVal'],
+ },
+ ],
+ [
+ {
+ a: {
+ a: 1,
+ b: 'oldVal',
+ },
+ b: 2,
+ },
+ {
+ a: {
+ a: 1,
+ b: 'newVal',
+ },
+ b: 2,
+ },
+ ],
+ [
+ {
+ a: {
+ a: 1,
+ b: 'prefix_oldVal',
+ c: null,
+ },
+ b: 2,
+ c: null,
+ },
+ {
+ a: {
+ a: 1,
+ b: 'prefix_newVal',
+ c: null,
+ },
+ b: 2,
+ c: null,
+ },
+ ],
+ [
+ {
+ oldVal: 1,
+ someOtherKey: 2,
+ },
+ {
+ newVal: 1,
+ someOtherKey: 2,
+ },
+ ],
+ [
+ {
+ prefix_oldVal: 1,
+ someOtherKey: 2,
+ },
+ {
+ prefix_newVal: 1,
+ someOtherKey: 2,
+ },
+ ],
+ ])('deepReplaceKeysAndValues(%s)', (input, expected) => {
+ expect(deepReplaceKeysAndValues(input, 'oldVal', 'newVal')).toStrictEqual(expected);
+ });
+});
diff --git a/tests/utils/TestHelper.js b/tests/utils/TestHelper.js
index ca3955e9eb90..4c658f004894 100644
--- a/tests/utils/TestHelper.js
+++ b/tests/utils/TestHelper.js
@@ -212,4 +212,8 @@ function buildTestReportComment(created, actorAccountID, actionID = null) {
};
}
-export {getGlobalFetchMock, signInWithTestUser, signOutTestUser, setPersonalDetails, buildPersonalDetails, buildTestReportComment};
+function assertFormDataMatchesObject(formData, obj) {
+ expect(_.reduce(Array.from(formData.entries()), (memo, x) => ({...memo, [x[0]]: x[1]}), {})).toEqual(expect.objectContaining(obj));
+}
+
+export {getGlobalFetchMock, signInWithTestUser, signOutTestUser, setPersonalDetails, buildPersonalDetails, buildTestReportComment, assertFormDataMatchesObject};