-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/images/playbook.svg b/docs/assets/images/playbook.svg
new file mode 100644
index 000000000000..0088d8f915f1
--- /dev/null
+++ b/docs/assets/images/playbook.svg
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/hubs/playbooks.html b/docs/hubs/playbooks.html
new file mode 100644
index 000000000000..0f15922fd061
--- /dev/null
+++ b/docs/hubs/playbooks.html
@@ -0,0 +1,6 @@
+---
+layout: default
+title: Playbooks
+---
+
+{% include hub.html %}
diff --git a/docs/index.html b/docs/index.html
index f3f27b7c9b76..d79c87e281a8 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -13,6 +13,7 @@
{% include hub-card.html href="send-money" %}
{% include hub-card.html href="request-money" %}
+ {% include hub-card.html href="playbooks" %}
{% include hub-card.html href="other" %}
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 998254346260..551df13bf1da 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.2.65
+ 1.2.72
CFBundleSignature
????
CFBundleURLTypes
@@ -30,7 +30,7 @@
CFBundleVersion
- 1.2.65.0
+ 1.2.72.0
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index aaba4d654499..f2b2a55a0f51 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.2.65
+ 1.2.72
CFBundleSignature
????
CFBundleVersion
- 1.2.65.0
+ 1.2.72.0
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index fbccc4067560..9e9ed15c25c4 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -472,7 +472,7 @@ PODS:
- React-Core
- react-native-image-manipulator (1.0.5):
- React
- - react-native-image-picker (5.0.1):
+ - react-native-image-picker (5.0.2):
- React-Core
- react-native-netinfo (8.3.1):
- React-Core
@@ -594,13 +594,13 @@ PODS:
- Firebase/Performance (= 8.8.0)
- React-Core
- RNFBApp
- - RNGestureHandler (2.6.0):
+ - RNGestureHandler (2.9.0):
- React-Core
- RNPermissions (3.6.1):
- React-Core
- RNReactNativeHapticFeedback (1.14.0):
- React-Core
- - RNReanimated (3.0.0-rc.6):
+ - RNReanimated (3.0.0-rc.10):
- DoubleConversion
- FBLazyVector
- FBReactNativeSpec
@@ -992,7 +992,7 @@ SPEC CHECKSUMS:
react-native-document-picker: f68191637788994baed5f57d12994aa32cf8bf88
react-native-flipper: dc5290261fbeeb2faec1bdc57ae6dd8d562e1de4
react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56
- react-native-image-picker: 8cb4280e2c1efc3daeb2d9d597f9429a60472e40
+ react-native-image-picker: a5dddebb4d2955ac4712a4ed66b00a85f62a63ac
react-native-netinfo: 1a6035d3b9780221d407c277ebfb5722ace00658
react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa
react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406
@@ -1024,10 +1024,10 @@ SPEC CHECKSUMS:
RNFBApp: 729c0666395b1953198dc4a1ec6deb8fbe1c302e
RNFBCrashlytics: 2061ca863e8e2fa1aae9b12477d7dfa8e88ca0f9
RNFBPerf: 389914cda4000fe0d996a752532a591132cbf3f9
- RNGestureHandler: 920eb17f5b1e15dae6e5ed1904045f8f90e0b11e
+ RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
- RNReanimated: 069f3aff5df4cbefaf81589c0622370073a89f1d
+ RNReanimated: 3eb05867410b44acaa81dd423945af3093305bd4
RNScreens: 0df01424e9e0ed7827200d6ed1087ddd06c493f9
RNSVG: 38ca962c970dbce1ca38991a5aebf26d163f9efb
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 000000000000..32c07a669aa7
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,36 @@
+const testFileExtension = '[jt]s?(x)';
+module.exports = {
+ // TODO: change this back to preset: 'react-native' once we upgrade to React Native >= 0.71.2
+ preset: '@testing-library/react-native',
+ testMatch: [
+ `/tests/ui/**/*.${testFileExtension}`,
+ `/tests/unit/**/*.${testFileExtension}`,
+ `/tests/actions/**/*.${testFileExtension}`,
+ `/?(*.)+(spec|test).${testFileExtension}`,
+ ],
+ transform: {
+ '^.+\\.jsx?$': 'babel-jest',
+ },
+ transformIgnorePatterns: [
+ '/node_modules/(?!react-native)/',
+ ],
+ testPathIgnorePatterns: [
+ '/node_modules',
+ ],
+ globals: {
+ __DEV__: true,
+ WebSocket: {},
+ },
+ fakeTimers: {
+ enableGlobally: true,
+ doNotFake: ['nextTick'],
+ },
+ testEnvironment: 'jsdom',
+ setupFiles: [
+ '/jest/setup.js',
+ ],
+ setupFilesAfterEnv: [
+ '@testing-library/jest-native/extend-expect',
+ ],
+ cacheDirectory: '/.jest-cache',
+};
diff --git a/patches/react-native-fast-image+8.6.3.patch b/patches/react-native-fast-image+8.6.3.patch
index fc7e59c17c2e..f01b87b7fd91 100644
--- a/patches/react-native-fast-image+8.6.3.patch
+++ b/patches/react-native-fast-image+8.6.3.patch
@@ -1,15 +1,16 @@
diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java
new file mode 100644
-index 0000000..03ad017
+index 0000000..2bd58b8
--- /dev/null
+++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java
-@@ -0,0 +1,31 @@
+@@ -0,0 +1,44 @@
+package com.dylanvann.fastimage;
+
+import android.graphics.BitmapFactory;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
++import androidx.exifinterface.media.ExifInterface;
+
+import com.bumptech.glide.load.Options;
+import com.bumptech.glide.load.ResourceDecoder;
@@ -32,6 +33,18 @@ index 0000000..03ad017
+ BitmapFactory.Options bitmapOptions = new BitmapFactory.Options();
+ bitmapOptions.inJustDecodeBounds = true;
+ BitmapFactory.decodeStream(source, null, bitmapOptions);
++
++ // BitmapFactory#decodeStream leaves stream's position where ever it was after reading the encoded data
++ // https://developer.android.com/reference/android/graphics/BitmapFactory#decodeStream(java.io.InputStream)
++ // so we need to rewind the stream to be able to read image header with exif values
++ source.reset();
++
++ int orientation = new ExifInterface(source).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED);
++ if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270) {
++ int tmpWidth = bitmapOptions.outWidth;
++ bitmapOptions.outWidth = bitmapOptions.outHeight;
++ bitmapOptions.outHeight = tmpWidth;
++ }
+ return new SimpleResource(bitmapOptions);
+ }
+}
diff --git a/src/CONST.js b/src/CONST.js
index ae3ffca7f025..10f2a7e63dec 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -7,6 +7,7 @@ const ACTIVE_EXPENSIFY_URL = Url.addTrailingForwardSlash(lodashGet(Config, 'NEW_
const USE_EXPENSIFY_URL = 'https://use.expensify.com';
const PLATFORM_OS_MACOS = 'Mac OS';
const ANDROID_PACKAGE_NAME = 'com.expensify.chat';
+const USA_COUNTRY_NAME = 'United States';
const CONST = {
ANDROID_PACKAGE_NAME,
@@ -27,7 +28,7 @@ const CONST = {
AVATAR_MAX_ATTACHMENT_SIZE: 6291456,
- AVATAR_ALLOWED_EXTENSIONS: ['jpg', 'jpeg', 'png', 'gif', 'bmp'],
+ AVATAR_ALLOWED_EXTENSIONS: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'],
// Minimum width and height size in px for a selected image
AVATAR_MIN_WIDTH_PX: 80,
@@ -40,6 +41,11 @@ const CONST = {
DEFAULT_AVATAR_COUNT: 24,
OLD_DEFAULT_AVATAR_COUNT: 8,
+ DISPLAY_NAME: {
+ MAX_LENGTH: 50,
+ RESERVED_FIRST_NAMES: ['Expensify', 'Concierge'],
+ },
+
// Sizes needed for report empty state background image handling
EMPTY_STATE_BACKGROUND: {
SMALL_SCREEN: {
@@ -484,6 +490,11 @@ const CONST = {
EMOJI_SPACER: 'SPACER',
+ // This is the number of columns in each row of the picker.
+ // Because of how flatList implements these rows, each row is an index rather than each element
+ // For this reason to make headers work, we need to have the header be the only rendered element in its row
+ // If this number is changed, emojis.js will need to be updated to have the proper number of spacer elements
+ // around each header.
EMOJI_NUM_PER_ROW: 8,
EMOJI_FREQUENT_ROW_COUNT: 3,
@@ -504,6 +515,7 @@ const CONST = {
VISIBLE_PASSWORD: 'visible-password',
EMAIL_ADDRESS: 'email-address',
ASCII_CAPABLE: 'ascii-capable',
+ URL: 'url',
},
ATTACHMENT_SOURCE_ATTRIBUTE: 'data-expensify-source',
@@ -530,9 +542,9 @@ const CONST = {
ADD_PAYMENT_MENU_POSITION_X: 356,
EMOJI_PICKER_SIZE: {
WIDTH: 320,
- HEIGHT: 390,
+ HEIGHT: 392,
},
- NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 288,
+ NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 256,
EMOJI_PICKER_ITEM_HEIGHT: 32,
EMOJI_PICKER_HEADER_HEIGHT: 32,
COMPOSER_MAX_HEIGHT: 125,
@@ -717,7 +729,6 @@ const CONST = {
},
DEFAULT_LOCALE: 'en',
- DEFAULT_SKIN_TONE: 'default',
POLICY: {
TYPE: {
@@ -798,6 +809,7 @@ const CONST = {
AFTER_FIRST_LINE_BREAK: /\n.*/g,
CODE_2FA: /^\d{6}$/,
ATTACHMENT_ID: /chat-attachments\/(\d+)/,
+ MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/,
},
PRONOUNS: {
@@ -840,6 +852,8 @@ const CONST = {
MAX_COMMENT_LENGTH: 15000,
FORM_CHARACTER_LIMIT: 50,
+ LEGAL_NAMES_CHARACTER_LIMIT: 150,
+ WORKSPACE_NAME_CHARACTER_LIMIT: 80,
AVATAR_CROP_MODAL: {
// The next two constants control what is min and max value of the image crop scale.
// Values define in how many times the image can be bigger than its container.
@@ -907,6 +921,259 @@ const CONST = {
TFA_CODE_LENGTH: 6,
CHAT_ATTACHMENT_TOKEN_KEY: 'X-Chat-Attachment-Token',
+
+ USA_COUNTRY_NAME,
+ ALL_COUNTRIES: [
+ 'Afghanistan',
+ 'Aland Islands',
+ 'Albania',
+ 'Algeria',
+ 'American Samoa',
+ 'Andorra',
+ 'Angola',
+ 'Anguilla',
+ 'Antarctica',
+ 'Antigua and Barbuda',
+ 'Argentina',
+ 'Armenia',
+ 'Aruba',
+ 'Australia',
+ 'Austria',
+ 'Azerbaijan',
+ 'Bahamas',
+ 'Bahrain',
+ 'Bangladesh',
+ 'Barbados',
+ 'Belarus',
+ 'Belgium',
+ 'Belize',
+ 'Benin',
+ 'Bermuda',
+ 'Bhutan',
+ 'Bolivia',
+ 'Bonaire, Saint Eustatius and Saba ',
+ 'Bosnia and Herzegovina',
+ 'Botswana',
+ 'Bouvet Island',
+ 'Brazil',
+ 'British Indian Ocean Territory',
+ 'British Virgin Islands',
+ 'Brunei',
+ 'Bulgaria',
+ 'Burkina Faso',
+ 'Burundi',
+ 'Cambodia',
+ 'Cameroon',
+ 'Canada',
+ 'Cape Verde',
+ 'Cayman Islands',
+ 'Central African Republic',
+ 'Chad',
+ 'Chile',
+ 'China',
+ 'Christmas Island',
+ 'Cocos Islands',
+ 'Colombia',
+ 'Comoros',
+ 'Cook Islands',
+ 'Costa Rica',
+ 'Croatia',
+ 'Cuba',
+ 'Curacao',
+ 'Cyprus',
+ 'Czech Republic',
+ 'Democratic Republic of the Congo',
+ 'Denmark',
+ 'Djibouti',
+ 'Dominica',
+ 'Dominican Republic',
+ 'East Timor',
+ 'Ecuador',
+ 'Egypt',
+ 'El Salvador',
+ 'Equatorial Guinea',
+ 'Eritrea',
+ 'Estonia',
+ 'Ethiopia',
+ 'Falkland Islands',
+ 'Faroe Islands',
+ 'Fiji',
+ 'Finland',
+ 'France',
+ 'French Guiana',
+ 'French Polynesia',
+ 'French Southern Territories',
+ 'Gabon',
+ 'Gambia',
+ 'Georgia',
+ 'Germany',
+ 'Ghana',
+ 'Gibraltar',
+ 'Greece',
+ 'Greenland',
+ 'Grenada',
+ 'Guadeloupe',
+ 'Guam',
+ 'Guatemala',
+ 'Guernsey',
+ 'Guinea',
+ 'Guinea-Bissau',
+ 'Guyana',
+ 'Haiti',
+ 'Heard Island and McDonald Islands',
+ 'Honduras',
+ 'Hong Kong',
+ 'Hungary',
+ 'Iceland',
+ 'India',
+ 'Indonesia',
+ 'Iran',
+ 'Iraq',
+ 'Ireland',
+ 'Isle of Man',
+ 'Israel',
+ 'Italy',
+ 'Ivory Coast',
+ 'Jamaica',
+ 'Japan',
+ 'Jersey',
+ 'Jordan',
+ 'Kazakhstan',
+ 'Kenya',
+ 'Kiribati',
+ 'Kosovo',
+ 'Kuwait',
+ 'Kyrgyzstan',
+ 'Laos',
+ 'Latvia',
+ 'Lebanon',
+ 'Lesotho',
+ 'Liberia',
+ 'Libya',
+ 'Liechtenstein',
+ 'Lithuania',
+ 'Luxembourg',
+ 'Macao',
+ 'Macedonia',
+ 'Madagascar',
+ 'Malawi',
+ 'Malaysia',
+ 'Maldives',
+ 'Mali',
+ 'Malta',
+ 'Marshall Islands',
+ 'Martinique',
+ 'Mauritania',
+ 'Mauritius',
+ 'Mayotte',
+ 'Mexico',
+ 'Micronesia',
+ 'Moldova',
+ 'Monaco',
+ 'Mongolia',
+ 'Montenegro',
+ 'Montserrat',
+ 'Morocco',
+ 'Mozambique',
+ 'Myanmar',
+ 'Namibia',
+ 'Nauru',
+ 'Nepal',
+ 'Netherlands',
+ 'New Caledonia',
+ 'New Zealand',
+ 'Nicaragua',
+ 'Niger',
+ 'Nigeria',
+ 'Niue',
+ 'Norfolk Island',
+ 'North Korea',
+ 'Northern Mariana Islands',
+ 'Norway',
+ 'Oman',
+ 'Pakistan',
+ 'Palau',
+ 'Palestinian Territory',
+ 'Panama',
+ 'Papua New Guinea',
+ 'Paraguay',
+ 'Peru',
+ 'Philippines',
+ 'Pitcairn',
+ 'Poland',
+ 'Portugal',
+ 'Puerto Rico',
+ 'Qatar',
+ 'Republic of the Congo',
+ 'Reunion',
+ 'Romania',
+ 'Russia',
+ 'Rwanda',
+ 'Saint Barthelemy',
+ 'Saint Helena',
+ 'Saint Kitts and Nevis',
+ 'Saint Lucia',
+ 'Saint Martin',
+ 'Saint Pierre and Miquelon',
+ 'Saint Vincent and the Grenadines',
+ 'Samoa',
+ 'San Marino',
+ 'Sao Tome and Principe',
+ 'Saudi Arabia',
+ 'Senegal',
+ 'Serbia',
+ 'Seychelles',
+ 'Sierra Leone',
+ 'Singapore',
+ 'Sint Maarten',
+ 'Slovakia',
+ 'Slovenia',
+ 'Solomon Islands',
+ 'Somalia',
+ 'South Africa',
+ 'South Georgia and the South Sandwich Islands',
+ 'South Korea',
+ 'South Sudan',
+ 'Spain',
+ 'Sri Lanka',
+ 'Sudan',
+ 'Suriname',
+ 'Svalbard and Jan Mayen',
+ 'Swaziland',
+ 'Sweden',
+ 'Switzerland',
+ 'Syria',
+ 'Taiwan',
+ 'Tajikistan',
+ 'Tanzania',
+ 'Thailand',
+ 'Togo',
+ 'Tokelau',
+ 'Tonga',
+ 'Trinidad and Tobago',
+ 'Tunisia',
+ 'Turkey',
+ 'Turkmenistan',
+ 'Turks and Caicos Islands',
+ 'Tuvalu',
+ 'U.S. Virgin Islands',
+ 'Uganda',
+ 'Ukraine',
+ 'United Arab Emirates',
+ 'United Kingdom',
+ USA_COUNTRY_NAME,
+ 'Uruguay',
+ 'Uzbekistan',
+ 'Vanuatu',
+ 'Vatican',
+ 'Venezuela',
+ 'Vietnam',
+ 'Wallis and Futuna',
+ 'Western Sahara',
+ 'Yemen',
+ 'Zambia',
+ 'Zimbabwe',
+ ],
};
export default CONST;
diff --git a/src/Expensify.js b/src/Expensify.js
index 6750c365c164..b4a4f341194e 100644
--- a/src/Expensify.js
+++ b/src/Expensify.js
@@ -2,9 +2,10 @@ import _ from 'underscore';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {PureComponent} from 'react';
-import {AppState} from 'react-native';
+import {AppState, Linking} from 'react-native';
import Onyx, {withOnyx} from 'react-native-onyx';
+import * as ReportUtils from './libs/ReportUtils';
import BootSplash from './libs/BootSplash';
import * as ActiveClientManager from './libs/ActiveClientManager';
import ONYXKEYS from './ONYXKEYS';
@@ -26,6 +27,7 @@ import Navigation from './libs/Navigation/Navigation';
import DeeplinkWrapper from './components/DeeplinkWrapper';
import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu';
import * as ReportActionContextMenu from './pages/home/report/ContextMenu/ReportActionContextMenu';
+import KeyboardShortcutsModal from './components/KeyboardShortcutsModal';
// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
// eslint-disable-next-line no-unused-vars
@@ -120,6 +122,9 @@ class Expensify extends PureComponent {
});
this.appStateChangeListener = AppState.addEventListener('change', this.initializeClient);
+
+ // Open chat report from a deep link (only mobile native)
+ Linking.addEventListener('url', state => ReportUtils.openReportFromDeepLink(state.url));
}
componentDidUpdate() {
@@ -134,6 +139,9 @@ class Expensify extends PureComponent {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({isSplashShown: false});
+
+ // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report
+ Linking.getInitialURL().then(url => ReportUtils.openReportFromDeepLink(url));
}
}
@@ -190,6 +198,7 @@ class Expensify extends PureComponent {
{!this.state.isSplashShown && (
<>
+
`settings/addlogin/${type}`,
SETTINGS_PAYMENTS_TRANSFER_BALANCE: 'settings/payments/transfer-balance',
SETTINGS_PAYMENTS_CHOOSE_TRANSFER_ACCOUNT: 'settings/payments/choose-transfer-account',
+ SETTINGS_PERSONAL_DETAILS,
+ SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: `${SETTINGS_PERSONAL_DETAILS}/legal-name`,
+ SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: `${SETTINGS_PERSONAL_DETAILS}/date-of-birth`,
+ SETTINGS_PERSONAL_DETAILS_ADDRESS: `${SETTINGS_PERSONAL_DETAILS}/address`,
NEW_GROUP: 'new/group',
NEW_CHAT: 'new/chat',
REPORT,
diff --git a/src/components/AddressSearch.js b/src/components/AddressSearch.js
index 77aeb76c7624..3ba622591546 100644
--- a/src/components/AddressSearch.js
+++ b/src/components/AddressSearch.js
@@ -7,9 +7,10 @@ import lodashGet from 'lodash/get';
import CONFIG from '../CONFIG';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import styles from '../styles/styles';
+import themeColors from '../styles/themes/default';
import TextInput from './TextInput';
-import Log from '../libs/Log';
import * as GooglePlacesUtils from '../libs/GooglePlacesUtils';
+import CONST from '../CONST';
// The error that's being thrown below will be ignored until we fork the
// react-native-google-places-autocomplete repo and replace the
@@ -48,6 +49,9 @@ const propTypes = {
// eslint-disable-next-line react/forbid-prop-types
containerStyles: PropTypes.arrayOf(PropTypes.object),
+ /** Should address search be limited to results in the USA */
+ isLimitedToUSA: PropTypes.bool,
+
/** A map of inputID key names */
renamedInputKeys: PropTypes.shape({
street: PropTypes.string,
@@ -56,6 +60,9 @@ const propTypes = {
zipCode: PropTypes.string,
}),
+ /** Maximum number of characters allowed in search input */
+ maxInputLength: PropTypes.number,
+
...withLocalizePropTypes,
};
@@ -68,12 +75,14 @@ const defaultProps = {
value: undefined,
defaultValue: undefined,
containerStyles: [],
+ isLimitedToUSA: true,
renamedInputKeys: {
street: 'addressStreet',
city: 'addressCity',
state: 'addressState',
zipCode: 'addressZipCode',
},
+ maxInputLength: undefined,
};
// Do not convert to class component! It's been tried before and presents more challenges than it's worth.
@@ -81,6 +90,10 @@ const defaultProps = {
// Reference: https://github.com/FaridSafi/react-native-google-places-autocomplete/issues/609#issuecomment-886133839
const AddressSearch = (props) => {
const [displayListViewBorder, setDisplayListViewBorder] = useState(false);
+ const query = {language: props.preferredLocale, types: 'address'};
+ if (props.isLimitedToUSA) {
+ query.components = 'country:us';
+ }
const saveLocationDetails = (details) => {
const addressComponents = details.address_components;
@@ -89,20 +102,33 @@ const AddressSearch = (props) => {
}
// Gather the values from the Google details
- const streetNumber = GooglePlacesUtils.getAddressComponent(addressComponents, 'street_number', 'long_name') || '';
- const streetName = GooglePlacesUtils.getAddressComponent(addressComponents, 'route', 'long_name') || '';
- const street = `${streetNumber} ${streetName}`.trim();
- let city = GooglePlacesUtils.getAddressComponent(addressComponents, 'locality', 'long_name');
- if (!city) {
- city = GooglePlacesUtils.getAddressComponent(addressComponents, 'sublocality', 'long_name');
- Log.hmmm('[AddressSearch] Replacing missing locality with sublocality: ', {address: details.formatted_address, sublocality: city});
- }
- const zipCode = GooglePlacesUtils.getAddressComponent(addressComponents, 'postal_code', 'long_name');
- const state = GooglePlacesUtils.getAddressComponent(addressComponents, 'administrative_area_level_1', 'short_name');
+ const {
+ street_number: streetNumber,
+ route: streetName,
+ locality: city,
+ sublocality: cityFallback, // Some locations only return sublocality instead of locality
+ postal_code: zipCode,
+ administrative_area_level_1: state,
+ country,
+ } = GooglePlacesUtils.getAddressComponents(addressComponents, {
+ street_number: 'long_name',
+ route: 'long_name',
+ locality: 'long_name',
+ sublocality: 'long_name',
+ postal_code: 'long_name',
+ administrative_area_level_1: 'short_name',
+ country: 'long_name',
+ });
const values = {
street: props.value ? props.value.trim() : '',
+ city: city || cityFallback,
+ zipCode,
+ state,
+ country: '',
};
+
+ const street = `${streetNumber} ${streetName}`.trim();
if (street && street.length >= values.street.length) {
// We are only passing the street number and name if the combined length is longer than the value
// that was initially passed to the autocomplete component. Google Places can truncate details
@@ -110,18 +136,11 @@ const AddressSearch = (props) => {
// specific than the one the user entered manually.
values.street = street;
}
- if (city) {
- values.city = city;
- }
- if (zipCode) {
- values.zipCode = zipCode;
- }
- if (state) {
- values.state = state;
- }
- if (_.size(values) === 0) {
- return;
+
+ if (_.includes(CONST.ALL_COUNTRIES, country)) {
+ values.country = country;
}
+
if (props.inputID) {
_.each(values, (value, key) => {
const inputKey = lodashGet(props.renamedInputKeys, key, key);
@@ -162,11 +181,7 @@ const AddressSearch = (props) => {
// After we select an option, we set displayListViewBorder to false to prevent UI flickering
setDisplayListViewBorder(false);
}}
- query={{
- language: props.preferredLocale,
- types: 'address',
- components: 'country:us',
- }}
+ query={query}
requestUrl={{
useOnPlatform: 'all',
url: `${CONFIG.EXPENSIFY.URL_API_ROOT}api?command=Proxy_GooglePlaces&proxyUrl=`,
@@ -208,6 +223,7 @@ const AddressSearch = (props) => {
setDisplayListViewBorder(false);
}
},
+ maxLength: props.maxInputLength,
}}
styles={{
textInputContainer: [styles.flexColumn],
@@ -228,6 +244,8 @@ const AddressSearch = (props) => {
description: [styles.googleSearchText],
separator: [styles.googleSearchSeparator],
}}
+ listHoverColor={themeColors.border}
+ listUnderlayColor={themeColors.buttonPressedBG}
onLayout={(event) => {
// We use the height of the element to determine if we should hide the border of the listView dropdown
// to prevent a lingering border when there are no address suggestions.
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 7e3e0713aca1..d76106411eca 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -20,7 +20,6 @@ import HeaderWithCloseButton from './HeaderWithCloseButton';
import fileDownload from '../libs/fileDownload';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import ConfirmModal from './ConfirmModal';
-import TextWithEllipsis from './TextWithEllipsis';
import HeaderGap from './HeaderGap';
import SafeAreaConsumer from './SafeAreaConsumer';
@@ -233,8 +232,6 @@ class AttachmentModal extends PureComponent {
// If source is a URL, add auth token to get access
const source = this.state.source;
- const {fileName, fileExtension} = FileUtils.splitExtensionFromFileName(this.props.originalFileName || lodashGet(this.state, 'file.name', ''));
-
return (
<>
this.downloadAttachment(source)}
onCloseButtonPress={() => this.setState({isModalOpen: false})}
- subtitle={fileName ? (
-
- ) : ''}
/>
{this.state.source && (
diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js
index 1d9922ead4cc..b0b568d986c8 100644
--- a/src/components/AvatarCropModal/AvatarCropModal.js
+++ b/src/components/AvatarCropModal/AvatarCropModal.js
@@ -270,10 +270,16 @@ const AvatarCropModal = (props) => {
height: size, width: size, originX, originY,
};
+ // Svg images are converted to a png blob to preserve transparency, so we need to update the
+ // image name and type accordingly.
+ const isSvg = props.imageType.includes('image/svg');
+ const imageName = isSvg ? 'fileName.png' : props.imageName;
+ const imageType = isSvg ? 'image/png' : props.imageType;
+
cropOrRotateImage(
props.imageUri,
[{rotate: rotation.value % 360}, {crop}],
- {compress: 1, name: props.imageName, type: props.imageType},
+ {compress: 1, name: imageName, type: imageType},
)
.then((newImage) => {
props.onClose();
diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js
index 5e00825c5a50..6a0167fe937a 100755
--- a/src/components/Composer/index.js
+++ b/src/components/Composer/index.js
@@ -12,6 +12,8 @@ import updateIsFullComposerAvailable from '../../libs/ComposerUtils/updateIsFull
import getNumberOfLines from '../../libs/ComposerUtils/index';
import * as Browser from '../../libs/Browser';
import Clipboard from '../../libs/Clipboard';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions';
+import compose from '../../libs/compose';
const propTypes = {
/** Maximum number of lines in the text input */
@@ -65,6 +67,8 @@ const propTypes = {
isComposerFullSize: PropTypes.bool,
...withLocalizePropTypes,
+
+ ...windowDimensionsPropTypes,
};
const defaultProps = {
@@ -156,7 +160,8 @@ class Composer extends React.Component {
if (prevProps.value !== this.props.value
|| prevProps.defaultValue !== this.props.defaultValue
- || prevProps.isComposerFullSize !== this.props.isComposerFullSize) {
+ || prevProps.isComposerFullSize !== this.props.isComposerFullSize
+ || prevProps.windowWidth !== this.props.windowWidth) {
this.updateNumberOfLines();
}
@@ -268,7 +273,7 @@ class Composer extends React.Component {
return;
}
- const plainText = event.clipboardData.getData('text/plain').replace(/\n\n/g, '\n');
+ const plainText = event.clipboardData.getData('text/plain');
this.paste(Str.htmlDecode(plainText));
}
@@ -297,9 +302,7 @@ class Composer extends React.Component {
// the only stuff put into the clipboard is what the user selected.
const selectedText = event.target.value.substring(this.state.selection.start, this.state.selection.end);
- // The plaintext portion that is put into the clipboard needs to have the newlines duplicated. This is because
- // the paste functionality is stripping all duplicate newlines to try and provide consistent behavior.
- Clipboard.setHtml(selectedText, selectedText.replace(/\n/g, '\n\n'));
+ Clipboard.setHtml(selectedText, selectedText);
}
/**
@@ -366,7 +369,10 @@ class Composer extends React.Component {
Composer.propTypes = propTypes;
Composer.defaultProps = defaultProps;
-export default withLocalize(React.forwardRef((props, ref) => (
+export default compose(
+ withLocalize,
+ withWindowDimensions,
+)(React.forwardRef((props, ref) => (
/* eslint-disable-next-line react/jsx-props-no-spreading */
)));
diff --git a/src/components/CopySelectionHelper.js b/src/components/CopySelectionHelper.js
index d7d5d206d2c1..f300adabcbff 100644
--- a/src/components/CopySelectionHelper.js
+++ b/src/components/CopySelectionHelper.js
@@ -36,7 +36,9 @@ class CopySelectionHelper extends React.Component {
Clipboard.setString(parser.htmlToMarkdown(selection));
return;
}
- Clipboard.setHtml(selection, Str.htmlDecode(parser.htmlToText(selection)));
+
+ // Replace doubled newlines with the single ones because selection from SelectionScraper html contains doubled marks
+ Clipboard.setHtml(selection, Str.htmlDecode(parser.htmlToText(selection).replace(/\n\n/g, '\n')));
}
render() {
diff --git a/src/components/CountryPicker.js b/src/components/CountryPicker.js
new file mode 100644
index 000000000000..d673e3719079
--- /dev/null
+++ b/src/components/CountryPicker.js
@@ -0,0 +1,66 @@
+import _ from 'underscore';
+import React, {forwardRef} from 'react';
+import PropTypes from 'prop-types';
+import CONST from '../CONST';
+import Picker from './Picker';
+import withLocalize, {withLocalizePropTypes} from './withLocalize';
+
+const COUNTRIES = _.map(CONST.ALL_COUNTRIES, countryName => ({
+ value: countryName,
+ label: countryName,
+}));
+
+const propTypes = {
+ /** The label for the field */
+ label: PropTypes.string,
+
+ /** A callback method that is called when the value changes and it receives the selected value as an argument. */
+ onInputChange: PropTypes.func.isRequired,
+
+ /** The value that needs to be selected */
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+
+ /** The ID used to uniquely identify the input in a form */
+ inputID: PropTypes.string,
+
+ /** Saves a draft of the input value when used in a form */
+ shouldSaveDraft: PropTypes.bool,
+
+ /** Callback that is called when the text input is blurred */
+ onBlur: PropTypes.func,
+
+ /** Error text to display */
+ errorText: PropTypes.string,
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ label: '',
+ value: undefined,
+ errorText: '',
+ shouldSaveDraft: false,
+ inputID: undefined,
+ onBlur: () => {},
+};
+
+const CountryPicker = forwardRef((props, ref) => (
+
+));
+
+CountryPicker.propTypes = propTypes;
+CountryPicker.defaultProps = defaultProps;
+CountryPicker.displayName = 'CountryPicker';
+
+export default withLocalize(CountryPicker);
diff --git a/src/components/DeeplinkWrapper/deeplinkRoutes.js b/src/components/DeeplinkWrapper/deeplinkRoutes.js
index 2e41db1a48cf..06b6aba8e828 100644
--- a/src/components/DeeplinkWrapper/deeplinkRoutes.js
+++ b/src/components/DeeplinkWrapper/deeplinkRoutes.js
@@ -1,4 +1,5 @@
import ROUTES from '../../ROUTES';
+import Permissions from '../../libs/Permissions';
/** @type {Array} Routes regex used for desktop deeplinking */
export default [
@@ -21,6 +22,10 @@ export default [
{
// /v/*
pattern: '/v($|(//*))',
+
+ // Disable deep linking in desktop App when passwordless is enabled because
+ // we want to open the magic link in its own tab
+ isDisabled: betas => Permissions.canUsePasswordlessLogins(betas),
},
{
// /bank-account/*
diff --git a/src/components/DeeplinkWrapper/index.website.js b/src/components/DeeplinkWrapper/index.website.js
index 5498d81627ab..cfcda9660ca8 100644
--- a/src/components/DeeplinkWrapper/index.website.js
+++ b/src/components/DeeplinkWrapper/index.website.js
@@ -2,6 +2,7 @@ import _ from 'underscore';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import React, {PureComponent} from 'react';
+import {withOnyx} from 'react-native-onyx';
import deeplinkRoutes from './deeplinkRoutes';
import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator';
import TextLink from '../TextLink';
@@ -9,12 +10,14 @@ import * as Illustrations from '../Icon/Illustrations';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import Text from '../Text';
import styles from '../../styles/styles';
+import compose from '../../libs/compose';
import CONST from '../../CONST';
import CONFIG from '../../CONFIG';
import Icon from '../Icon';
import * as Expensicons from '../Icon/Expensicons';
import colors from '../../styles/colors';
import * as Browser from '../../libs/Browser';
+import ONYXKEYS from '../../ONYXKEYS';
const propTypes = {
/** Children to render. */
@@ -54,6 +57,9 @@ class DeeplinkWrapper extends PureComponent {
// check if pathname matches with deeplink routes
const matchedRoute = _.find(deeplinkRoutes, (route) => {
+ if (route.isDisabled && route.isDisabled(this.props.betas)) {
+ return false;
+ }
const routeRegex = new RegExp(route.pattern);
return routeRegex.test(window.location.pathname);
});
@@ -156,4 +162,9 @@ class DeeplinkWrapper extends PureComponent {
}
DeeplinkWrapper.propTypes = propTypes;
-export default withLocalize(DeeplinkWrapper);
+export default compose(
+ withLocalize,
+ withOnyx({
+ betas: {key: ONYXKEYS.BETAS},
+ }),
+)(DeeplinkWrapper);
diff --git a/src/components/EmojiPicker/CategoryShortcutBar.js b/src/components/EmojiPicker/CategoryShortcutBar.js
new file mode 100644
index 000000000000..0114df692850
--- /dev/null
+++ b/src/components/EmojiPicker/CategoryShortcutBar.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import _ from 'underscore';
+import styles from '../../styles/styles';
+import FrequentlyUsed from '../../../assets/images/history.svg';
+import Smiley from '../../../assets/images/emoji.svg';
+import AnimalsAndNature from '../../../assets/images/emojiCategoryIcons/plant.svg';
+import FoodAndDrink from '../../../assets/images/emojiCategoryIcons/hamburger.svg';
+import TravelAndPlaces from '../../../assets/images/emojiCategoryIcons/plane.svg';
+import Activities from '../../../assets/images/emojiCategoryIcons/soccer-ball.svg';
+import Objects from '../../../assets/images/emojiCategoryIcons/light-bulb.svg';
+import Symbols from '../../../assets/images/emojiCategoryIcons/peace-sign.svg';
+import Flags from '../../../assets/images/emojiCategoryIcons/flag.svg';
+import CategoryShortcutButton from './CategoryShortcutButton';
+
+const propTypes = {
+ /** The function to call when an emoji is selected */
+ onPress: PropTypes.func.isRequired,
+
+ /** The indices that the icons should link to */
+ headerIndices: PropTypes.arrayOf(PropTypes.number).isRequired,
+};
+
+const CategoryShortcutBar = (props) => {
+ const icons = [Smiley, AnimalsAndNature, FoodAndDrink, TravelAndPlaces, Activities, Objects, Symbols, Flags];
+
+ // If the user has frequently used emojis, there will be 9 headers, otherwise there will be 8
+ if (props.headerIndices.length === 9) {
+ icons.unshift(FrequentlyUsed);
+ }
+
+ return (
+
+ {_.map(props.headerIndices, (headerIndex, i) => (
+ props.onPress(headerIndex)}
+ key={`categoryShortcut${i}`}
+ />
+ ))}
+
+ );
+};
+CategoryShortcutBar.propTypes = propTypes;
+CategoryShortcutBar.displayName = 'CategoryShortcutBar';
+
+export default CategoryShortcutBar;
diff --git a/src/components/EmojiPicker/CategoryShortcutButton.js b/src/components/EmojiPicker/CategoryShortcutButton.js
new file mode 100644
index 000000000000..3b5d43f9b10d
--- /dev/null
+++ b/src/components/EmojiPicker/CategoryShortcutButton.js
@@ -0,0 +1,53 @@
+import React, {PureComponent} from 'react';
+import PropTypes from 'prop-types';
+import {Pressable, View} from 'react-native';
+import Icon from '../Icon';
+import variables from '../../styles/variables';
+import styles from '../../styles/styles';
+import * as StyleUtils from '../../styles/StyleUtils';
+import getButtonState from '../../libs/getButtonState';
+import themeColors from '../../styles/themes/default';
+
+const propTypes = {
+ /** The icon representation of the category that this button links to */
+ icon: PropTypes.func.isRequired,
+
+ /** The function to call when an emoji is selected */
+ onPress: PropTypes.func.isRequired,
+};
+
+class CategoryShortcutButton extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isHighlighted: false,
+ };
+ }
+
+ render() {
+ return (
+ this.setState({isHighlighted: true})}
+ onHoverOut={() => this.setState({isHighlighted: false})}
+ style={({pressed}) => ([
+ StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)),
+ styles.categoryShortcutButton,
+ this.state.isHighlighted && styles.emojiItemHighlighted,
+ ])}
+ >
+
+
+
+
+ );
+ }
+}
+CategoryShortcutButton.propTypes = propTypes;
+
+export default CategoryShortcutButton;
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index e2ad3bed1b78..810fc49040d7 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -8,11 +8,9 @@ import CONST from '../../../CONST';
import ONYXKEYS from '../../../ONYXKEYS';
import styles from '../../../styles/styles';
import * as StyleUtils from '../../../styles/StyleUtils';
-import themeColors from '../../../styles/themes/default';
import emojis from '../../../../assets/emojis';
import EmojiPickerMenuItem from '../EmojiPickerMenuItem';
import Text from '../../Text';
-import Composer from '../../Composer';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../withWindowDimensions';
import withLocalize, {withLocalizePropTypes} from '../../withLocalize';
import compose from '../../../libs/compose';
@@ -20,6 +18,8 @@ import getOperatingSystem from '../../../libs/getOperatingSystem';
import * as User from '../../../libs/actions/User';
import EmojiSkinToneList from '../EmojiSkinToneList';
import * as EmojiUtils from '../../../libs/EmojiUtils';
+import CategoryShortcutBar from '../CategoryShortcutBar';
+import TextInput from '../../TextInput';
const propTypes = {
/** Function to add the selected emoji to the main compose text input */
@@ -57,24 +57,20 @@ class EmojiPickerMenu extends Component {
// Ref for emoji FlatList
this.emojiList = undefined;
- // This is the number of columns in each row of the picker.
- // Because of how flatList implements these rows, each row is an index rather than each element
- // For this reason to make headers work, we need to have the header be the only rendered element in its row
- // If this number is changed, emojis.js will need to be updated to have the proper number of spacer elements
- // around each header.
- this.numColumns = CONST.EMOJI_NUM_PER_ROW;
-
const allEmojis = EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis, this.props.frequentlyUsedEmojis);
- // This is the indices of each category of emojis
+ // This is the actual header index starting at the first emoji and counting each one
+ this.headerIndices = EmojiUtils.getHeaderIndices(allEmojis);
+
+ // This is the indices of each header's Row
// The positions are static, and are calculated as index/numColumns (8 in our case)
- // This is because each row of 8 emojis counts as one index
- this.unfilteredHeaderIndices = EmojiUtils.getDynamicHeaderIndices(allEmojis);
+ // This is because each row of 8 emojis counts as one index to the flatlist
+ this.headerRowIndices = _.map(this.headerIndices, headerIndex => Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW));
// If we're on Windows, don't display the flag emojis (the last category),
// since Windows doesn't support them (and only displays country codes instead)
this.emojis = getOperatingSystem() === CONST.OS.WINDOWS
- ? allEmojis.slice(0, this.unfilteredHeaderIndices.pop() * this.numColumns)
+ ? allEmojis.slice(0, this.headerRowIndices.pop() * CONST.EMOJI_NUM_PER_ROW)
: allEmojis;
this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300);
@@ -88,13 +84,14 @@ class EmojiPickerMenu extends Component {
this.updatePreferredSkinTone = this.updatePreferredSkinTone.bind(this);
this.setFirstNonHeaderIndex = this.setFirstNonHeaderIndex.bind(this);
this.getItemLayout = this.getItemLayout.bind(this);
+ this.scrollToHeader = this.scrollToHeader.bind(this);
this.currentScrollOffset = 0;
this.firstNonHeaderIndex = 0;
this.state = {
filteredEmojis: this.emojis,
- headerIndices: this.unfilteredHeaderIndices,
+ headerIndices: this.headerRowIndices,
highlightedIndex: -1,
arePointerEventsDisabled: false,
selection: {
@@ -301,8 +298,8 @@ class EmojiPickerMenu extends Component {
switch (arrowKey) {
case 'ArrowDown':
move(
- this.numColumns,
- () => this.state.highlightedIndex + this.numColumns > this.state.filteredEmojis.length - 1,
+ CONST.EMOJI_NUM_PER_ROW,
+ () => this.state.highlightedIndex + CONST.EMOJI_NUM_PER_ROW > this.state.filteredEmojis.length - 1,
);
break;
case 'ArrowLeft':
@@ -319,8 +316,8 @@ class EmojiPickerMenu extends Component {
break;
case 'ArrowUp':
move(
- -this.numColumns,
- () => this.state.highlightedIndex - this.numColumns < this.firstNonHeaderIndex,
+ -CONST.EMOJI_NUM_PER_ROW,
+ () => this.state.highlightedIndex - CONST.EMOJI_NUM_PER_ROW < this.firstNonHeaderIndex,
() => {
// Reaching start of the list, arrow up set the focus to searchInput.
this.focusInputWithTextSelect();
@@ -339,25 +336,23 @@ class EmojiPickerMenu extends Component {
}
}
+ scrollToHeader(headerIndex) {
+ const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT;
+ this.emojiList.flashScrollIndicators();
+ this.emojiList.scrollToOffset({offset: calculatedOffset, animated: true});
+ }
+
/**
* Calculates the required scroll offset (aka distance from top) and scrolls the FlatList to the highlighted emoji
* if any portion of it falls outside of the window.
* Doing this because scrollToIndex doesn't work as expected.
*/
scrollToHighlightedIndex() {
- // If there are headers in the emoji array, so we need to offset by their heights as well
- let numHeaders = 0;
- if (this.state.filteredEmojis.length === this.emojis.length) {
- numHeaders = _.filter(this.unfilteredHeaderIndices, i => this.state.highlightedIndex > i * this.numColumns).length;
- }
-
- // Calculate the scroll offset at the bottom of the currently highlighted emoji
- // (subtract numHeaders because the highlightedIndex includes them, and add 1 to include the current row)
- const numEmojiRows = (Math.floor(this.state.highlightedIndex / this.numColumns) - numHeaders) + 1;
+ // Calculate the number of rows above the current row, then add 1 to include the current row
+ const numRows = Math.floor(this.state.highlightedIndex / CONST.EMOJI_NUM_PER_ROW) + 1;
// The scroll offsets at the top and bottom of the highlighted emoji
- const offsetAtEmojiBottom = ((numHeaders) * CONST.EMOJI_PICKER_HEADER_HEIGHT)
- + (numEmojiRows * CONST.EMOJI_PICKER_ITEM_HEIGHT);
+ const offsetAtEmojiBottom = numRows * CONST.EMOJI_PICKER_HEADER_HEIGHT;
const offsetAtEmojiTop = offsetAtEmojiBottom - CONST.EMOJI_PICKER_ITEM_HEIGHT;
// Scroll to fit the entire highlighted emoji into the window if we need to
@@ -388,7 +383,7 @@ class EmojiPickerMenu extends Component {
// There are no headers when searching, so we need to re-make them sticky when there is no search term
this.setState({
filteredEmojis: this.emojis,
- headerIndices: this.unfilteredHeaderIndices,
+ headerIndices: this.headerRowIndices,
highlightedIndex: -1,
});
this.setFirstNonHeaderIndex(this.emojis);
@@ -439,7 +434,7 @@ class EmojiPickerMenu extends Component {
if (header) {
return (
-
+
{this.props.translate(`emojiPicker.headers.${code}`)}
@@ -473,14 +468,15 @@ class EmojiPickerMenu extends Component {
style={[styles.emojiPickerContainer, StyleUtils.getEmojiPickerStyle(this.props.isSmallScreenWidth)]}
pointerEvents={this.state.arePointerEventsDisabled ? 'none' : 'auto'}
>
+
{!this.props.isSmallScreenWidth && (
-
-
+ this.searchInput = el}
autoFocus
@@ -512,7 +508,7 @@ class EmojiPickerMenu extends Component {
data={this.state.filteredEmojis}
renderItem={this.renderItem}
keyExtractor={item => `emoji_picker_${item.code}`}
- numColumns={this.numColumns}
+ numColumns={CONST.EMOJI_NUM_PER_ROW}
style={[
styles.emojiPickerList,
this.isMobileLandscape() && styles.emojiPickerListLandscape,
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
index d70eed98800b..edf383eda1d8 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
@@ -1,7 +1,9 @@
import React, {Component} from 'react';
-import {View, FlatList} from 'react-native';
+import {View, findNodeHandle} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
+import _ from 'underscore';
+import Animated, {runOnUI, _scrollTo} from 'react-native-reanimated';
import compose from '../../../libs/compose';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../withWindowDimensions';
import CONST from '../../../CONST';
@@ -14,6 +16,7 @@ import withLocalize, {withLocalizePropTypes} from '../../withLocalize';
import EmojiSkinToneList from '../EmojiSkinToneList';
import * as EmojiUtils from '../../../libs/EmojiUtils';
import * as User from '../../../libs/actions/User';
+import CategoryShortcutBar from '../CategoryShortcutBar';
const propTypes = {
/** Function to add the selected emoji to the main compose text input */
@@ -43,23 +46,28 @@ class EmojiPickerMenu extends Component {
constructor(props) {
super(props);
- // This is the number of columns in each row of the picker.
- // Because of how flatList implements these rows, each row is an index rather than each element
- // For this reason to make headers work, we need to have the header be the only rendered element in its row
- // If this number is changed, emojis.js will need to be updated to have the proper number of spacer elements
- // around each header.
- this.numColumns = CONST.EMOJI_NUM_PER_ROW;
+ // Ref for emoji FlatList
+ this.emojiList = undefined;
this.emojis = EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis, this.props.frequentlyUsedEmojis);
- // This is the indices of each category of emojis
+ // This is the actual header index starting at the first emoji and counting each one
+ this.headerIndices = EmojiUtils.getHeaderIndices(this.emojis);
+
+ // This is the indices of each header's Row
// The positions are static, and are calculated as index/numColumns (8 in our case)
- // This is because each row of 8 emojis counts as one index
- this.unfilteredHeaderIndices = EmojiUtils.getDynamicHeaderIndices(this.emojis);
+ // This is because each row of 8 emojis counts as one index to the flatlist
+ this.headerRowIndices = _.map(this.headerIndices, headerIndex => Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW));
this.renderItem = this.renderItem.bind(this);
this.isMobileLandscape = this.isMobileLandscape.bind(this);
this.updatePreferredSkinTone = this.updatePreferredSkinTone.bind(this);
+ this.scrollToHeader = this.scrollToHeader.bind(this);
+ this.getItemLayout = this.getItemLayout.bind(this);
+ }
+
+ getItemLayout(data, index) {
+ return {length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index};
}
/**
@@ -91,6 +99,17 @@ class EmojiPickerMenu extends Component {
User.updatePreferredSkinTone(skinTone);
}
+ scrollToHeader(headerIndex) {
+ const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT;
+ this.emojiList.flashScrollIndicators();
+ const node = findNodeHandle(this.emojiList);
+ runOnUI(() => {
+ 'worklet';
+
+ _scrollTo(node, 0, calculatedOffset, true);
+ })();
+ }
+
/**
* Given an emoji item object, render a component based on its type.
* Items with the code "SPACER" return nothing and are used to fill rows up to 8
@@ -108,7 +127,7 @@ class EmojiPickerMenu extends Component {
if (item.header) {
return (
-
+
{this.props.translate(`emojiPicker.headers.${item.code}`)}
@@ -130,16 +149,25 @@ class EmojiPickerMenu extends Component {
render() {
return (
-
+
+
+ this.emojiList = el}
data={this.emojis}
renderItem={this.renderItem}
keyExtractor={item => (`emoji_picker_${item.code}`)}
- numColumns={this.numColumns}
+ numColumns={CONST.EMOJI_NUM_PER_ROW}
style={[
styles.emojiPickerList,
this.isMobileLandscape() && styles.emojiPickerListLandscape,
]}
- stickyHeaderIndices={this.unfilteredHeaderIndices}
+ stickyHeaderIndices={this.headerRowIndices}
+ getItemLayout={this.getItemLayout}
+ showsVerticalScrollIndicator
/>
diff --git a/src/components/LocalePicker.js b/src/components/LocalePicker.js
index dece444a303f..5be074168250 100644
--- a/src/components/LocalePicker.js
+++ b/src/components/LocalePicker.js
@@ -7,7 +7,6 @@ import * as App from '../libs/actions/App';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import ONYXKEYS from '../ONYXKEYS';
import CONST from '../CONST';
-import Permissions from '../libs/Permissions';
import * as Localize from '../libs/Localize';
import Picker from './Picker';
import styles from '../styles/styles';
@@ -19,51 +18,41 @@ const propTypes = {
/** Indicates size of a picker component and whether to render the label or not */
size: PropTypes.oneOf(['normal', 'small']),
- /** Beta features list */
- betas: PropTypes.arrayOf(PropTypes.string),
-
...withLocalizePropTypes,
};
const defaultProps = {
preferredLocale: CONST.DEFAULT_LOCALE,
size: 'normal',
- betas: [],
};
const localesToLanguages = {
default: {
value: 'en',
- label: Localize.translate('en', 'preferencesPage.languages.english'),
+ label: Localize.translate('en', 'languagePage.languages.en.label'),
},
es: {
value: 'es',
- label: Localize.translate('es', 'preferencesPage.languages.spanish'),
+ label: Localize.translate('es', 'languagePage.languages.es.label'),
},
};
-const LocalePicker = (props) => {
- if (!Permissions.canUseInternationalization(props.betas)) {
- return null;
- }
-
- return (
- {
- if (locale === props.preferredLocale) {
- return;
- }
+const LocalePicker = props => (
+ {
+ if (locale === props.preferredLocale) {
+ return;
+ }
- App.setLocale(locale);
- }}
- items={_.values(localesToLanguages)}
- size={props.size}
- value={props.preferredLocale}
- containerStyles={props.size === 'small' ? [styles.pickerContainerSmall] : []}
- />
- );
-};
+ App.setLocale(locale);
+ }}
+ items={_.values(localesToLanguages)}
+ size={props.size}
+ value={props.preferredLocale}
+ containerStyles={props.size === 'small' ? [styles.pickerContainerSmall] : []}
+ />
+);
LocalePicker.defaultProps = defaultProps;
LocalePicker.propTypes = propTypes;
@@ -75,8 +64,5 @@ export default compose(
preferredLocale: {
key: ONYXKEYS.NVP_PREFERRED_LOCALE,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
}),
)(LocalePicker);
diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js
index 1b58579f93cf..dc29bc489618 100644
--- a/src/components/Modal/BaseModal.js
+++ b/src/components/Modal/BaseModal.js
@@ -76,7 +76,8 @@ class BaseModal extends PureComponent {
isSmallScreenWidth: this.props.isSmallScreenWidth,
},
this.props.popoverAnchorPosition,
- this.props.containerStyle,
+ this.props.innerContainerStyle,
+ this.props.outerStyle,
);
return (
0 && this.props.shouldHaveOptionSeparator}
+ shouldDisableRowInnerPadding={this.props.shouldDisableRowInnerPadding}
/>
);
}
diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js
index 0ca8b7a70901..91aaa5988d57 100644
--- a/src/components/OptionsList/optionsListPropTypes.js
+++ b/src/components/OptionsList/optionsListPropTypes.js
@@ -70,6 +70,9 @@ const propTypes = {
/** Whether to show a line separating options in list */
shouldHaveOptionSeparator: PropTypes.bool,
+
+ /** Whether to disable the inner padding in rows */
+ shouldDisableRowInnerPadding: PropTypes.bool,
};
const defaultProps = {
@@ -90,6 +93,7 @@ const defaultProps = {
isDisabled: false,
onLayout: undefined,
shouldHaveOptionSeparator: false,
+ shouldDisableRowInnerPadding: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index 78afe0f379b2..d430e4ab13c2 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -20,12 +20,19 @@ const propTypes = {
/** Whether we should wait before focusing the TextInput, useful when using transitions on Android */
shouldDelayFocus: PropTypes.bool,
+ /** padding bottom style of safe area */
+ safeAreaPaddingBottomStyle: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.object),
+ PropTypes.object,
+ ]),
+
...optionsSelectorPropTypes,
...withLocalizePropTypes,
};
const defaultProps = {
shouldDelayFocus: false,
+ safeAreaPaddingBottomStyle: {},
...optionsSelectorDefaultProps,
};
@@ -271,6 +278,7 @@ class BaseOptionsSelector extends Component {
isDisabled={this.props.isDisabled}
shouldHaveOptionSeparator={this.props.shouldHaveOptionSeparator}
onLayout={this.props.onLayout}
+ contentContainerStyles={shouldShowFooter ? undefined : [this.props.safeAreaPaddingBottomStyle]}
/>
) : ;
return (
diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js
index f8a6d961ca7e..f3ef801f668d 100644
--- a/src/components/PDFView/index.js
+++ b/src/components/PDFView/index.js
@@ -148,6 +148,9 @@ class PDFView extends Component {
width={pageWidth}
key={`page_${index + 1}`}
pageNumber={index + 1}
+
+ // This needs to be empty to avoid multiple loading texts which show per page and look ugly
+ // See https://github.com/Expensify/App/issues/14358 for more details
loading=""
/>
))}
diff --git a/src/components/PasswordPopover/BasePasswordPopover.js b/src/components/PasswordPopover/BasePasswordPopover.js
index d4d9fb5a9a82..0029fe97040f 100644
--- a/src/components/PasswordPopover/BasePasswordPopover.js
+++ b/src/components/PasswordPopover/BasePasswordPopover.js
@@ -11,6 +11,7 @@ import TextInput from '../TextInput';
import KeyboardSpacer from '../KeyboardSpacer';
import {propTypes as passwordPopoverPropTypes, defaultProps as passwordPopoverDefaultProps} from './passwordPopoverPropTypes';
import Button from '../Button';
+import withViewportOffsetTop from '../withViewportOffsetTop';
const propTypes = {
/** Whether we should wait before focusing the TextInput, useful when using transitions on Android */
@@ -55,6 +56,7 @@ class BasePasswordPopover extends Component {
onClose={this.props.onClose}
anchorPosition={this.props.anchorPosition}
onModalShow={this.focusInput}
+ outerStyle={{maxHeight: this.props.windowHeight, marginTop: this.props.viewportOffsetTop}}
>
),
onBlur: () => {},
@@ -162,14 +166,18 @@ class Picker extends PureComponent {
]}
>
{this.props.label && (
- {this.props.label}
+
+ {this.props.label}
+
)}
({...item, color: themeColors.pickerOptionsTextColor}))}
- style={this.props.size === 'normal' ? styles.picker(this.props.isDisabled) : styles.pickerSmall}
+ style={this.props.size === 'normal'
+ ? styles.picker(this.props.isDisabled, this.props.backgroundColor)
+ : styles.pickerSmall(this.props.backgroundColor)}
useNativeAndroidPickerStyle={false}
placeholder={this.placeholder}
value={this.props.value}
diff --git a/src/components/Popover/index.js b/src/components/Popover/index.js
index 9a2e20ed3da7..54ca87239593 100644
--- a/src/components/Popover/index.js
+++ b/src/components/Popover/index.js
@@ -1,22 +1,44 @@
import React from 'react';
+import PropTypes from 'prop-types';
import {createPortal} from 'react-dom';
-import {propTypes, defaultProps} from './popoverPropTypes';
+import {withOnyx} from 'react-native-onyx';
+import {propTypes as popoverPropTypes, defaultProps as popoverDefaultProps} from './popoverPropTypes';
import CONST from '../../CONST';
import Modal from '../Modal';
import withWindowDimensions from '../withWindowDimensions';
+import compose from '../../libs/compose';
+import ONYXKEYS from '../../ONYXKEYS';
+
+const propTypes = {
+ isShortcutsModalOpen: PropTypes.bool,
+ ...popoverPropTypes,
+};
+
+const defaultProps = {
+ isShortcutsModalOpen: false,
+ ...popoverDefaultProps,
+};
/*
* This is a convenience wrapper around the Modal component for a responsive Popover.
* On small screen widths, it uses BottomDocked modal type, and a Popover type on wide screen widths.
*/
const Popover = (props) => {
+ if (props.isShortcutsModalOpen && props.isVisible) {
+ // There are modals that can show up on top of these pop-overs, for example, the keyboard shortcut menu,
+ // if that happens, close the pop-over as if we were clicking outside.
+ props.onClose();
+ return null;
+ }
+
if (!props.fullscreen && !props.isSmallScreenWidth) {
return createPortal(
{
}
return (
{Boolean(this.props.prefixCharacter) && (
-
- {this.props.prefixCharacter}
-
+
+
+ {this.props.prefixCharacter}
+
+
)}
{
diff --git a/src/components/ValidateCodeModal.js b/src/components/ValidateCodeModal.js
new file mode 100644
index 000000000000..9bf52d2c4795
--- /dev/null
+++ b/src/components/ValidateCodeModal.js
@@ -0,0 +1,91 @@
+import React, {PureComponent} from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import colors from '../styles/colors';
+import styles from '../styles/styles';
+import Icon from './Icon';
+import withLocalize, {withLocalizePropTypes} from './withLocalize';
+import Text from './Text';
+import * as Expensicons from './Icon/Expensicons';
+import * as Illustrations from './Icon/Illustrations';
+import variables from '../styles/variables';
+import TextLink from './TextLink';
+
+const propTypes = {
+
+ /** Whether the user has been signed in with the link. */
+ isSuccessfullySignedIn: PropTypes.bool,
+
+ /** Code to display. */
+ code: PropTypes.string.isRequired,
+
+ /** Whether the user can get signed straight in the App from the current page */
+ shouldShowSignInHere: PropTypes.bool,
+
+ /** Callback to be called when user clicks the Sign in here link */
+ onSignInHereClick: PropTypes.func,
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ isSuccessfullySignedIn: false,
+ shouldShowSignInHere: false,
+ onSignInHereClick: () => {},
+};
+
+class ValidateCodeModal extends PureComponent {
+ render() {
+ return (
+
+
+
+
+
+
+ {this.props.translate(this.props.isSuccessfullySignedIn ? 'validateCodeModal.successfulSignInTitle' : 'validateCodeModal.title')}
+
+
+
+ {this.props.translate(this.props.isSuccessfullySignedIn ? 'validateCodeModal.successfulSignInDescription' : 'validateCodeModal.description')}
+ {this.props.shouldShowSignInHere
+ && (
+ <>
+ {this.props.translate('validateCodeModal.or')}
+ {' '}
+
+ {this.props.translate('validateCodeModal.signInHere')}
+
+ >
+ )}
+ {this.props.shouldShowSignInHere ? '!' : '.'}
+
+
+ {!this.props.isSuccessfullySignedIn && (
+
+
+ {this.props.code}
+
+
+ )}
+
+
+
+
+
+ );
+ }
+}
+
+ValidateCodeModal.propTypes = propTypes;
+ValidateCodeModal.defaultProps = defaultProps;
+export default withLocalize(ValidateCodeModal);
diff --git a/src/components/withViewportOffsetTop.js b/src/components/withViewportOffsetTop.js
new file mode 100644
index 000000000000..2fcad4f48651
--- /dev/null
+++ b/src/components/withViewportOffsetTop.js
@@ -0,0 +1,72 @@
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import lodashGet from 'lodash/get';
+import getComponentDisplayName from '../libs/getComponentDisplayName';
+import addViewportResizeListener from '../libs/VisualViewport';
+
+const viewportOffsetTopPropTypes = {
+ // viewportOffsetTop returns the offset of the top edge of the visual viewport from the
+ // top edge of the layout viewport in CSS pixels, when the visual viewport is resized.
+
+ viewportOffsetTop: PropTypes.number.isRequired,
+};
+
+export default function (WrappedComponent) {
+ class WithViewportOffsetTop extends Component {
+ constructor(props) {
+ super(props);
+
+ this.updateDimensions = this.updateDimensions.bind(this);
+
+ this.state = {
+ viewportOffsetTop: 0,
+ };
+ }
+
+ componentDidMount() {
+ this.removeViewportResizeListener = addViewportResizeListener(this.updateDimensions);
+ }
+
+ componentWillUnmount() {
+ this.removeViewportResizeListener();
+ }
+
+ /**
+ * @param {SyntheticEvent} e
+ */
+ updateDimensions(e) {
+ const viewportOffsetTop = lodashGet(e, 'target.offsetTop', 0);
+ this.setState({viewportOffsetTop});
+ }
+
+ render() {
+ return (
+
+ );
+ }
+ }
+
+ WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`;
+ WithViewportOffsetTop.propTypes = {
+ forwardedRef: PropTypes.oneOfType([
+ PropTypes.func,
+ PropTypes.shape({current: PropTypes.instanceOf(React.Component)}),
+ ]),
+ };
+ WithViewportOffsetTop.defaultProps = {
+ forwardedRef: undefined,
+ };
+ return React.forwardRef((props, ref) => (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+ ));
+}
+
+export {
+ viewportOffsetTopPropTypes,
+};
diff --git a/src/languages/en.js b/src/languages/en.js
index f57d68bfe2d7..ad9310d9dcd1 100755
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -53,16 +53,20 @@ export default {
members: 'Members',
invite: 'Invite',
here: 'here',
+ date: 'Date',
dob: 'Date of birth',
ssnLast4: 'Last 4 digits of SSN',
ssnFull9: 'Full 9 digits of SSN',
+ addressLine: ({lineNumber}) => `Address line ${lineNumber}`,
personalAddress: 'Personal address',
companyAddress: 'Company address',
noPO: 'PO boxes and mail drop addresses are not allowed',
city: 'City',
state: 'State',
+ stateOrProvince: 'State / Province',
+ country: 'Country',
zip: 'Zip code',
- isRequiredField: 'is a required field',
+ zipPostCode: 'Zip / Postcode',
whatThis: 'What\'s this?',
iAcceptThe: 'I accept the ',
remove: 'Remove',
@@ -83,7 +87,12 @@ export default {
invalidAmount: 'Invalid amount',
acceptedTerms: 'You must accept the Terms of Service to continue',
phoneNumber: `Please enter a valid phone number, with the country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER})`,
+ fieldRequired: 'This field is required.',
+ characterLimit: ({limit}) => `Exceeds the maximum length of ${limit} characters`,
+ dateInvalid: 'Please enter a valid date',
},
+ comma: 'comma',
+ semicolon: 'semicolon',
please: 'Please',
contactUs: 'contact us',
pleaseEnterEmailOrPhoneNumber: 'Please enter an email or phone number',
@@ -113,8 +122,8 @@ export default {
websiteExample: 'e.g. https://www.expensify.com',
},
attachmentPicker: {
- cameraPermissionRequired: 'Camera permission required',
- expensifyDoesntHaveAccessToCamera: 'This app does not have access to your camera, please enable the permission and try again.',
+ cameraPermissionRequired: 'Camera access',
+ expensifyDoesntHaveAccessToCamera: 'Expensify can\'t take photos without access to your camera. Tap Settings to update permissions.',
attachmentError: 'Attachment error',
errorWhileSelectingAttachment: 'An error occurred while selecting an attachment, please try again',
errorWhileSelectingCorruptedImage: 'An error occurred while selecting a corrupted attachment, please try another file',
@@ -146,6 +155,14 @@ export default {
youCanAlso: 'You can also',
openLinkInBrowser: 'open this link in your browser',
},
+ validateCodeModal: {
+ successfulSignInTitle: 'Abracadabra,\nyou are signed in!',
+ successfulSignInDescription: 'Head back to your original tab to continue.',
+ title: 'Here is your magic code',
+ description: 'Please enter the code using the device\nwhere it was originally requested',
+ or: ', or',
+ signInHere: 'just sign in here',
+ },
iOUConfirmationList: {
whoPaid: 'Who paid?',
whoWasThere: 'Who was there?',
@@ -327,7 +344,9 @@ export default {
eEyEmEir: 'E / Ey / Em / Eir',
faeFaer: 'Fae / Faer',
heHimHis: 'He / Him / His',
+ heHimHisTheyThemTheirs: 'He / Him / His / They / Them / Theirs',
sheHerHers: 'She / Her / Hers',
+ sheHerHersTheyThemTheirs: 'She / Her / Hers / They / Them / Theirs',
merMers: 'Mer / Mers',
neNirNirs: 'Ne / Nir / Nirs',
neeNerNers: 'Nee / Ner / Ners',
@@ -493,16 +512,31 @@ export default {
defaultPaymentMethod: 'Default',
},
preferencesPage: {
- mostRecent: 'Most recent',
- mostRecentModeDescription: 'This will display all chats by default, sorted by most recent, with pinned items at the top.',
- focus: '#focus',
- focusModeDescription: '#focus – This will only display unread and pinned chats, all sorted alphabetically.',
receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receive relevant feature updates and Expensify news',
+ },
+ priorityModePage: {
priorityMode: 'Priority mode',
+ explainerText: 'Choose whether to show all chats by default sorted with most recent with pinned items at the top, or #focus on unread pinned items, sorted alphabetically.',
+ priorityModes: {
+ default: {
+ label: 'Most recent',
+ description: 'Show all chats sorted by most recent',
+ },
+ gsd: {
+ label: '#focus',
+ description: 'Only show unread sorted alphabetically',
+ },
+ },
+ },
+ languagePage: {
language: 'Language',
languages: {
- english: 'English',
- spanish: 'Spanish',
+ en: {
+ label: 'English',
+ },
+ es: {
+ label: 'Spanish',
+ },
},
},
signInPage: {
@@ -566,12 +600,22 @@ export default {
},
personalDetails: {
error: {
- firstNameLength: 'First name shouldn\'t be longer than 50 characters',
- lastNameLength: 'Last name shouldn\'t be longer than 50 characters',
- characterLimit: ({limit}) => `Exceeds the max length of ${limit} characters`,
- hasInvalidCharacter: ({invalidCharacter}) => `Please remove the ${invalidCharacter} from the name field.`,
- comma: 'comma',
- semicolon: 'semicolon',
+ firstNameLength: `First name cannot be longer than ${CONST.DISPLAY_NAME.MAX_LENGTH} characters`,
+ lastNameLength: `Last name cannot be longer than ${CONST.DISPLAY_NAME.MAX_LENGTH} characters`,
+ containsReservedWord: 'First name cannot contain the words Expensify or Concierge',
+ hasInvalidCharacter: 'Name cannot contain a comma or semicolon',
+ },
+ },
+ privatePersonalDetails: {
+ personalDetails: 'Personal details',
+ privateDataMessage: 'These details are used for travel and payments. They are never shown on your public profile.',
+ legalName: 'Legal name',
+ legalFirstName: 'Legal first name',
+ legalLastName: 'Legal last name',
+ homeAddress: 'Home address',
+ error: {
+ dateShouldBeBefore: ({dateString}) => `Date should be before ${dateString}.`,
+ dateShouldBeAfter: ({dateString}) => `Date should be after ${dateString}.`,
},
},
resendValidationForm: {
@@ -631,7 +675,6 @@ export default {
addressCity: 'Please enter a valid city',
addressStreet: 'Please enter a valid street address that is not a PO Box',
addressState: 'Please select a valid state',
- incorporationDate: 'Please enter a valid date',
incorporationDateFuture: 'Incorporation date cannot be in the future',
incorporationState: 'Please enter a valid state',
industryCode: 'Please enter a valid industry classification code. Must be 6 digits.',
@@ -1024,8 +1067,6 @@ export default {
phoneNumberExtension: 'Please enter a valid phone extension number',
firstName: 'Please provide your first name',
lastName: 'Please provide your last name',
- firstNameLength: 'First name shouldn\'t be longer than 50 characters',
- lastNameLength: 'Last name shouldn\'t be longer than 50 characters',
},
},
requestCallConfirmationScreen: {
@@ -1106,8 +1147,8 @@ export default {
message: 'Attachment cannot be downloaded',
},
permissionError: {
- title: 'Access needed',
- message: 'Expensify does not have access to save attachments. To enable access, go to Settings and allow access',
+ title: 'Storage access',
+ message: 'Expensify can\'t save attachments without storage access. Tap Settings to update permissions.',
},
},
desktopApplicationMenu: {
diff --git a/src/languages/es.js b/src/languages/es.js
index c46381c8d169..e299f3a7db0f 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -28,8 +28,8 @@ export default {
not: 'No',
signIn: 'Conectarse',
continue: 'Continuar',
- firstName: 'Primer nombre',
- lastName: 'Apellido',
+ firstName: 'Nombre',
+ lastName: 'Apellidos',
phone: 'teléfono',
phoneNumber: 'Número de teléfono',
phoneNumberPlaceholder: '(prefijo) + (número)',
@@ -53,16 +53,20 @@ export default {
members: 'Miembros',
invite: 'Invitar',
here: 'aquí',
+ date: 'Fecha',
dob: 'Fecha de Nacimiento',
ssnLast4: 'Últimos 4 dígitos de su SSN',
ssnFull9: 'Los 9 dígitos del SSN',
+ addressLine: ({lineNumber}) => `Dirección línea ${lineNumber}`,
personalAddress: 'Dirección física personal',
companyAddress: 'Dirección física de la empresa',
noPO: 'No se aceptan apartados ni direcciones postales',
city: 'Ciudad',
state: 'Estado',
+ stateOrProvince: 'Estado / Provincia',
+ country: 'País',
zip: 'Código postal',
- isRequiredField: 'es un campo obligatorio',
+ zipPostCode: 'Código Postal',
whatThis: '¿Qué es esto?',
iAcceptThe: 'Acepto los ',
remove: 'Eliminar',
@@ -83,7 +87,12 @@ export default {
invalidAmount: 'Monto no válido',
acceptedTerms: 'Debes aceptar los Términos de servicio para continuar',
phoneNumber: `Ingresa un teléfono válido, incluyendo el código de país (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER})`,
+ fieldRequired: 'Este campo es obligatorio.',
+ characterLimit: ({limit}) => `Supera el límite de ${limit} caracteres`,
+ dateInvalid: 'Ingresa una fecha válida',
},
+ comma: 'la coma',
+ semicolon: 'el punto y coma',
please: 'Por favor',
contactUs: 'contáctenos',
pleaseEnterEmailOrPhoneNumber: 'Por favor escribe un email o número de teléfono',
@@ -113,8 +122,8 @@ export default {
websiteExample: 'p. ej. https://www.expensify.com',
},
attachmentPicker: {
- cameraPermissionRequired: 'Se necesita permiso para usar la cámara',
- expensifyDoesntHaveAccessToCamera: 'Esta aplicación no tiene acceso a tu cámara, por favor activa el permiso y vuelve a intentarlo.',
+ cameraPermissionRequired: 'Permiso para acceder a la cámara',
+ expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a tu cámara. Haz click en Configuración para actualizar los permisos.',
attachmentError: 'Error al adjuntar archivo',
errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un adjunto, por favor inténtalo de nuevo',
errorWhileSelectingCorruptedImage: 'Ha ocurrido un error al seleccionar un adjunto corrupto, por favor inténtalo con otro archivo',
@@ -146,6 +155,14 @@ export default {
youCanAlso: 'También puedes',
openLinkInBrowser: 'abrir este enlace en tu navegador',
},
+ validateCodeModal: {
+ successfulSignInTitle: 'Abracadabra,\n¡sesión iniciada!',
+ successfulSignInDescription: 'Vuelve a la pestaña original para continuar.',
+ title: 'Aquí está tu código mágico',
+ or: ', ¡o',
+ description: 'Por favor, introduzca el código utilizando el dispositivo\nen el que se solicitó originalmente',
+ signInHere: 'simplemente inicia sesión aquí',
+ },
iOUConfirmationList: {
whoPaid: '¿Quién pago?',
whoWasThere: '¿Quién asistió?',
@@ -327,7 +344,9 @@ export default {
eEyEmEir: 'E / Ey / Em / Eir',
faeFaer: 'Fae / Faer',
heHimHis: 'Él',
+ heHimHisTheyThemTheirs: 'Él / Ellos',
sheHerHers: 'Ella',
+ sheHerHersTheyThemTheirs: 'Ella / Ellos',
merMers: 'Mer / Mers',
neNirNirs: 'Ne / Nir / Nirs',
neeNerNers: 'Nee / Ner / Ners',
@@ -493,16 +512,31 @@ export default {
defaultPaymentMethod: 'Predeterminado',
},
preferencesPage: {
- mostRecent: 'Más recientes',
- mostRecentModeDescription: 'Esta opción muestra por defecto todos los chats, ordenados a partir del más reciente, con los chats destacados arriba de todo.',
- focus: '#concentración',
- focusModeDescription: '#concentración – Muestra sólo los chats no leídos y destacados ordenados alfabéticamente.',
receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto',
+ },
+ priorityModePage: {
priorityMode: 'Modo prioridad',
+ explainerText: 'Elija si desea mostrar por defecto todos los chats ordenados desde el más reciente y con los elementos anclados en la parte superior, o elija el modo #concentración, con los elementos no leídos anclados en la parte superior y ordenados alfabéticamente.',
+ priorityModes: {
+ default: {
+ label: 'Más recientes',
+ description: 'Mostrar todos los chats ordenados desde el más reciente',
+ },
+ gsd: {
+ label: '#concentración',
+ description: 'Mostrar sólo los no leídos ordenados alfabéticamente',
+ },
+ },
+ },
+ languagePage: {
language: 'Idioma',
languages: {
- english: 'Inglés',
- spanish: 'Español',
+ en: {
+ label: 'Inglés',
+ },
+ es: {
+ label: 'Español',
+ },
},
},
signInPage: {
@@ -566,12 +600,22 @@ export default {
},
personalDetails: {
error: {
- firstNameLength: 'El nombre no debe tener más de 50 caracteres',
- lastNameLength: 'El apellido no debe tener más de 50 caracteres',
- characterLimit: ({limit}) => `Supera el límite de ${limit} caracteres`,
- hasInvalidCharacter: ({invalidCharacter}) => `Por favor elimina ${invalidCharacter} del campo nombre.`,
- comma: 'la coma',
- semicolon: 'el punto y coma',
+ firstNameLength: `El nombre no puede tener más de ${CONST.DISPLAY_NAME.MAX_LENGTH} caracteres`,
+ lastNameLength: `El apellido no puede tener más de ${CONST.DISPLAY_NAME.MAX_LENGTH} caracteres`,
+ containsReservedWord: 'El nombre no puede contener las palabras Expensify o Concierge',
+ hasInvalidCharacter: 'El nombre no puede contener una coma o un punto y coma',
+ },
+ },
+ privatePersonalDetails: {
+ personalDetails: 'Datos personales',
+ privateDataMessage: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en su perfil público.',
+ legalName: 'Nombre completo',
+ legalFirstName: 'Nombre legal',
+ legalLastName: 'Apellidos legales',
+ homeAddress: 'Domicilio',
+ error: {
+ dateShouldBeBefore: ({dateString}) => `La fecha debe ser anterior a ${dateString}.`,
+ dateShouldBeAfter: ({dateString}) => `La fecha debe ser posterior a ${dateString}.`,
},
},
resendValidationForm: {
@@ -631,7 +675,6 @@ export default {
addressCity: 'Ingresa una ciudad válida',
addressStreet: 'Ingresa una calle de dirección válida que no sea un apartado postal',
addressState: 'Por favor, selecciona un estado',
- incorporationDate: 'Ingresa una fecha válida',
incorporationDateFuture: 'La fecha de incorporación no puede ser futura',
incorporationState: 'Ingresa un estado válido',
industryCode: 'Ingresa un código de clasificación de industria válido',
@@ -644,8 +687,8 @@ export default {
dob: 'Ingresa una fecha de nacimiento válida',
age: 'Debe ser mayor de 18 años',
ssnLast4: 'Ingresa los últimos 4 dígitos del número de seguro social',
- firstName: 'Ingresa un nombre válido',
- lastName: 'Ingresa un apellido válido',
+ firstName: 'Ingresa el nombre',
+ lastName: 'Ingresa los apellidos',
noDefaultDepositAccountOrDebitCardAvailable: 'Por favor agrega una cuenta bancaria para depósitos o una tarjeta de débito',
validationAmounts: 'Los montos de validación que ingresaste son incorrectos. Verifica tu cuenta de banco e intenta de nuevo.',
},
@@ -696,7 +739,7 @@ export default {
helpLink: 'Obtenga más información sobre por qué necesitamos esto.',
legalFirstNameLabel: 'Primer nombre legal',
legalMiddleNameLabel: 'Segundo nombre legal',
- legalLastNameLabel: 'Apellido legal',
+ legalLastNameLabel: 'Apellidos legales',
selectAnswer: 'Selecciona una respuesta.',
ssnFull9Error: 'Por favor escribe los 9 dígitos de un SSN válido',
needSSNFull9: 'Estamos teniendo problemas para verificar su SSN. Ingresa los 9 dígitos del SSN.',
@@ -1025,9 +1068,7 @@ export default {
error: {
phoneNumberExtension: 'Por favor, introduce una extensión telefónica válida',
firstName: 'Por favor, ingresa tu nombre',
- lastName: 'Por favor, ingresa tu apellido',
- firstNameLength: 'El nombre no debe tener más de 50 caracteres',
- lastNameLength: 'El apellido no debe tener más de 50 caracteres',
+ lastName: 'Por favor, ingresa tus apellidos',
},
},
requestCallConfirmationScreen: {
@@ -1108,8 +1149,8 @@ export default {
message: 'No se puede descargar el archivo adjunto',
},
permissionError: {
- title: 'Se necesita acceso',
- message: 'Expensify no tiene acceso para guardar archivos. Para habilitar la descarga de archivos, entra en Preferencias y habilita el acceso',
+ title: 'Permiso para acceder al almacenamiento',
+ message: 'Expensify no puede guardar los archivos adjuntos sin permiso para acceder al almacenamiento. Haz click en Configuración para actualizar los permisos.',
},
},
desktopApplicationMenu: {
diff --git a/src/libs/E2E/apiMocks/openApp.js b/src/libs/E2E/apiMocks/openApp.js
index 8ebea45e79d0..da612f4dab87 100644
--- a/src/libs/E2E/apiMocks/openApp.js
+++ b/src/libs/E2E/apiMocks/openApp.js
@@ -888,7 +888,7 @@ export default () => ({
{
onyxMethod: 'merge',
key: 'preferredEmojiSkinTone',
- value: 'default',
+ value: -1,
},
{
onyxMethod: 'set',
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js
index d6ed8875e998..6f3583b70c0a 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.js
@@ -86,13 +86,13 @@ function containsOnlyEmojis(message) {
* @param {Object[]} emojis
* @returns {Number[]}
*/
-function getDynamicHeaderIndices(emojis) {
+function getHeaderIndices(emojis) {
const headerIndices = [];
_.each(emojis, (emoji, index) => {
if (!emoji.header) {
return;
}
- headerIndices.push(Math.floor(index / CONST.EMOJI_NUM_PER_ROW));
+ headerIndices.push(index);
});
return headerIndices;
}
@@ -246,7 +246,7 @@ function suggestEmojis(text, limit = 5) {
}
export {
- getDynamicHeaderIndices,
+ getHeaderIndices,
mergeEmojisWithFrequentlyUsedEmojis,
addToFrequentlyUsedEmojis,
containsOnlyEmojis,
diff --git a/src/libs/Environment/betaChecker/index.android.js b/src/libs/Environment/betaChecker/index.android.js
index 3b38f37bcb52..8fbe93f84c0c 100644
--- a/src/libs/Environment/betaChecker/index.android.js
+++ b/src/libs/Environment/betaChecker/index.android.js
@@ -1,6 +1,15 @@
import semver from 'semver';
+import Onyx from 'react-native-onyx';
import CONST from '../../../CONST';
import pkg from '../../../../package.json';
+import ONYXKEYS from '../../../ONYXKEYS';
+import * as AppUpdate from '../../actions/AppUpdate';
+
+let isLastSavedBeta = false;
+Onyx.connect({
+ key: ONYXKEYS.IS_BETA,
+ callback: value => isLastSavedBeta = value,
+});
/**
* Check the GitHub releases to see if the current build is a beta build or production build
@@ -14,15 +23,18 @@ function isBetaBuild() {
.then((json) => {
const productionVersion = json.tag_name;
if (!productionVersion) {
+ AppUpdate.setIsAppInBeta(false);
resolve(false);
}
// If the current version we are running is greater than the production version, we are on a beta version of Android
const isBeta = semver.gt(pkg.version, productionVersion);
+ AppUpdate.setIsAppInBeta(isBeta);
resolve(isBeta);
})
.catch(() => {
- resolve(false);
+ // Use isLastSavedBeta in case we fail to fetch the new one, e.g. when we are offline
+ resolve(isLastSavedBeta);
});
});
}
diff --git a/src/libs/GooglePlacesUtils.js b/src/libs/GooglePlacesUtils.js
index 5a875e7f102a..36b776e05a52 100644
--- a/src/libs/GooglePlacesUtils.js
+++ b/src/libs/GooglePlacesUtils.js
@@ -3,25 +3,31 @@ import _ from 'underscore';
/**
* Finds an address component by type, and returns the value associated to key. Each address component object
* inside the addressComponents array has the following structure:
- * {
+ * [{
* long_name: "New York",
* short_name: "New York",
* types: [ "locality", "political" ]
- * }
+ * }]
*
* @param {Array} addressComponents
- * @param {String} type
- * @param {String} key
- * @returns {String|undefined}
+ * @param {Object} fieldsToExtract – has shape: {addressType: 'keyToUse'}
+ * @returns {Object}
*/
-function getAddressComponent(addressComponents, type, key) {
- return _.chain(addressComponents)
- .find(component => _.contains(component.types, type))
- .get(key)
- .value();
+function getAddressComponents(addressComponents, fieldsToExtract) {
+ const result = _.mapObject(fieldsToExtract, () => '');
+ _.each(addressComponents, (addressComponent) => {
+ _.each(addressComponent.types, (addressType) => {
+ if (!_.has(fieldsToExtract, addressType) || !_.isEmpty(result[addressType])) {
+ return;
+ }
+ const value = addressComponent[fieldsToExtract[addressType]] ? addressComponent[fieldsToExtract[addressType]] : '';
+ result[addressType] = value;
+ });
+ });
+ return result;
}
export {
// eslint-disable-next-line import/prefer-default-export
- getAddressComponent,
+ getAddressComponents,
};
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js
index 58fae5f8b4c5..c3230a6d353e 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.js
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.js
@@ -22,6 +22,7 @@ import * as Modal from '../../actions/Modal';
import modalCardStyleInterpolator from './modalCardStyleInterpolator';
import createCustomModalStackNavigator from './createCustomModalStackNavigator';
import NotFoundPage from '../../../pages/ErrorPage/NotFoundPage';
+import getCurrentUrl from '../currentUrl';
// Modal Stack Navigators
import * as ModalStackNavigators from './ModalStackNavigators';
@@ -156,6 +157,8 @@ class AuthScreens extends React.Component {
// when displaying a modal. This allows us to dismiss by clicking outside on web / large screens.
isModal: true,
};
+ const url = getCurrentUrl();
+ const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : '';
return (
{
- const last = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies);
+const getInitialReportScreenParams = (reports, ignoreDefaultRooms, policies, openOnAdminRoom) => {
+ const last = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, openOnAdminRoom);
// Fallback to empty if for some reason reportID cannot be derived - prevents the app from crashing
const reportID = lodashGet(last, 'reportID', '');
@@ -61,7 +68,12 @@ class MainDrawerNavigator extends Component {
constructor(props) {
super(props);
this.trackAppStartTiming = this.trackAppStartTiming.bind(this);
- this.initialParams = getInitialReportScreenParams(props.reports, !Permissions.canUseDefaultRooms(props.betas), props.policies);
+ this.initialParams = getInitialReportScreenParams(
+ props.reports,
+ !Permissions.canUseDefaultRooms(props.betas),
+ props.policies,
+ lodashGet(props, 'route.params.openOnAdminRoom', false),
+ );
// When we have chat reports the moment this component got created
// we know that the data was served from storage/cache
@@ -69,7 +81,12 @@ class MainDrawerNavigator extends Component {
}
shouldComponentUpdate(nextProps) {
- const initialNextParams = getInitialReportScreenParams(nextProps.reports, !Permissions.canUseDefaultRooms(nextProps.betas), nextProps.policies);
+ const initialNextParams = getInitialReportScreenParams(
+ nextProps.reports,
+ !Permissions.canUseDefaultRooms(nextProps.betas),
+ nextProps.policies,
+ lodashGet(nextProps, 'route.params.openOnAdminRoom', false),
+ );
if (this.initialParams.reportID === initialNextParams.reportID) {
return false;
}
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index e95581674fab..607ee566c6cd 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -245,6 +245,34 @@ const SettingsModalStackNavigator = createModalStackNavigator([
},
name: 'Settings_Timezone_Select',
},
+ {
+ getComponent: () => {
+ const SettingsPersonalDetailsInitialPage = require('../../../pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage').default;
+ return SettingsPersonalDetailsInitialPage;
+ },
+ name: 'Settings_PersonalDetails_Initial',
+ },
+ {
+ getComponent: () => {
+ const SettingsLegalNamePage = require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default;
+ return SettingsLegalNamePage;
+ },
+ name: 'Settings_PersonalDetails_LegalName',
+ },
+ {
+ getComponent: () => {
+ const SettingsDateOfBirthPage = require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default;
+ return SettingsDateOfBirthPage;
+ },
+ name: 'Settings_PersonalDetails_DateOfBirth',
+ },
+ {
+ getComponent: () => {
+ const SettingsAddressPage = require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default;
+ return SettingsAddressPage;
+ },
+ name: 'Settings_PersonalDetails_Address',
+ },
{
getComponent: () => {
const SettingsAddSecondaryLoginPage = require('../../../pages/settings/AddSecondaryLoginPage').default;
@@ -254,11 +282,25 @@ const SettingsModalStackNavigator = createModalStackNavigator([
},
{
getComponent: () => {
- const SettingsPreferencesPage = require('../../../pages/settings/PreferencesPage').default;
+ const SettingsPreferencesPage = require('../../../pages/settings/Preferences/PreferencesPage').default;
return SettingsPreferencesPage;
},
name: 'Settings_Preferences',
},
+ {
+ getComponent: () => {
+ const SettingsPreferencesPriorityModePage = require('../../../pages/settings/Preferences/PriorityModePage').default;
+ return SettingsPreferencesPriorityModePage;
+ },
+ name: 'Settings_Preferences_PriorityMode',
+ },
+ {
+ getComponent: () => {
+ const SettingsPreferencesLanguagePage = require('../../../pages/settings/Preferences/LanguagePage').default;
+ return SettingsPreferencesLanguagePage;
+ },
+ name: 'Settings_Preferences_Language',
+ },
{
getComponent: () => {
const SettingsPasswordPage = require('../../../pages/settings/PasswordPage').default;
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index ea730bb45757..447fd481efab 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -21,6 +21,11 @@ const drawerIsReadyPromise = new Promise((resolve) => {
resolveDrawerIsReadyPromise = resolve;
});
+let resolveReportScreenIsReadyPromise;
+const reportScreenIsReadyPromise = new Promise((resolve) => {
+ resolveReportScreenIsReadyPromise = resolve;
+});
+
let isLoggedIn = false;
let pendingRoute = null;
let isNavigating = false;
@@ -267,6 +272,14 @@ function setIsDrawerReady() {
resolveDrawerIsReadyPromise();
}
+function isReportScreenReady() {
+ return reportScreenIsReadyPromise;
+}
+
+function setIsReportScreenIsReady() {
+ resolveReportScreenIsReadyPromise();
+}
+
export default {
canNavigate,
navigate,
@@ -284,6 +297,8 @@ export default {
setIsDrawerReady,
isDrawerRoute,
setIsNavigating,
+ isReportScreenReady,
+ setIsReportScreenIsReady,
};
export {
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index e9bbeb987d25..12b31245caf0 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -44,6 +44,14 @@ export default {
path: ROUTES.SETTINGS_PREFERENCES,
exact: true,
},
+ Settings_Preferences_PriorityMode: {
+ path: ROUTES.SETTINGS_PRIORITY_MODE,
+ exact: true,
+ },
+ Settings_Preferences_Language: {
+ path: ROUTES.SETTINGS_LANGUAGE,
+ exact: true,
+ },
Settings_Close: {
path: ROUTES.SETTINGS_CLOSE,
exact: true,
@@ -115,6 +123,22 @@ export default {
Settings_Add_Secondary_Login: {
path: ROUTES.SETTINGS_ADD_LOGIN,
},
+ Settings_PersonalDetails_Initial: {
+ path: ROUTES.SETTINGS_PERSONAL_DETAILS,
+ exact: true,
+ },
+ Settings_PersonalDetails_LegalName: {
+ path: ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME,
+ exact: true,
+ },
+ Settings_PersonalDetails_DateOfBirth: {
+ path: ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH,
+ exact: true,
+ },
+ Settings_PersonalDetails_Address: {
+ path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS,
+ exact: true,
+ },
Workspace_Initial: {
path: ROUTES.WORKSPACE_INITIAL,
},
diff --git a/src/libs/Notification/PushNotification/index.js b/src/libs/Notification/PushNotification/index.js
index c4100583442d..88136ff5dc72 100644
--- a/src/libs/Notification/PushNotification/index.js
+++ b/src/libs/Notification/PushNotification/index.js
@@ -2,6 +2,7 @@ import NotificationType from './NotificationType';
// Push notifications are only supported on mobile, so we'll just noop here
export default {
+ init: () => {},
register: () => {},
deregister: () => {},
onReceived: () => {},
diff --git a/src/libs/Notification/PushNotification/index.native.js b/src/libs/Notification/PushNotification/index.native.js
index 4dc3ad1c3a8e..b6787858191d 100644
--- a/src/libs/Notification/PushNotification/index.native.js
+++ b/src/libs/Notification/PushNotification/index.native.js
@@ -4,8 +4,6 @@ import {UrbanAirship, EventType, iOS} from 'urbanairship-react-native';
import lodashGet from 'lodash/get';
import Log from '../../Log';
import NotificationType from './NotificationType';
-import PushNotification from '.';
-import * as Report from '../../actions/Report';
const notificationEventActionMap = {};
@@ -109,12 +107,6 @@ function register(accountID) {
// Regardless of the user's opt-in status, we still want to receive silent push notifications.
Log.info(`[PUSH_NOTIFICATIONS] Subscribing to notifications for account ID ${accountID}`);
UrbanAirship.setNamedUser(accountID.toString());
-
- // When the user logged out and then logged in with a different account
- // while the app is still in background, we must resubscribe to the report
- // push notification in order to render the report click behaviour correctly
- PushNotification.init();
- Report.subscribeToReportCommentPushNotifications();
}
/**
diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js
index 905b29c99cf4..68f1a493f158 100644
--- a/src/libs/Permissions.js
+++ b/src/libs/Permissions.js
@@ -43,14 +43,6 @@ function canUseDefaultRooms(betas) {
return _.contains(betas, CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas);
}
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseInternationalization(betas) {
- return _.contains(betas, CONST.BETAS.INTERNATIONALIZATION) || canUseAllBetas(betas);
-}
-
/**
* @param {Array} betas
* @returns {Boolean}
@@ -107,7 +99,6 @@ export default {
canUseIOU,
canUsePayWithExpensify,
canUseDefaultRooms,
- canUseInternationalization,
canUseIOUSend,
canUseWallet,
canUseCommentLinking,
diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js
index bb67b2fb39b6..f260bbc4c58d 100644
--- a/src/libs/ReportActionsUtils.js
+++ b/src/libs/ReportActionsUtils.js
@@ -7,7 +7,6 @@ import moment from 'moment';
import * as CollectionUtils from './CollectionUtils';
import CONST from '../CONST';
import ONYXKEYS from '../ONYXKEYS';
-import * as ReportUtils from './ReportUtils';
const allReportActions = {};
Onyx.connect({
@@ -170,7 +169,9 @@ function getLastVisibleMessageText(reportID, actionsToMerge = {}) {
const parser = new ExpensiMark();
const messageText = parser.htmlToText(htmlText);
- return ReportUtils.formatReportLastMessageText(messageText);
+ return String(messageText)
+ .replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '')
+ .substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH);
}
export {
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index 51a28759d45c..0a4fdcdebf87 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -3,6 +3,7 @@ import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
+import {InteractionManager} from 'react-native';
import ONYXKEYS from '../ONYXKEYS';
import CONST from '../CONST';
import * as Localize from './Localize';
@@ -13,8 +14,10 @@ import Navigation from './Navigation/Navigation';
import ROUTES from '../ROUTES';
import * as NumberUtils from './NumberUtils';
import * as NumberFormatUtils from './NumberFormatUtils';
+import * as ReportActionsUtils from './ReportActionsUtils';
import Permissions from './Permissions';
import DateUtils from './DateUtils';
+import linkingConfig from './Navigation/linkingConfig';
import * as defaultAvatars from '../components/Icon/DefaultAvatars';
let sessionEmail;
@@ -70,7 +73,7 @@ let doesDomainHaveApprovedAccountant;
Onyx.connect({
key: ONYXKEYS.ACCOUNT,
waitForCollectionCallback: true,
- callback: val => doesDomainHaveApprovedAccountant = val.doesDomainHaveApprovedAccountant,
+ callback: val => doesDomainHaveApprovedAccountant = lodashGet(val, 'doesDomainHaveApprovedAccountant', false),
});
function getChatType(report) {
@@ -224,6 +227,15 @@ function isChatRoom(report) {
return isUserCreatedPolicyRoom(report) || isDefaultRoom(report);
}
+/**
+ * Whether the provided report is a direct message
+ * @param {Object} report
+ * @returns {Boolean}
+ */
+function isDirectMessage(report) {
+ return _.isEmpty(getChatType(report));
+}
+
/**
* Get the policy type from a given report
* @param {Object} report
@@ -248,9 +260,10 @@ function hasExpensifyGuidesEmails(emails) {
* @param {Record|Array<{lastReadTime, reportID}>} reports
* @param {Boolean} [ignoreDefaultRooms]
* @param {Object} policies
+ * @param {Boolean} openOnAdminRoom
* @returns {Object}
*/
-function findLastAccessedReport(reports, ignoreDefaultRooms, policies) {
+function findLastAccessedReport(reports, ignoreDefaultRooms, policies, openOnAdminRoom = false) {
let sortedReports = sortReportsByLastRead(reports);
if (ignoreDefaultRooms) {
@@ -259,7 +272,15 @@ function findLastAccessedReport(reports, ignoreDefaultRooms, policies) {
|| hasExpensifyGuidesEmails(lodashGet(report, ['participants'], [])));
}
- return _.last(sortedReports);
+ let adminReport;
+ if (!ignoreDefaultRooms && openOnAdminRoom) {
+ adminReport = _.find(sortedReports, (report) => {
+ const chatType = getChatType(report);
+ return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS;
+ });
+ }
+
+ return adminReport || _.last(sortedReports);
}
/**
@@ -643,6 +664,39 @@ function getDisplayNamesWithTooltips(participants, isMultipleParticipantReport)
});
}
+/**
+ * Get the title for a policy expense chat which depends on the role of the policy member seeing this report
+ *
+ * @param {Object} report
+ * @param {Object} [policies]
+ * @returns {String}
+ */
+function getPolicyExpenseChatName(report, policies = {}) {
+ const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerEmail) || report.ownerEmail || report.reportName;
+
+ // If the policy expense chat is owned by this user, use the name of the policy as the report name.
+ if (report.isOwnPolicyExpenseChat) {
+ return getPolicyName(report, policies);
+ }
+
+ const policyExpenseChatRole = lodashGet(policies, [
+ `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role',
+ ]) || 'user';
+
+ // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat
+ // of the account which was merged into the current user's account. Use the name of the policy as the name of the report.
+ if (isArchivedRoom(report)) {
+ const lastAction = ReportActionsUtils.getLastVisibleAction(report.reportID);
+ const archiveReason = (lastAction && lastAction.originalMessage && lastAction.originalMessage.reason) || CONST.REPORT.ARCHIVE_REASON.DEFAULT;
+ if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED && policyExpenseChatRole !== CONST.POLICY.ROLE.ADMIN) {
+ return getPolicyName(report, policies);
+ }
+ }
+
+ // If user can see this report and they are not its owner, they must be an admin and the report name should be the name of the policy member
+ return reportOwnerDisplayName;
+}
+
/**
* Get the title for a report.
*
@@ -657,8 +711,7 @@ function getReportName(report, policies = {}) {
}
if (isPolicyExpenseChat(report)) {
- const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerEmail) || report.ownerEmail || report.reportName;
- formattedName = report.isOwnPolicyExpenseChat ? getPolicyName(report, policies) : reportOwnerDisplayName;
+ formattedName = getPolicyExpenseChatName(report, policies);
}
if (isArchivedRoom(report)) {
@@ -974,7 +1027,7 @@ function buildOptimisticChatReport(
lastActionCreated: currentTime,
notificationPreference,
oldPolicyName,
- ownerEmail,
+ ownerEmail: ownerEmail || CONST.REPORT.OWNER_EMAIL_FAKE,
participants: participantList,
policyID,
reportID: generateReportID(),
@@ -1313,6 +1366,11 @@ function shouldReportBeInOptionList(report, reportIDFromRoute, isInGSDMode, curr
return false;
}
+ // Exclude direct message chats that don't have any chat history
+ if (isDirectMessage(report) && report.maxSequenceNumber === 1) {
+ return false;
+ }
+
return true;
}
@@ -1371,6 +1429,66 @@ function getNewMarkerReportActionID(report, sortedAndFilteredReportActions) {
: '';
}
+/**
+ * Replace code points > 127 with C escape sequences, and return the resulting string's overall length
+ * Used for compatibility with the backend auth validator for AddComment
+ * @param {String} textComment
+ * @returns {Number}
+ */
+function getCommentLength(textComment) {
+ return textComment.replace(/[^ -~]/g, '\\u????').length;
+}
+
+/**
+ * @param {String|null} url
+ * @returns {String}
+ */
+function getReportIDFromDeepLink(url) {
+ if (!url) {
+ return '';
+ }
+
+ // Get the reportID from URL
+ let route = url;
+ _.each(linkingConfig.prefixes, (prefix) => {
+ const localWebAndroidRegEx = /^(http:\/\/([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3}))/;
+ if (route.startsWith(prefix)) {
+ route = route.replace(prefix, '');
+ } else if (localWebAndroidRegEx.test(route)) {
+ route = route.replace(localWebAndroidRegEx, '');
+ } else {
+ return;
+ }
+
+ // Remove the port if it's a localhost URL
+ if (/^:\d+/.test(route)) {
+ route = route.replace(/:\d+/, '');
+ }
+
+ // Remove the leading slash if exists
+ if (route.startsWith('/')) {
+ route = route.replace('/', '');
+ }
+ });
+ const {reportID} = ROUTES.parseReportRouteParams(route);
+ return reportID;
+}
+
+/**
+ * @param {String|null} url
+ */
+function openReportFromDeepLink(url) {
+ const reportID = getReportIDFromDeepLink(url);
+ if (!reportID) {
+ return;
+ }
+ InteractionManager.runAfterInteractions(() => {
+ Navigation.isReportScreenReady().then(() => {
+ Navigation.navigate(ROUTES.getReportRoute(reportID));
+ });
+ });
+}
+
export {
getReportParticipantsTitle,
isReportMessageAttachment,
@@ -1402,6 +1520,7 @@ export {
getRoomWelcomeMessage,
getDisplayNamesWithTooltips,
getReportName,
+ getReportIDFromDeepLink,
navigateToDetailsPage,
generateReportID,
hasReportNameError,
@@ -1425,4 +1544,6 @@ export {
getOldDotDefaultAvatar,
getNewMarkerReportActionID,
canSeeDefaultRoom,
+ getCommentLength,
+ openReportFromDeepLink,
};
diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js
index f287c821f73b..ee8cd145f055 100644
--- a/src/libs/ValidationUtils.js
+++ b/src/libs/ValidationUtils.js
@@ -206,6 +206,30 @@ function meetsAgeRequirements(date) {
return testDate.isValid() && testDate.isBetween(oneHundredFiftyYearsAgo, eighteenYearsAgo);
}
+/**
+ * Validate that given date is in a specified range of years before now.
+ *
+ * @param {String} date
+ * @param {Number} minimumAge
+ * @param {Number} maximumAge
+ * @returns {String}
+ */
+function getAgeRequirementError(date, minimumAge, maximumAge) {
+ const recentDate = moment().subtract(minimumAge, 'years');
+ const longAgoDate = moment().subtract(maximumAge, 'years');
+ const testDate = moment(date);
+ if (!testDate.isValid()) {
+ return Localize.translateLocal('common.error.dateInvalid');
+ }
+ if (testDate.isBetween(longAgoDate, recentDate)) {
+ return '';
+ }
+ if (testDate.isAfter(recentDate)) {
+ return Localize.translateLocal('privatePersonalDetails.error.dateShouldBeBefore', {dateString: recentDate.format(CONST.DATE.MOMENT_FORMAT_STRING)});
+ }
+ return Localize.translateLocal('privatePersonalDetails.error.dateShouldBeAfter', {dateString: longAgoDate.format(CONST.DATE.MOMENT_FORMAT_STRING)});
+}
+
/**
* Similar to backend, checks whether a website has a valid URL or not.
* http/https/ftp URL scheme required.
@@ -333,46 +357,25 @@ function isValidRoutingNumber(number) {
}
/**
- * Checks if each string in array is of valid length and then returns true
- * for each string which exceeds the limit.
+ * Checks that the provided name doesn't contain any commas or semicolons
*
- * @param {Number} maxLength
- * @param {String[]} valuesToBeValidated
- * @returns {Boolean[]}
- */
-function doesFailCharacterLimit(maxLength, valuesToBeValidated) {
- return _.map(valuesToBeValidated, value => value && value.length > maxLength);
-}
-
-/**
- * Checks if each string in array is of valid length and then returns true
- * for each string which exceeds the limit. The function trims the passed values.
- *
- * @param {Number} maxLength
- * @param {String[]} valuesToBeValidated
- * @returns {Boolean[]}
+ * @param {String} name
+ * @returns {Boolean}
*/
-function doesFailCharacterLimitAfterTrim(maxLength, valuesToBeValidated) {
- return _.map(valuesToBeValidated, value => value && value.trim().length > maxLength);
+function isValidDisplayName(name) {
+ return !name.includes(',') && !name.includes(';');
}
/**
- * Checks if input value includes comma or semicolon which are not accepted
+ * Checks if the provided string includes any of the provided reserved words
*
- * @param {String[]} valuesToBeValidated
- * @returns {String[]}
+ * @param {String} value
+ * @param {String[]} reservedWords
+ * @returns {Boolean}
*/
-function findInvalidSymbols(valuesToBeValidated) {
- return _.map(valuesToBeValidated, (value) => {
- if (!value) {
- return '';
- }
- let inValidSymbol = value.replace(/[,]+/g, '') !== value ? Localize.translateLocal('personalDetails.error.comma') : '';
- if (_.isEmpty(inValidSymbol)) {
- inValidSymbol = value.replace(/[;]+/g, '') !== value ? Localize.translateLocal('personalDetails.error.semicolon') : '';
- }
- return inValidSymbol;
- });
+function doesContainReservedWord(value, reservedWords) {
+ const valueToCheck = value.trim().toLowerCase();
+ return _.some(reservedWords, reservedWord => valueToCheck.includes(reservedWord.toLowerCase()));
}
/**
@@ -427,6 +430,7 @@ function isValidTaxID(taxID) {
export {
meetsAgeRequirements,
+ getAgeRequirementError,
isValidAddress,
isValidDate,
isValidCardName,
@@ -448,12 +452,11 @@ export {
isValidRoutingNumber,
isValidSSNLastFour,
isValidSSNFullNine,
- doesFailCharacterLimit,
- doesFailCharacterLimitAfterTrim,
isReservedRoomName,
isExistingRoomName,
isValidRoomName,
isValidTaxID,
isValidValidateCode,
- findInvalidSymbols,
+ isValidDisplayName,
+ doesContainReservedWord,
};
diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js
index b405ae82aa49..b3d05bed7f6a 100644
--- a/src/libs/actions/App.js
+++ b/src/libs/actions/App.js
@@ -52,6 +52,12 @@ Onyx.connect({
callback: policies => allPolicies = policies,
});
+let preferredLocale;
+Onyx.connect({
+ key: ONYXKEYS.NVP_PREFERRED_LOCALE,
+ callback: val => preferredLocale = val,
+});
+
/**
* @param {Array} policies
* @return {Array} array of policy ids
@@ -68,6 +74,10 @@ function getNonOptimisticPolicyIDs(policies) {
* @param {String} locale
*/
function setLocale(locale) {
+ if (locale === preferredLocale) {
+ return;
+ }
+
// If user is not signed in, change just locally.
if (!currentUserAccountID) {
Onyx.merge(ONYXKEYS.NVP_PREFERRED_LOCALE, locale);
@@ -88,6 +98,14 @@ function setLocale(locale) {
}, {optimisticData});
}
+/**
+* @param {String} locale
+*/
+function setLocaleAndNavigate(locale) {
+ setLocale(locale);
+ Navigation.navigate(ROUTES.SETTINGS_PREFERENCES);
+}
+
function setSidebarLoaded() {
if (isSidebarLoaded) {
return;
@@ -270,6 +288,7 @@ function openProfile() {
export {
setLocale,
+ setLocaleAndNavigate,
setSidebarLoaded,
setUpPoliciesAndNavigate,
openProfile,
diff --git a/src/libs/actions/AppUpdate.js b/src/libs/actions/AppUpdate.js
index 1ba2f5fb8384..c198c0e1adbb 100644
--- a/src/libs/actions/AppUpdate.js
+++ b/src/libs/actions/AppUpdate.js
@@ -5,7 +5,14 @@ function triggerUpdateAvailable() {
Onyx.set(ONYXKEYS.UPDATE_AVAILABLE, true);
}
+/**
+ * @param {Boolean} isBeta
+ */
+function setIsAppInBeta(isBeta) {
+ Onyx.set(ONYXKEYS.IS_BETA, isBeta);
+}
+
export {
- // eslint-disable-next-line import/prefer-default-export
triggerUpdateAvailable,
+ setIsAppInBeta,
};
diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js
index 57443eb54e9d..9220b851cc42 100644
--- a/src/libs/actions/PersonalDetails.js
+++ b/src/libs/actions/PersonalDetails.js
@@ -173,6 +173,74 @@ function updateDisplayName(firstName, lastName) {
Navigation.navigate(ROUTES.SETTINGS_PROFILE);
}
+/**
+ * @param {String} legalFirstName
+ * @param {String} legalLastName
+ */
+function updateLegalName(legalFirstName, legalLastName) {
+ API.write('UpdateLegalName', {legalFirstName, legalLastName}, {
+ optimisticData: [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ value: {
+ legalFirstName,
+ legalLastName,
+ },
+ }],
+ });
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS);
+}
+
+/**
+ * @param {String} dob - date of birth
+ */
+function updateDateOfBirth(dob) {
+ API.write('UpdateDateOfBirth', {dob}, {
+ optimisticData: [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ value: {
+ dob,
+ },
+ }],
+ });
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS);
+}
+
+/**
+ * @param {String} street
+ * @param {String} street2
+ * @param {String} city
+ * @param {String} state
+ * @param {String} zip
+ * @param {String} country
+ */
+function updateAddress(street, street2, city, state, zip, country) {
+ API.write('UpdateHomeAddress', {
+ addressStreet: street,
+ addressStreet2: street2,
+ addressCity: city,
+ addressState: state,
+ addressZipCode: zip,
+ addressCountry: country,
+ }, {
+ optimisticData: [{
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ value: {
+ address: {
+ street: `${street}\n${street2}`,
+ city,
+ state,
+ zip,
+ country,
+ },
+ },
+ }],
+ });
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS);
+}
+
/**
* Updates timezone's 'automatic' setting, and updates
* selected timezone if set to automatically update.
@@ -230,6 +298,13 @@ function openIOUModalPage() {
API.read('OpenIOUModalPage');
}
+/**
+ * Fetches additional personal data like legal name, date of birth, address
+ */
+function openPersonalDetailsPage() {
+ API.read('OpenPersonalDetailsPage');
+}
+
/**
* Updates the user's avatar image
*
@@ -331,8 +406,12 @@ export {
updateAvatar,
deleteAvatar,
openIOUModalPage,
+ openPersonalDetailsPage,
extractFirstAndLastNameFromAvailableDetails,
updateDisplayName,
+ updateLegalName,
+ updateDateOfBirth,
+ updateAddress,
updatePronouns,
clearAvatarErrors,
updateAutomaticTimezone,
diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js
index 4255b79d5d22..94799fa6b3c6 100644
--- a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js
+++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js
@@ -25,7 +25,7 @@ function resetFreePlanBankAccount(bankAccountID) {
{
optimisticData: [
{
- onyxMethod: 'set',
+ onyxMethod: CONST.ONYX.METHOD.SET,
key: ONYXKEYS.ONFIDO_TOKEN,
value: '',
},
@@ -44,12 +44,31 @@ function resetFreePlanBankAccount(bankAccountID) {
key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
value: ReimbursementAccountProps.reimbursementAccountDefaultProps,
},
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {isLoading: true},
+ },
{
onyxMethod: CONST.ONYX.METHOD.SET,
key: ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT,
value: {},
},
],
+ successData: [
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {isLoading: false},
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {isLoading: false},
+ },
+ ],
});
}
diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js
index bcf3f0919de2..00539f2591ac 100644
--- a/src/libs/actions/Session/index.js
+++ b/src/libs/actions/Session/index.js
@@ -15,7 +15,10 @@ import * as Authentication from '../../Authentication';
import * as Welcome from '../Welcome';
import * as API from '../../API';
import * as NetworkStore from '../../Network/NetworkStore';
+import * as Report from '../Report';
import DateUtils from '../../DateUtils';
+import Navigation from '../../Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
let credentials = {};
Onyx.connect({
@@ -40,6 +43,10 @@ Onyx.connect({
if (accountID) {
PushNotification.register(accountID);
+
+ // Prevent issue where report linking fails after users switch accounts without closing the app
+ PushNotification.init();
+ Report.subscribeToReportCommentPushNotifications();
} else {
PushNotification.deregister();
PushNotification.clearNotifications();
@@ -133,6 +140,13 @@ function beginSignIn(login) {
isLoading: false,
},
},
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.CREDENTIALS,
+ value: {
+ validateCode: null,
+ },
+ },
];
const failureData = [
@@ -237,8 +251,16 @@ function signIn(password, validateCode, twoFactorAuthCode) {
},
];
+ const params = {twoFactorAuthCode};
+ if (credentials.login) {
+ // The user initiated the sign in operation on the current device, sign in with the email
+ params.email = credentials.login;
+ } else {
+ // The user is signing in with the accountID and validateCode from the magic link
+ params.accountID = credentials.accountID;
+ }
+
// Conditionally pass a password or validateCode to command since we temporarily allow both flows
- const params = {email: credentials.login, twoFactorAuthCode};
if (validateCode) {
params.validateCode = validateCode;
} else {
@@ -248,6 +270,58 @@ function signIn(password, validateCode, twoFactorAuthCode) {
API.write('SigninUser', params, {optimisticData, successData, failureData});
}
+function signInWithValidateCode(accountID, validateCode) {
+ const optimisticData = [
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ ...CONST.DEFAULT_ACCOUNT_DATA,
+ isLoading: true,
+ },
+ },
+ ];
+
+ const successData = [
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.CREDENTIALS,
+ value: {
+ accountID,
+ validateCode,
+ },
+ },
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ // This is temporary for now. Server should login with the accountID and validateCode
+ API.write('SigninUser', {
+ validateCode,
+ accountID,
+ }, {optimisticData, successData, failureData});
+}
+
+function signInWithValidateCodeAndNavigate(accountID, validateCode) {
+ signInWithValidateCode(accountID, validateCode);
+ Navigation.navigate(ROUTES.HOME);
+}
+
/**
* User forgot the password so let's send them the link to reset their password
*/
@@ -466,6 +540,8 @@ export {
beginSignIn,
updatePasswordAndSignin,
signIn,
+ signInWithValidateCode,
+ signInWithValidateCodeAndNavigate,
signInWithShortLivedAuthToken,
cleanupSession,
signOut,
diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js
index dd23484176ec..a74a7350e06a 100644
--- a/src/libs/actions/User.js
+++ b/src/libs/actions/User.js
@@ -402,6 +402,7 @@ function updateChatPriorityMode(mode) {
API.write('UpdateChatPriorityMode', {
value: mode,
}, {optimisticData});
+ Navigation.navigate(ROUTES.SETTINGS_PREFERENCES);
}
/**
diff --git a/src/libs/actions/Welcome.js b/src/libs/actions/Welcome.js
index 58a760a50f63..d3795ad825a3 100644
--- a/src/libs/actions/Welcome.js
+++ b/src/libs/actions/Welcome.js
@@ -111,10 +111,11 @@ function show({routes, showCreateMenu}) {
// If we are rendering the SidebarScreen at the same time as a workspace route that means we've already created a workspace via workspace/new and should not open the global
// create menu right now. We should also stay on the workspace page if that is our destination.
- const topRouteName = lodashGet(_.last(routes), 'name', '');
+ const topRoute = _.last(routes);
+ const isWorkspaceRoute = topRoute.name === 'Settings' && topRoute.params.path.includes('workspace');
const transitionRoute = _.find(routes, route => route.name === SCREENS.TRANSITION_FROM_OLD_DOT);
const exitingToWorkspaceRoute = lodashGet(transitionRoute, 'params.exitTo', '') === 'workspace/new';
- const isDisplayingWorkspaceRoute = topRouteName.toLowerCase().includes('workspace') || exitingToWorkspaceRoute;
+ const isDisplayingWorkspaceRoute = isWorkspaceRoute || exitingToWorkspaceRoute;
// We want to display the Workspace chat first since that means a user is already in a Workspace and doesn't need to create another one
const workspaceChatReport = _.find(allReports, report => ReportUtils.isPolicyExpenseChat(report) && report.ownerEmail === email);
diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js
index 1c0294881360..1ede56c65ca7 100755
--- a/src/pages/NewChatPage.js
+++ b/src/pages/NewChatPage.js
@@ -260,6 +260,7 @@ class NewChatPage extends Component {
confirmButtonText={this.props.translate('newChatPage.createGroup')}
onConfirmSelection={this.createGroup}
placeholderText={this.props.translate('optionsSelector.nameEmailOrPhoneNumber')}
+ safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
/>
) : (
diff --git a/src/pages/ReimbursementAccount/CompanyStep.js b/src/pages/ReimbursementAccount/CompanyStep.js
index a666631d3738..16a550c0080f 100644
--- a/src/pages/ReimbursementAccount/CompanyStep.js
+++ b/src/pages/ReimbursementAccount/CompanyStep.js
@@ -103,7 +103,7 @@ class CompanyStep extends React.Component {
}
if (!values.incorporationDate || !ValidationUtils.isValidDate(values.incorporationDate)) {
- errors.incorporationDate = this.props.translate('bankAccount.error.incorporationDate');
+ errors.incorporationDate = this.props.translate('common.error.dateInvalid');
} else if (!values.incorporationDate || !ValidationUtils.isValidPastDate(values.incorporationDate)) {
errors.incorporationDate = this.props.translate('bankAccount.error.incorporationDateFuture');
}
@@ -200,6 +200,7 @@ class CompanyStep extends React.Component {
defaultValue={this.props.getDefaultStateForField('website', this.defaultWebsite)}
shouldSaveDraft
hint={this.props.translate('common.websiteExample')}
+ keyboardType={CONST.KEYBOARD_TYPE.URL}
/>
(
BankAccounts.requestResetFreePlanBankAccount()}
shouldShowRightIcon
wrapperStyle={[styles.cardMenuItem]}
/>
+
+ {props.reimbursementAccount.shouldShowResetModal && (
+
+ )}
);
diff --git a/src/pages/ReimbursementAccount/EnableStep.js b/src/pages/ReimbursementAccount/EnableStep.js
index 329f8c61cc9d..b8b7dd1a029f 100644
--- a/src/pages/ReimbursementAccount/EnableStep.js
+++ b/src/pages/ReimbursementAccount/EnableStep.js
@@ -22,6 +22,7 @@ import * as Link from '../../libs/actions/Link';
import * as User from '../../libs/actions/User';
import ScreenWrapper from '../../components/ScreenWrapper';
import * as BankAccounts from '../../libs/actions/ReimbursementAccount';
+import WorkspaceResetBankAccountModal from '../workspace/WorkspaceResetBankAccountModal';
const propTypes = {
/** Bank account currently in setup */
@@ -102,6 +103,11 @@ const EnableStep = (props) => {
)}
+ {props.reimbursementAccount.shouldShowResetModal && (
+
+ )}
);
};
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
index 193bf2c9a46b..4337292048bb 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
@@ -29,7 +29,6 @@ import EnableStep from './EnableStep';
import ROUTES from '../../ROUTES';
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
import * as ReimbursementAccountProps from './reimbursementAccountPropTypes';
-import WorkspaceResetBankAccountModal from '../workspace/WorkspaceResetBankAccountModal';
import reimbursementAccountDraftPropTypes from './ReimbursementAccountDraftPropTypes';
import * as ReimbursementAccountUtils from '../../libs/ReimbursementAccountUtils';
@@ -263,13 +262,8 @@ class ReimbursementAccountPage extends React.Component {
);
}
- if (this.props.reimbursementAccount.shouldShowResetModal && Boolean(achData.bankAccountID)) {
- return (
-
- );
- }
-
// Show the "Continue with setup" button if a bank account setup is already in progress and no specific further step was passed in the url
+ // We'll show the workspace bank account reset modal if the user wishes to start over
if (!this.state.shouldHideContinueSetupButton
&& Boolean(achData.bankAccountID)
&& achData.state !== BankAccount.STATE.OPEN
@@ -280,11 +274,9 @@ class ReimbursementAccountPage extends React.Component {
)) {
return (
{
- this.setState({shouldHideContinueSetupButton: true});
- BankAccounts.requestResetFreePlanBankAccount();
- }}
+ startOver={() => this.setState({shouldHideContinueSetupButton: true})}
/>
);
}
diff --git a/src/pages/ReimbursementAccount/ValidationStep.js b/src/pages/ReimbursementAccount/ValidationStep.js
index ea3d5a93c115..077ee7544029 100644
--- a/src/pages/ReimbursementAccount/ValidationStep.js
+++ b/src/pages/ReimbursementAccount/ValidationStep.js
@@ -27,7 +27,9 @@ import Section from '../../components/Section';
import CONST from '../../CONST';
import Button from '../../components/Button';
import MenuItem from '../../components/MenuItem';
+import WorkspaceResetBankAccountModal from '../workspace/WorkspaceResetBankAccountModal';
import Enable2FAPrompt from './Enable2FAPrompt';
+import ScreenWrapper from '../../components/ScreenWrapper';
const propTypes = {
...withLocalizePropTypes,
@@ -102,7 +104,7 @@ class ValidationStep extends React.Component {
* @returns {String}
*/
filterInput(amount) {
- let value = amount ? amount.trim() : '';
+ let value = amount ? amount.toString().trim() : '';
if (value === '' || !Math.abs(Str.fromUSDToNumber(value)) || _.isNaN(Number(value))) {
return '';
}
@@ -128,7 +130,7 @@ class ValidationStep extends React.Component {
const requiresTwoFactorAuth = lodashGet(this.props, 'account.requiresTwoFactorAuth');
return (
-
+
+ {this.props.reimbursementAccount.shouldShowResetModal && (
+
+ )}
{!requiresTwoFactorAuth && (
)}
)}
-
+
);
}
}
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index b97b31e7e5de..688b691fb62c 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -23,6 +23,7 @@ import Text from '../components/Text';
import CONST from '../CONST';
import reportPropTypes from './reportPropTypes';
import withReportOrNavigateHome from './home/report/withReportOrNavigateHome';
+import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView';
const propTypes = {
...withLocalizePropTypes,
@@ -109,66 +110,68 @@ class ReportDetailsPage extends Component {
const menuItems = this.getMenuItems();
return (
- Navigation.goBack()}
- onCloseButtonPress={() => Navigation.dismissModal()}
- />
-
-
-
-
-
-
-
-
-
+ Navigation.goBack()}
+ onCloseButtonPress={() => Navigation.dismissModal()}
+ />
+
+
+
+
+
-
- {chatRoomSubtitle}
-
+
+
+
+
+
+ {chatRoomSubtitle}
+
+
-
- {_.map(menuItems, (item) => {
- const brickRoadIndicator = (
- ReportUtils.hasReportNameError(this.props.report)
- && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS
- )
- ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR
- : '';
- return (
-
- );
- })}
-
+ {_.map(menuItems, (item) => {
+ const brickRoadIndicator = (
+ ReportUtils.hasReportNameError(this.props.report)
+ && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS
+ )
+ ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR
+ : '';
+ return (
+
+ );
+ })}
+
+
);
}
diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js
index e91667d4e908..07c4d62b448a 100755
--- a/src/pages/ReportParticipantsPage.js
+++ b/src/pages/ReportParticipantsPage.js
@@ -20,6 +20,7 @@ import compose from '../libs/compose';
import * as ReportUtils from '../libs/ReportUtils';
import reportPropTypes from './reportPropTypes';
import withReportOrNavigateHome from './home/report/withReportOrNavigateHome';
+import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView';
const propTypes = {
/* Onyx Props */
@@ -72,38 +73,42 @@ const ReportParticipantsPage = (props) => {
const participants = getAllParticipants(props.report, props.personalDetails);
return (
-
-
-
- {Boolean(participants.length)
- && (
- {
- Navigation.navigate(ROUTES.getReportParticipantRoute(
- props.route.params.reportID, option.login,
- ));
- }}
- hideSectionHeaders
- showTitleTooltip
- disableFocusOptions
- boldStyle
- optionHoveredStyle={styles.hoveredComponentBG}
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
- )}
-
+
+ {Boolean(participants.length) && (
+ {
+ Navigation.navigate(ROUTES.getReportParticipantRoute(
+ props.route.params.reportID, option.login,
+ ));
+ }}
+ hideSectionHeaders
+ showTitleTooltip
+ disableFocusOptions
+ boldStyle
+ optionHoveredStyle={styles.hoveredComponentBG}
+ contentContainerStyles={[safeAreaPaddingBottomStyle]}
+ />
+ )}
+
+
+ )}
);
};
diff --git a/src/pages/ReportSettingsPage.js b/src/pages/ReportSettingsPage.js
index 1ff79c3b98a2..86fa8b6cec16 100644
--- a/src/pages/ReportSettingsPage.js
+++ b/src/pages/ReportSettingsPage.js
@@ -22,6 +22,7 @@ import OfflineWithFeedback from '../components/OfflineWithFeedback';
import reportPropTypes from './reportPropTypes';
import withReportOrNavigateHome from './home/report/withReportOrNavigateHome';
import Form from '../components/Form';
+import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView';
const propTypes = {
/** Route params */
@@ -54,7 +55,6 @@ class ReportSettingsPage extends Component {
super(props);
this.validate = this.validate.bind(this);
- this.updatePolicyRoomName = this.updatePolicyRoomName.bind(this);
}
getNotificationPreferenceOptions() {
@@ -110,106 +110,107 @@ class ReportSettingsPage extends Component {
render() {
const shouldShowRoomName = !ReportUtils.isPolicyExpenseChat(this.props.report);
- const shouldDisableRename = ReportUtils.isDefaultRoom(this.props.report)
- || ReportUtils.isArchivedRoom(this.props.report);
+ const shouldDisableRename = ReportUtils.isDefaultRoom(this.props.report) || ReportUtils.isArchivedRoom(this.props.report);
const linkedWorkspace = _.find(this.props.policies, policy => policy && policy.id === this.props.report.policyID);
return (
-
-
+
+
+ )}
+ {linkedWorkspace && (
+
+
+ {this.props.translate('workspace.common.workspace')}
+
+
+ {linkedWorkspace.name}
+
+
+ )}
+ {this.props.report.visibility && (
+
+
+ {this.props.translate('newRoomPage.visibility')}
+
+
+ {this.props.translate(`newRoomPage.visibilityOptions.${this.props.report.visibility}`)}
+
+
+ {
+ this.props.report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED
+ ? this.props.translate('newRoomPage.restrictedDescription')
+ : this.props.translate('newRoomPage.privateDescription')
+ }
+
+
+ )}
+
+
);
}
diff --git a/src/pages/RequestCallPage.js b/src/pages/RequestCallPage.js
index 4bcd38d97410..91b6d1e5e0e8 100644
--- a/src/pages/RequestCallPage.js
+++ b/src/pages/RequestCallPage.js
@@ -219,16 +219,6 @@ class RequestCallPage extends Component {
errors.lastName = this.props.translate('requestCallPage.error.lastName');
}
- const [firstNameLengthError, lastNameLengthError] = ValidationUtils.doesFailCharacterLimit(50, [values.firstName, values.lastName]);
-
- if (firstNameLengthError) {
- errors.firstName = this.props.translate('requestCallPage.error.firstNameLength');
- }
-
- if (lastNameLengthError) {
- errors.lastName = this.props.translate('requestCallPage.error.lastNameLength');
- }
-
const phoneNumber = LoginUtils.getPhoneNumberWithoutSpecialChars(values.phoneNumber);
if (_.isEmpty(values.phoneNumber.trim()) || !Str.isValidPhone(phoneNumber)) {
errors.phoneNumber = this.props.translate('common.error.phoneNumber');
@@ -285,6 +275,7 @@ class RequestCallPage extends Component {
name="fname"
placeholder={this.props.translate('profilePage.john')}
containerStyles={[styles.mt4]}
+ maxLength={CONST.DISPLAY_NAME.MAX_LENGTH}
/>
- {({didScreenTransitionEnd}) => (
+ {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => (
<>
>
diff --git a/src/pages/SetPasswordPage.js b/src/pages/SetPasswordPage.js
index 7a75677c7486..32d25f38fa56 100755
--- a/src/pages/SetPasswordPage.js
+++ b/src/pages/SetPasswordPage.js
@@ -10,7 +10,7 @@ import lodashGet from 'lodash/get';
import {
propTypes as validateLinkPropTypes,
defaultProps as validateLinkDefaultProps,
-} from './validateLinkPropTypes';
+} from './ValidateLoginPage/validateLinkPropTypes';
import styles from '../styles/styles';
import * as Session from '../libs/actions/Session';
import ONYXKEYS from '../ONYXKEYS';
diff --git a/src/pages/ValidateLoginPage.js b/src/pages/ValidateLoginPage.js
deleted file mode 100644
index ffa1d295225b..000000000000
--- a/src/pages/ValidateLoginPage.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React, {Component} from 'react';
-import lodashGet from 'lodash/get';
-import {
- propTypes as validateLinkPropTypes,
- defaultProps as validateLinkDefaultProps,
-} from './validateLinkPropTypes';
-import * as User from '../libs/actions/User';
-import FullScreenLoadingIndicator from '../components/FullscreenLoadingIndicator';
-
-const propTypes = {
- /** The accountID and validateCode are passed via the URL */
- route: validateLinkPropTypes,
-};
-
-const defaultProps = {
- route: validateLinkDefaultProps,
-};
-class ValidateLoginPage extends Component {
- componentDidMount() {
- const accountID = lodashGet(this.props.route.params, 'accountID', '');
- const validateCode = lodashGet(this.props.route.params, 'validateCode', '');
-
- User.validateLogin(accountID, validateCode);
- }
-
- render() {
- return ;
- }
-}
-
-ValidateLoginPage.propTypes = propTypes;
-ValidateLoginPage.defaultProps = defaultProps;
-
-export default ValidateLoginPage;
diff --git a/src/pages/ValidateLoginPage/index.js b/src/pages/ValidateLoginPage/index.js
new file mode 100644
index 000000000000..d76ef47c9856
--- /dev/null
+++ b/src/pages/ValidateLoginPage/index.js
@@ -0,0 +1,65 @@
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+import {
+ propTypes as validateLinkPropTypes,
+ defaultProps as validateLinkDefaultProps,
+} from './validateLinkPropTypes';
+import * as User from '../../libs/actions/User';
+import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator';
+import ONYXKEYS from '../../ONYXKEYS';
+import * as Session from '../../libs/actions/Session';
+import Permissions from '../../libs/Permissions';
+import Navigation from '../../libs/Navigation/Navigation';
+
+const propTypes = {
+ /** The accountID and validateCode are passed via the URL */
+ route: validateLinkPropTypes,
+
+ /** List of betas available to current user */
+ betas: PropTypes.arrayOf(PropTypes.string),
+};
+
+const defaultProps = {
+ route: validateLinkDefaultProps,
+ betas: [],
+};
+
+class ValidateLoginPage extends Component {
+ componentDidMount() {
+ if (Permissions.canUsePasswordlessLogins(this.props.betas)) {
+ if (lodashGet(this.props, 'session.authToken', null)) {
+ // If already signed in, do not show the validate code if not on web,
+ // because we don't want to block the user with the interstitial page.
+ Navigation.goBack(false);
+ } else {
+ Session.signInWithValidateCodeAndNavigate(this.accountID(), this.validateCode());
+ }
+ } else {
+ User.validateLogin(this.accountID(), this.validateCode());
+ }
+ }
+
+ /**
+ * @returns {String}
+ */
+ accountID = () => lodashGet(this.props.route.params, 'accountID', '');
+
+ /**
+ * @returns {String}
+ */
+ validateCode = () => lodashGet(this.props.route.params, 'validateCode', '');
+
+ render() {
+ return ;
+ }
+}
+
+ValidateLoginPage.propTypes = propTypes;
+ValidateLoginPage.defaultProps = defaultProps;
+
+export default withOnyx({
+ betas: {key: ONYXKEYS.BETAS},
+ session: {key: ONYXKEYS.SESSION},
+})(ValidateLoginPage);
diff --git a/src/pages/ValidateLoginPage/index.website.js b/src/pages/ValidateLoginPage/index.website.js
new file mode 100644
index 000000000000..ee8e1f38b04c
--- /dev/null
+++ b/src/pages/ValidateLoginPage/index.website.js
@@ -0,0 +1,116 @@
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+import {
+ propTypes as validateLinkPropTypes,
+ defaultProps as validateLinkDefaultProps,
+} from './validateLinkPropTypes';
+import * as User from '../../libs/actions/User';
+import compose from '../../libs/compose';
+import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator';
+import ValidateCodeModal from '../../components/ValidateCodeModal';
+import ONYXKEYS from '../../ONYXKEYS';
+import * as Session from '../../libs/actions/Session';
+import Permissions from '../../libs/Permissions';
+import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
+
+const propTypes = {
+ /** The accountID and validateCode are passed via the URL */
+ route: validateLinkPropTypes,
+
+ /** List of betas available to current user */
+ betas: PropTypes.arrayOf(PropTypes.string),
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ route: validateLinkDefaultProps,
+ betas: [],
+};
+
+class ValidateLoginPage extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {justSignedIn: false};
+ }
+
+ componentDidMount() {
+ // Validate login if
+ // - The user is not on passwordless beta
+ if (!this.isOnPasswordlessBeta()) {
+ User.validateLogin(this.accountID(), this.validateCode());
+ return;
+ }
+
+ // Sign in if
+ // - The user is on the passwordless beta
+ // - AND the user is not authenticated
+ // - AND the user has initiated the sign in process in another tab
+ if (this.isOnPasswordlessBeta() && !this.isAuthenticated() && this.isSignInInitiated()) {
+ Session.signInWithValidateCode(this.accountID(), this.validateCode());
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!(prevProps.credentials && !prevProps.credentials.validateCode && this.props.credentials.validateCode)) {
+ return;
+ }
+ this.setState({justSignedIn: true});
+ }
+
+ /**
+ * @returns {Boolean}
+ */
+ isOnPasswordlessBeta = () => Permissions.canUsePasswordlessLogins(this.props.betas);
+
+ /**
+ * @returns {String}
+ */
+ accountID = () => lodashGet(this.props.route.params, 'accountID', '');
+
+ /**
+ * @returns {String}
+ */
+ validateCode = () => lodashGet(this.props.route.params, 'validateCode', '');
+
+ /**
+ * @returns {Boolean}
+ */
+ isAuthenticated = () => Boolean(lodashGet(this.props, 'session.authToken', null));
+
+ /**
+ * Where SignIn was initiated on the current browser.
+ * @returns {Boolean}
+ */
+ isSignInInitiated = () => !this.isAuthenticated() && this.props.credentials && this.props.credentials.login;
+
+ render() {
+ return (
+ this.isOnPasswordlessBeta()
+ ? (
+ Session.signInWithValidateCodeAndNavigate(this.accountID(), this.validateCode())}
+ />
+ )
+ :
+ );
+ }
+}
+
+ValidateLoginPage.propTypes = propTypes;
+ValidateLoginPage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ betas: {key: ONYXKEYS.BETAS},
+ session: {key: ONYXKEYS.SESSION},
+ credentials: {key: ONYXKEYS.CREDENTIALS},
+ }),
+)(ValidateLoginPage);
diff --git a/src/pages/validateLinkPropTypes.js b/src/pages/ValidateLoginPage/validateLinkPropTypes.js
similarity index 100%
rename from src/pages/validateLinkPropTypes.js
rename to src/pages/ValidateLoginPage/validateLinkPropTypes.js
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 1be75b3e1d90..467ce7b70a70 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -20,7 +20,6 @@ import CONST from '../../CONST';
import ReportActionsSkeletonView from '../../components/ReportActionsSkeletonView';
import reportActionPropTypes from './report/reportActionPropTypes';
import toggleReportActionComposeView from '../../libs/toggleReportActionComposeView';
-import addViewportResizeListener from '../../libs/VisualViewport';
import {withNetwork} from '../../components/OnyxProvider';
import compose from '../../libs/compose';
import networkPropTypes from '../../components/networkPropTypes';
@@ -33,6 +32,7 @@ import withLocalize from '../../components/withLocalize';
import reportPropTypes from '../reportPropTypes';
import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView';
import ReportHeaderSkeletonView from '../../components/ReportHeaderSkeletonView';
+import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop';
const propTypes = {
/** Navigation route context info provided by react navigation */
@@ -73,6 +73,7 @@ const propTypes = {
...windowDimensionsPropTypes,
...withDrawerPropTypes,
+ ...viewportOffsetTopPropTypes,
};
const defaultProps = {
@@ -107,14 +108,11 @@ class ReportScreen extends React.Component {
super(props);
this.onSubmitComment = this.onSubmitComment.bind(this);
- this.updateViewportOffsetTop = this.updateViewportOffsetTop.bind(this);
this.chatWithAccountManager = this.chatWithAccountManager.bind(this);
this.dismissBanner = this.dismissBanner.bind(this);
- this.removeViewportResizeListener = () => {};
this.state = {
skeletonViewContainerHeight: reportActionsListViewHeight,
- viewportOffsetTop: 0,
isBannerVisible: true,
};
}
@@ -122,7 +120,7 @@ class ReportScreen extends React.Component {
componentDidMount() {
this.fetchReportIfNeeded();
toggleReportActionComposeView(true);
- this.removeViewportResizeListener = addViewportResizeListener(this.updateViewportOffsetTop);
+ Navigation.setIsReportScreenIsReady();
}
componentDidUpdate(prevProps) {
@@ -134,10 +132,6 @@ class ReportScreen extends React.Component {
toggleReportActionComposeView(true);
}
- componentWillUnmount() {
- this.removeViewportResizeListener();
- }
-
/**
* @param {String} text
*/
@@ -171,14 +165,6 @@ class ReportScreen extends React.Component {
Report.openReport(reportIDFromPath);
}
- /**
- * @param {SyntheticEvent} e
- */
- updateViewportOffsetTop(e) {
- const viewportOffsetTop = lodashGet(e, 'target.offsetTop', 0);
- this.setState({viewportOffsetTop});
- }
-
dismissBanner() {
this.setState({isBannerVisible: false});
}
@@ -205,7 +191,7 @@ class ReportScreen extends React.Component {
const reportID = getReportID(this.props.route);
const addWorkspaceRoomOrChatPendingAction = lodashGet(this.props.report, 'pendingFields.addWorkspaceRoom') || lodashGet(this.props.report, 'pendingFields.createChat');
const addWorkspaceRoomOrChatErrors = lodashGet(this.props.report, 'errorFields.addWorkspaceRoom') || lodashGet(this.props.report, 'errorFields.createChat');
- const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: this.state.viewportOffsetTop}];
+ const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: this.props.viewportOffsetTop}];
// There are no reportActions at all to display and we are still in the process of loading the next set of actions.
const isLoadingInitialReportActions = _.isEmpty(this.props.reportActions) && this.props.report.isLoadingReportActions;
@@ -323,6 +309,7 @@ ReportScreen.propTypes = propTypes;
ReportScreen.defaultProps = defaultProps;
export default compose(
+ withViewportOffsetTop,
withLocalize,
withWindowDimensions,
withDrawerState,
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index a75e3963e715..949286358000 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -112,11 +112,7 @@ export default [
if (!Clipboard.canSetHtml()) {
Clipboard.setString(parser.htmlToMarkdown(content));
} else {
- // Thanks to how browsers work, when text is highlighted and CTRL+c is pressed, browsers end up doubling the amount of newlines. Since the code in this file is
- // triggered from a context menu and not CTRL+c, the newlines need to be doubled so that the content that goes into the clipboard is consistent with CTRL+c behavior.
- // The extra newlines are stripped when the contents are pasted into the compose input, but if the contents are pasted outside of the comment composer, it will
- // contain extra newlines and that's OK because it is consistent with CTRL+c behavior.
- const plainText = Str.htmlDecode(parser.htmlToText(content)).replace(/\n/g, '\n\n');
+ const plainText = Str.htmlDecode(parser.htmlToText(content));
Clipboard.setHtml(content, plainText);
}
}
diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js
index 7ef7fd1d7a83..4075e50b3b37 100644
--- a/src/pages/home/report/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose.js
@@ -294,14 +294,15 @@ class ReportActionCompose extends React.Component {
];
}
- // DM chats and workspace chats that only have 2 people will see the Send / Request money options.
+ // 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.
return [
{
icon: Expensicons.MoneyCircle,
text: this.props.translate('iou.requestMoney'),
onSelected: () => Navigation.navigate(ROUTES.getIouRequestRoute(this.props.reportID)),
},
- ...(Permissions.canUseIOUSend(this.props.betas)
+ ...((Permissions.canUseIOUSend(this.props.betas) && !ReportUtils.isPolicyExpenseChat(this.props.report))
? [
{
icon: Expensicons.Send,
@@ -471,7 +472,7 @@ class ReportActionCompose extends React.Component {
const trimmedComment = this.comment.trim();
// Don't submit empty comments or comments that exceed the character limit
- if (this.state.isCommentEmpty || trimmedComment.length > CONST.MAX_COMMENT_LENGTH) {
+ if (this.state.isCommentEmpty || ReportUtils.getCommentLength(trimmedComment) > CONST.MAX_COMMENT_LENGTH) {
return '';
}
@@ -534,7 +535,8 @@ class ReportActionCompose extends React.Component {
const isComposeDisabled = this.props.isDrawerOpen && this.props.isSmallScreenWidth;
const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge);
const inputPlaceholder = this.getInputPlaceholder();
- const hasExceededMaxCommentLength = this.comment.length > CONST.MAX_COMMENT_LENGTH;
+ const encodedCommentLength = ReportUtils.getCommentLength(this.comment);
+ const hasExceededMaxCommentLength = encodedCommentLength > CONST.MAX_COMMENT_LENGTH;
return (
{!this.props.isSmallScreenWidth && }
-
+
{this.state.isDraggingOver && }
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index 97fffebebcd3..5f6b5e36632e 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -15,6 +15,7 @@ import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFo
import compose from '../../../libs/compose';
import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton';
import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu';
+import * as ReportUtils from '../../../libs/ReportUtils';
import * as EmojiUtils from '../../../libs/EmojiUtils';
import reportPropTypes from '../../reportPropTypes';
import ExceededCommentLength from '../../../components/ExceededCommentLength';
@@ -154,7 +155,7 @@ class ReportActionItemMessageEdit extends React.Component {
*/
publishDraft() {
// Do nothing if draft exceed the character limit
- if (this.state.draft.length > CONST.MAX_COMMENT_LENGTH) {
+ if (ReportUtils.getCommentLength(this.state.draft) > CONST.MAX_COMMENT_LENGTH) {
return;
}
@@ -214,7 +215,8 @@ class ReportActionItemMessageEdit extends React.Component {
}
render() {
- const hasExceededMaxCommentLength = this.state.draft.length > CONST.MAX_COMMENT_LENGTH;
+ const draftLength = ReportUtils.getCommentLength(this.state.draft);
+ const hasExceededMaxCommentLength = draftLength > CONST.MAX_COMMENT_LENGTH;
return (
-
+
);
diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js
index 1df707541092..19d2cc05f58c 100644
--- a/src/pages/home/report/ReportActionItemSingle.js
+++ b/src/pages/home/report/ReportActionItemSingle.js
@@ -19,6 +19,7 @@ import Tooltip from '../../../components/Tooltip';
import ControlSelection from '../../../libs/ControlSelection';
import * as ReportUtils from '../../../libs/ReportUtils';
import OfflineWithFeedback from '../../../components/OfflineWithFeedback';
+import CONST from '../../../CONST';
const propTypes = {
/** All the data of the action */
@@ -51,13 +52,14 @@ const showUserDetails = (email) => {
};
const ReportActionItemSingle = (props) => {
+ const actorEmail = props.action.actorEmail.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
const {
avatar,
displayName,
login,
pendingFields,
- } = props.personalDetails[props.action.actorEmail] || {};
- const avatarSource = ReportUtils.getAvatar(avatar, props.action.actorEmail);
+ } = props.personalDetails[actorEmail] || {};
+ const avatarSource = ReportUtils.getAvatar(avatar, actorEmail);
// Since the display name for a report action message is delivered with the report history as an array of fragments
// we'll need to take the displayName from personal details and have it be in the same format for now. Eventually,
@@ -72,9 +74,9 @@ const ReportActionItemSingle = (props) => {
style={[styles.alignSelfStart, styles.mr3]}
onPressIn={ControlSelection.block}
onPressOut={ControlSelection.unblock}
- onPress={() => showUserDetails(props.action.actorEmail)}
+ onPress={() => showUserDetails(actorEmail)}
>
-
+
@@ -92,13 +94,13 @@ const ReportActionItemSingle = (props) => {
style={[styles.flexShrink1, styles.mr1]}
onPressIn={ControlSelection.block}
onPressOut={ControlSelection.unblock}
- onPress={() => showUserDetails(props.action.actorEmail)}
+ onPress={() => showUserDetails(actorEmail)}
>
{_.map(personArray, (fragment, index) => (
this.sortedAndFilteredReportActions.length) {
- this.setState({newMarkerReportActionID: ReportUtils.getNewMarkerReportActionID(this.props.report, this.sortedAndFilteredReportActions)});
+ // If the report action marking the unread point is deleted we need to recalculate which action should be the unread marker
+ if (this.state.newMarkerReportActionID && _.isEmpty(lodashGet(this.props.reportActions[this.state.newMarkerReportActionID], 'message[0].html'))) {
+ this.setState({
+ newMarkerReportActionID: ReportUtils.getNewMarkerReportActionID(this.props.report, this.sortedAndFilteredReportActions),
+ });
}
// When the user navigates to the LHN the ReportActionsView doesn't unmount and just remains hidden.
@@ -264,6 +270,7 @@ class ReportActionsView extends React.Component {
}
if (String(reportAction.sequenceNumber) === key) {
+ Log.info('Front-end filtered out reportAction keyed by sequenceNumber!', false, reportAction);
return false;
}
diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
index bcca70f3f354..0e99d72926f5 100644
--- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
+++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
@@ -9,7 +9,6 @@ import Timing from '../../../../libs/actions/Timing';
import CONST from '../../../../CONST';
import Performance from '../../../../libs/Performance';
import withDrawerState from '../../../../components/withDrawerState';
-import KeyboardShortcutsModal from '../../../../components/KeyboardShortcutsModal';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions';
import compose from '../../../../libs/compose';
import sidebarPropTypes from './sidebarPropTypes';
@@ -66,7 +65,6 @@ class BaseSidebarScreen extends Component {
onLayout={this.props.onLayout}
/>
-
{this.props.children}
>
)}
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index e78cbd48e16f..1d4524be7d66 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -113,18 +113,23 @@ class IOUCurrencySelection extends Component {
const headerMessage = this.state.searchValue.trim() && !this.state.currencyData.length ? this.props.translate('common.noResultsFound') : '';
return (
-
-
+ {({safeAreaPaddingBottomStyle}) => (
+ <>
+
+
+ >
+ )}
);
}
diff --git a/src/pages/iou/IOUDetailsModal.js b/src/pages/iou/IOUDetailsModal.js
index 4da6d6792f47..e3380122db7a 100644
--- a/src/pages/iou/IOUDetailsModal.js
+++ b/src/pages/iou/IOUDetailsModal.js
@@ -154,49 +154,52 @@ class IOUDetailsModal extends Component {
const pendingAction = this.findPendingAction();
const iouReportStateNum = lodashGet(this.props.iouReport, 'stateNum');
const hasOutstandingIOU = lodashGet(this.props.iouReport, 'hasOutstandingIOU');
+ const hasFixedFooter = hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail;
return (
-
-
-
- {this.props.iou.loading ? : (
-
-
-
-
-
- {(hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail && (
-
- this.payMoneyRequest(paymentMethodType)}
- shouldShowPaypal={Boolean(lodashGet(this.props, 'iouReport.submitterPayPalMeAddress'))}
- currency={lodashGet(this.props, 'iouReport.currency')}
- enablePaymentsRoute={ROUTES.IOU_DETAILS_ENABLE_PAYMENTS}
- addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT}
- addDebitCardRoute={ROUTES.IOU_DETAILS_ADD_DEBIT_CARD}
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+ {this.props.iou.loading ? : (
+
+
+
-
- ))}
-
- )}
-
+
+
+ {(hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail && (
+
+ this.payMoneyRequest(paymentMethodType)}
+ shouldShowPaypal={Boolean(lodashGet(this.props, 'iouReport.submitterPayPalMeAddress'))}
+ currency={lodashGet(this.props, 'iouReport.currency')}
+ enablePaymentsRoute={ROUTES.IOU_DETAILS_ENABLE_PAYMENTS}
+ addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT}
+ addDebitCardRoute={ROUTES.IOU_DETAILS_ADD_DEBIT_CARD}
+ chatReportID={this.props.route.params.chatReportID}
+ />
+
+ ))}
+
+ )}
+
+ )}
);
}
diff --git a/src/pages/iou/IOUModal.js b/src/pages/iou/IOUModal.js
index c733ce6f8989..6acc2fb35330 100755
--- a/src/pages/iou/IOUModal.js
+++ b/src/pages/iou/IOUModal.js
@@ -453,6 +453,7 @@ class IOUModal extends Component {
hasMultipleParticipants={this.props.hasMultipleParticipants}
onAddParticipants={this.addParticipants}
onStepComplete={this.navigateToNextStep}
+ safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
/>
)}
diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js
index ac719cda4c31..38d5c7a9d46d 100644
--- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js
+++ b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js
@@ -41,11 +41,18 @@ const propTypes = {
/** Whether or not the IOU step is loading (retrieving participants) */
loading: PropTypes.bool,
}),
+
+ /** padding bottom style of safe area */
+ safeAreaPaddingBottomStyle: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.object),
+ PropTypes.object,
+ ]),
};
const defaultProps = {
iou: {},
participants: [],
+ safeAreaPaddingBottomStyle: {},
};
const IOUParticipantsPage = (props) => {
@@ -63,12 +70,14 @@ const IOUParticipantsPage = (props) => {
onStepComplete={props.onStepComplete}
participants={props.participants}
onAddParticipants={props.onAddParticipants}
+ safeAreaPaddingBottomStyle={props.safeAreaPaddingBottomStyle}
/>
)
: (
)
);
diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js
index 0b29c1df14dd..c7f86ace9b4d 100755
--- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js
+++ b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js
@@ -27,9 +27,19 @@ const propTypes = {
/** All reports shared with the user */
reports: PropTypes.objectOf(reportPropTypes).isRequired,
+ /** padding bottom style of safe area */
+ safeAreaPaddingBottomStyle: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.object),
+ PropTypes.object,
+ ]),
+
...withLocalizePropTypes,
};
+const defaultProps = {
+ safeAreaPaddingBottomStyle: {},
+};
+
class IOUParticipantsRequest extends Component {
constructor(props) {
super(props);
@@ -141,12 +151,14 @@ class IOUParticipantsRequest extends Component {
headerMessage={headerMessage}
placeholderText={this.props.translate('optionsSelector.nameEmailOrPhoneNumber')}
boldStyle
+ safeAreaPaddingBottomStyle={this.props.safeAreaPaddingBottomStyle}
/>
);
}
}
IOUParticipantsRequest.propTypes = propTypes;
+IOUParticipantsRequest.defaultProps = defaultProps;
export default compose(
withLocalize,
diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js
index 7da28e971cdc..f43325b6bdc8 100755
--- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js
+++ b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js
@@ -13,7 +13,6 @@ import compose from '../../../../libs/compose';
import Text from '../../../../components/Text';
import personalDetailsPropType from '../../../personalDetailsPropType';
import reportPropTypes from '../../../reportPropTypes';
-import SafeAreaConsumer from '../../../../components/SafeAreaConsumer';
const propTypes = {
/** Beta features list */
@@ -43,11 +42,18 @@ const propTypes = {
/** All reports shared with the user */
reports: PropTypes.objectOf(reportPropTypes).isRequired,
+ /** padding bottom style of safe area */
+ safeAreaPaddingBottomStyle: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.object),
+ PropTypes.object,
+ ]),
+
...withLocalizePropTypes,
};
const defaultProps = {
participants: [],
+ safeAreaPaddingBottomStyle: {},
};
class IOUParticipantsSplit extends Component {
@@ -210,29 +216,26 @@ class IOUParticipantsSplit extends Component {
maxParticipantsReached,
);
return (
-
- {({safeAreaPaddingBottomStyle}) => (
- 0 ? safeAreaPaddingBottomStyle : {})]}>
-
- {this.props.translate('common.to')}
-
-
-
- )}
-
+ 0 ? this.props.safeAreaPaddingBottomStyle : {})]}>
+
+ {this.props.translate('common.to')}
+
+
+
);
}
}
diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js
index a9a809c6d0a2..7c7840fdbbdb 100644
--- a/src/pages/settings/AboutPage/AboutPage.js
+++ b/src/pages/settings/AboutPage/AboutPage.js
@@ -62,80 +62,85 @@ const AboutPage = (props) => {
return (
- Navigation.navigate(ROUTES.SETTINGS)}
- onCloseButtonPress={() => Navigation.dismissModal(true)}
- />
-
-
-
-
-
+ {({safeAreaPaddingBottomStyle}) => (
+ <>
+ Navigation.navigate(ROUTES.SETTINGS)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+
+
+
+ v
+ {pkg.version}
+
+
+ {props.translate('initialSettingsPage.aboutPage.description')}
+
+
+
+ {_.map(menuItems, item => (
+ item.action()}
+ shouldShowRightIcon
+ />
+ ))}
+
+
- v
- {pkg.version}
-
-
- {props.translate('initialSettingsPage.aboutPage.description')}
+ {props.translate(
+ 'initialSettingsPage.readTheTermsAndPrivacy.phrase1',
+ )}
+ {' '}
+
+ {props.translate(
+ 'initialSettingsPage.readTheTermsAndPrivacy.phrase2',
+ )}
+
+ {' '}
+ {props.translate(
+ 'initialSettingsPage.readTheTermsAndPrivacy.phrase3',
+ )}
+ {' '}
+
+ {props.translate(
+ 'initialSettingsPage.readTheTermsAndPrivacy.phrase4',
+ )}
+
+ .
-
- {_.map(menuItems, item => (
- item.action()}
- shouldShowRightIcon
- />
- ))}
-
-
-
- {props.translate(
- 'initialSettingsPage.readTheTermsAndPrivacy.phrase1',
- )}
- {' '}
-
- {props.translate(
- 'initialSettingsPage.readTheTermsAndPrivacy.phrase2',
- )}
-
- {' '}
- {props.translate(
- 'initialSettingsPage.readTheTermsAndPrivacy.phrase3',
- )}
- {' '}
-
- {props.translate(
- 'initialSettingsPage.readTheTermsAndPrivacy.phrase4',
- )}
-
- .
-
-
-
+
+ >
+ )}
);
};
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index 0c55f189bf1a..f10bca4c6037 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -244,57 +244,61 @@ class InitialSettingsPage extends React.Component {
return (
- Navigation.dismissModal(true)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- {this.props.currentUserPersonalDetails.displayName
- ? this.props.currentUserPersonalDetails.displayName
- : Str.removeSMSDomain(this.props.session.email)}
-
-
- {this.props.currentUserPersonalDetails.displayName && (
-
- {Str.removeSMSDomain(this.props.session.email)}
-
- )}
-
- {_.map(this.getDefaultMenuItems(), (item, index) => this.getMenuItem(item, index))}
-
- this.signOut(true)}
- onCancel={() => this.toggleSignoutConfirmModal(false)}
+ {({safeAreaPaddingBottomStyle}) => (
+ <>
+ Navigation.dismissModal(true)}
/>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {this.props.currentUserPersonalDetails.displayName
+ ? this.props.currentUserPersonalDetails.displayName
+ : Str.removeSMSDomain(this.props.session.email)}
+
+
+ {this.props.currentUserPersonalDetails.displayName && (
+
+ {Str.removeSMSDomain(this.props.session.email)}
+
+ )}
+
+ {_.map(this.getDefaultMenuItems(), (item, index) => this.getMenuItem(item, index))}
+
+ this.signOut(true)}
+ onCancel={() => this.toggleSignoutConfirmModal(false)}
+ />
+
+
+ >
+ )}
);
}
diff --git a/src/pages/settings/Payments/AddDebitCardPage.js b/src/pages/settings/Payments/AddDebitCardPage.js
index 542979a2b279..cb55aee243c2 100644
--- a/src/pages/settings/Payments/AddDebitCardPage.js
+++ b/src/pages/settings/Payments/AddDebitCardPage.js
@@ -21,6 +21,7 @@ import ONYXKEYS from '../../../ONYXKEYS';
import AddressSearch from '../../../components/AddressSearch';
import * as ComponentUtils from '../../../libs/ComponentUtils';
import Form from '../../../components/Form';
+import Permissions from '../../../libs/Permissions';
const propTypes = {
/* Onyx Props */
@@ -28,6 +29,9 @@ const propTypes = {
setupComplete: PropTypes.bool,
}),
+ /** List of betas available to current user */
+ betas: PropTypes.arrayOf(PropTypes.string),
+
...withLocalizePropTypes,
};
@@ -35,6 +39,7 @@ const defaultProps = {
formData: {
setupComplete: false,
},
+ betas: [],
};
class DebitCardPage extends Component {
@@ -95,7 +100,7 @@ class DebitCardPage extends Component {
errors.addressState = this.props.translate('addDebitCardPage.error.addressState');
}
- if (!values.password || _.isEmpty(values.password.trim())) {
+ if (!Permissions.canUsePasswordlessLogins(this.props.betas) && (!values.password || _.isEmpty(values.password.trim()))) {
errors.password = this.props.translate('addDebitCardPage.error.password');
}
@@ -176,15 +181,17 @@ class DebitCardPage extends Component {
/>
-
-
-
+ {!Permissions.canUsePasswordlessLogins(this.props.betas) && (
+
+
+
+ )}
(
@@ -212,5 +219,8 @@ export default compose(
formData: {
key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
},
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
}),
)(DebitCardPage);
diff --git a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js
index 9a480454eaf3..afc47c160ba4 100644
--- a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js
+++ b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js
@@ -49,6 +49,7 @@ class BasePaymentsPage extends React.Component {
formattedSelectedPaymentMethod: {
title: '',
},
+ selectedPaymentMethodType: null,
anchorPositionTop: 0,
anchorPositionBottom: 0,
anchorPositionRight: 0,
@@ -87,6 +88,27 @@ class BasePaymentsPage extends React.Component {
this.debounceSetShouldShowLoadingSpinner();
}
+ if (this.state.shouldShowDefaultDeleteMenu || this.state.shouldShowPasswordPrompt) {
+ // We should reset selected payment method state values and close corresponding modals if the selected payment method is deleted
+ let shouldResetPaymentMethodData = false;
+
+ if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.BANK_ACCOUNT && _.isEmpty(this.props.bankAccountList[this.state.methodID])) {
+ shouldResetPaymentMethodData = true;
+ } else if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD && _.isEmpty(this.props.cardList[this.state.methodID])) {
+ shouldResetPaymentMethodData = true;
+ } else if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.PAYPAL && this.props.payPalMeData !== prevProps.payPalMeData && _.isEmpty(this.props.payPalMeData)) {
+ shouldResetPaymentMethodData = true;
+ }
+ if (shouldResetPaymentMethodData) {
+ // Close corresponding selected payment method modals which are open
+ if (this.state.shouldShowDefaultDeleteMenu) {
+ this.hideDefaultDeleteMenu();
+ } else if (this.state.shouldShowPasswordPrompt) {
+ this.hidePasswordPrompt();
+ }
+ }
+ }
+
// previously online OR currently offline, skip fetch
if (!prevProps.network.isOffline || this.props.network.isOffline) {
return;
@@ -138,6 +160,23 @@ class BasePaymentsPage extends React.Component {
});
}
+ resetSelectedPaymentMethodData() {
+ // The below state values are used by payment method modals and we reset them while closing the modals.
+ // We should only reset the values when the modal animation is completed and so using InteractionManager.runAfterInteractions which fires after all animaitons are complete
+ InteractionManager.runAfterInteractions(() => {
+ // Reset to same values as in the constructor
+ this.setState({
+ isSelectedPaymentMethodDefault: false,
+ selectedPaymentMethod: {},
+ formattedSelectedPaymentMethod: {
+ title: '',
+ },
+ methodID: null,
+ selectedPaymentMethodType: null,
+ });
+ });
+ }
+
/**
* Display the delete/default menu, or the add payment method menu
*
@@ -235,18 +274,25 @@ class BasePaymentsPage extends React.Component {
/**
* Hide the default / delete modal
+ * @param {boolean} shouldClearSelectedData - Clear selected payment method data if true
*/
- hideDefaultDeleteMenu() {
+ hideDefaultDeleteMenu(shouldClearSelectedData = true) {
this.setState({shouldShowDefaultDeleteMenu: false});
InteractionManager.runAfterInteractions(() => {
this.setState({
showConfirmDeleteContent: false,
});
+ if (shouldClearSelectedData) {
+ this.resetSelectedPaymentMethodData();
+ }
});
}
- hidePasswordPrompt() {
+ hidePasswordPrompt(shouldClearSelectedData = true) {
this.setState({shouldShowPasswordPrompt: false});
+ if (shouldClearSelectedData) {
+ this.resetSelectedPaymentMethodData();
+ }
// Due to iOS modal freeze issue, password modal freezes the app when closed.
// LayoutAnimation undoes the running animation.
@@ -267,6 +313,7 @@ class BasePaymentsPage extends React.Component {
} else if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) {
PaymentMethods.makeDefaultPaymentMethod(password, null, this.state.selectedPaymentMethod.fundID, previousPaymentMethod, currentPaymentMethod);
}
+ this.resetSelectedPaymentMethodData();
}
deletePaymentMethod() {
@@ -277,6 +324,7 @@ class BasePaymentsPage extends React.Component {
} else if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) {
PaymentMethods.deletePaymentCard(this.state.selectedPaymentMethod.fundID);
}
+ this.resetSelectedPaymentMethodData();
}
navigateToTransferBalancePage() {
@@ -439,30 +487,10 @@ class BasePaymentsPage extends React.Component {
) : (
{
- this.setState({
- shouldShowDefaultDeleteMenu: false,
- });
- InteractionManager.runAfterInteractions(() => {
- this.setState({
- showConfirmDeleteContent: false,
- });
- });
+ this.hideDefaultDeleteMenu(false);
this.deletePaymentMethod();
}}
- onCancel={() => {
- this.setState({
- shouldShowDefaultDeleteMenu: false,
- });
- InteractionManager.runAfterInteractions(
- () => {
- this.setState(
- {
- showConfirmDeleteContent: false,
- },
- );
- },
- );
- }}
+ onCancel={this.hideDefaultDeleteMenu}
contentStyles={!this.props.isSmallScreenWidth ? [styles.sidebarPopover] : undefined}
title={this.props.translate('paymentsPage.deleteAccount')}
prompt={this.props.translate('paymentsPage.deleteConfirmation')}
@@ -485,7 +513,7 @@ class BasePaymentsPage extends React.Component {
right: this.state.anchorPositionRight,
}}
onSubmit={(password) => {
- this.hidePasswordPrompt();
+ this.hidePasswordPrompt(false);
this.makeDefaultPaymentMethod(password);
}}
submitButtonText={this.state.passwordButtonText}
@@ -521,6 +549,9 @@ export default compose(
walletTerms: {
key: ONYXKEYS.WALLET_TERMS,
},
+ payPalMeData: {
+ key: ONYXKEYS.PAYPAL,
+ },
isLoadingPaymentMethods: {
key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
},
diff --git a/src/pages/settings/Preferences/LanguagePage.js b/src/pages/settings/Preferences/LanguagePage.js
new file mode 100644
index 000000000000..c411e1de0123
--- /dev/null
+++ b/src/pages/settings/Preferences/LanguagePage.js
@@ -0,0 +1,70 @@
+import _ from 'underscore';
+import React from 'react';
+import PropTypes from 'prop-types';
+import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton';
+import ScreenWrapper from '../../../components/ScreenWrapper';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
+import OptionsList from '../../../components/OptionsList';
+import styles from '../../../styles/styles';
+import themeColors from '../../../styles/themes/default';
+import * as Expensicons from '../../../components/Icon/Expensicons';
+import * as App from '../../../libs/actions/App';
+
+const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success};
+
+const propTypes = {
+ ...withLocalizePropTypes,
+
+ /** The preferred language of the App */
+ preferredLocale: PropTypes.string.isRequired,
+};
+
+const LanguagePage = (props) => {
+ const localesToLanguages = _.map(props.translate('languagePage.languages'),
+ (language, key) => (
+ {
+ value: key,
+ text: language.label,
+ keyForList: key,
+
+ // Include the green checkmark icon to indicate the currently selected value
+ customIcon: props.preferredLocale === key ? greenCheckmark : undefined,
+
+ // This property will make the currently selected value have bold text
+ boldStyle: props.preferredLocale === key,
+ }
+ ));
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_PREFERENCES)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+ App.setLocaleAndNavigate(language.value)}
+ hideSectionHeaders
+ optionHoveredStyle={
+ {
+ ...styles.hoveredComponentBG,
+ ...styles.mhn5,
+ ...styles.ph5,
+ }
+ }
+ shouldHaveOptionSeparator
+ shouldDisableRowInnerPadding
+ contentContainerStyles={[styles.ph5]}
+ />
+
+ );
+};
+
+LanguagePage.displayName = 'LanguagePage';
+LanguagePage.propTypes = propTypes;
+
+export default withLocalize(LanguagePage);
diff --git a/src/pages/settings/Preferences/PreferencesPage.js b/src/pages/settings/Preferences/PreferencesPage.js
new file mode 100755
index 000000000000..86345c16f7bd
--- /dev/null
+++ b/src/pages/settings/Preferences/PreferencesPage.js
@@ -0,0 +1,112 @@
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import React from 'react';
+import {View, ScrollView} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
+import ONYXKEYS from '../../../ONYXKEYS';
+import styles from '../../../styles/styles';
+import Text from '../../../components/Text';
+import CONST from '../../../CONST';
+import * as User from '../../../libs/actions/User';
+import ScreenWrapper from '../../../components/ScreenWrapper';
+import Switch from '../../../components/Switch';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import compose from '../../../libs/compose';
+import withEnvironment, {environmentPropTypes} from '../../../components/withEnvironment';
+import TestToolMenu from '../../../components/TestToolMenu';
+import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription';
+
+const propTypes = {
+ /** The chat priority mode */
+ priorityMode: PropTypes.string,
+
+ /** The details about the user that is signed in */
+ user: PropTypes.shape({
+ /** Whether or not the user is subscribed to news updates */
+ isSubscribedToNewsletter: PropTypes.bool,
+ }),
+
+ /** The preferred language of the App */
+ preferredLocale: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+ ...environmentPropTypes,
+};
+
+const defaultProps = {
+ priorityMode: CONST.PRIORITY_MODE.DEFAULT,
+ user: {},
+};
+
+const PreferencesPage = (props) => {
+ const priorityModes = props.translate('priorityModePage.priorityModes');
+ const languages = props.translate('languagePage.languages');
+
+ // Enable additional test features in the staging or dev environments
+ const shouldShowTestToolMenu = _.contains([CONST.ENVIRONMENT.STAGING, CONST.ENVIRONMENT.DEV], props.environment);
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+ {props.translate('common.notifications')}
+
+
+
+
+ {props.translate('preferencesPage.receiveRelevantFeatureUpdatesAndExpensifyNews')}
+
+
+
+
+
+
+ Navigation.navigate(ROUTES.SETTINGS_PRIORITY_MODE)}
+ />
+ Navigation.navigate(ROUTES.SETTINGS_LANGUAGE)}
+ />
+ {shouldShowTestToolMenu && }
+
+
+
+ );
+};
+
+PreferencesPage.propTypes = propTypes;
+PreferencesPage.defaultProps = defaultProps;
+PreferencesPage.displayName = 'PreferencesPage';
+
+export default compose(
+ withEnvironment,
+ withLocalize,
+ withOnyx({
+ priorityMode: {
+ key: ONYXKEYS.NVP_PRIORITY_MODE,
+ },
+ user: {
+ key: ONYXKEYS.USER,
+ },
+ }),
+)(PreferencesPage);
diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js
new file mode 100644
index 000000000000..16af2569b824
--- /dev/null
+++ b/src/pages/settings/Preferences/PriorityModePage.js
@@ -0,0 +1,80 @@
+import _, {compose} from 'underscore';
+import React from 'react';
+import {withOnyx} from 'react-native-onyx';
+import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton';
+import ScreenWrapper from '../../../components/ScreenWrapper';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
+import OptionsList from '../../../components/OptionsList';
+import styles from '../../../styles/styles';
+import Text from '../../../components/Text';
+import themeColors from '../../../styles/themes/default';
+import * as Expensicons from '../../../components/Icon/Expensicons';
+import ONYXKEYS from '../../../ONYXKEYS';
+import * as User from '../../../libs/actions/User';
+
+const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success};
+
+const propTypes = {
+ ...withLocalizePropTypes,
+};
+
+const PriorityModePage = (props) => {
+ const priorityModes = _.map(props.translate('priorityModePage.priorityModes'),
+ (mode, key) => (
+ {
+ value: key,
+ text: mode.label,
+ alternateText: mode.description,
+ keyForList: key,
+
+ // Include the green checkmark icon to indicate the currently selected value
+ customIcon: props.priorityMode === key ? greenCheckmark : undefined,
+
+ // This property will make the currently selected value have bold text
+ boldStyle: props.priorityMode === key,
+ }
+ ));
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_PREFERENCES)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+ {props.translate('priorityModePage.explainerText')}
+
+ User.updateChatPriorityMode(mode.value)}
+ hideSectionHeaders
+ optionHoveredStyle={
+ {
+ ...styles.hoveredComponentBG,
+ ...styles.mhn5,
+ ...styles.ph5,
+ }
+ }
+ shouldHaveOptionSeparator
+ shouldDisableRowInnerPadding
+ contentContainerStyles={[styles.ph5]}
+ />
+
+ );
+};
+
+PriorityModePage.displayName = 'PriorityModePage';
+PriorityModePage.propTypes = propTypes;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ priorityMode: {
+ key: ONYXKEYS.NVP_PRIORITY_MODE,
+ },
+ }),
+)(PriorityModePage);
diff --git a/src/pages/settings/PreferencesPage.js b/src/pages/settings/PreferencesPage.js
deleted file mode 100755
index ee4a39ddc404..000000000000
--- a/src/pages/settings/PreferencesPage.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
-import React from 'react';
-import {View, ScrollView} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import PropTypes from 'prop-types';
-
-import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
-import LocalePicker from '../../components/LocalePicker';
-import Navigation from '../../libs/Navigation/Navigation';
-import ROUTES from '../../ROUTES';
-import ONYXKEYS from '../../ONYXKEYS';
-import styles from '../../styles/styles';
-import Text from '../../components/Text';
-import CONST from '../../CONST';
-import * as User from '../../libs/actions/User';
-import ScreenWrapper from '../../components/ScreenWrapper';
-import Switch from '../../components/Switch';
-import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
-import compose from '../../libs/compose';
-import Picker from '../../components/Picker';
-import withEnvironment, {environmentPropTypes} from '../../components/withEnvironment';
-import TestToolMenu from '../../components/TestToolMenu';
-
-const propTypes = {
- /** The chat priority mode */
- priorityMode: PropTypes.string,
-
- /** The details about the user that is signed in */
- user: PropTypes.shape({
- /** Whether or not the user is subscribed to news updates */
- isSubscribedToNewsletter: PropTypes.bool,
- shouldUseStagingServer: PropTypes.bool,
- }),
-
- ...withLocalizePropTypes,
- ...environmentPropTypes,
-};
-
-const defaultProps = {
- priorityMode: CONST.PRIORITY_MODE.DEFAULT,
- user: {},
-};
-
-const PreferencesPage = (props) => {
- const priorityModes = {
- default: {
- value: CONST.PRIORITY_MODE.DEFAULT,
- label: props.translate('preferencesPage.mostRecent'),
- description: props.translate('preferencesPage.mostRecentModeDescription'),
- },
- gsd: {
- value: CONST.PRIORITY_MODE.GSD,
- label: props.translate('preferencesPage.focus'),
- description: props.translate('preferencesPage.focusModeDescription'),
- },
- };
-
- return (
-
- Navigation.navigate(ROUTES.SETTINGS)}
- onCloseButtonPress={() => Navigation.dismissModal(true)}
- />
-
-
-
- {props.translate('common.notifications')}
-
-
-
-
- {props.translate('preferencesPage.receiveRelevantFeatureUpdatesAndExpensifyNews')}
-
-
-
-
-
-
-
- User.updateChatPriorityMode(mode)
- }
- items={_.values(priorityModes)}
- value={props.priorityMode}
- />
-
-
- {priorityModes[props.priorityMode].description}
-
-
-
-
-
- {/* If we are in the staging environment then we enable additional test features */}
- {_.contains([CONST.ENVIRONMENT.STAGING, CONST.ENVIRONMENT.DEV], props.environment) && }
-
-
-
- );
-};
-
-PreferencesPage.propTypes = propTypes;
-PreferencesPage.defaultProps = defaultProps;
-PreferencesPage.displayName = 'PreferencesPage';
-
-export default compose(
- withEnvironment,
- withLocalize,
- withOnyx({
- priorityMode: {
- key: ONYXKEYS.NVP_PRIORITY_MODE,
- },
- user: {
- key: ONYXKEYS.USER,
- },
- }),
-)(PreferencesPage);
diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js
index 3d0336b45358..9e4e948237f4 100644
--- a/src/pages/settings/Profile/DisplayNamePage.js
+++ b/src/pages/settings/Profile/DisplayNamePage.js
@@ -1,12 +1,10 @@
import lodashGet from 'lodash/get';
-import _ from 'underscore';
import React, {Component} from 'react';
import {View} from 'react-native';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails';
import ScreenWrapper from '../../../components/ScreenWrapper';
import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
-import * as Localize from '../../../libs/Localize';
import ROUTES from '../../../ROUTES';
import Form from '../../../components/Form';
import ONYXKEYS from '../../../ONYXKEYS';
@@ -58,59 +56,21 @@ class DisplayNamePage extends Component {
validate(values) {
const errors = {};
- // Check for invalid characters in first and last name
- const [firstNameInvalidCharacter, lastNameInvalidCharacter] = ValidationUtils.findInvalidSymbols(
- [values.firstName, values.lastName],
- );
- this.assignError(
- errors,
- 'firstName',
- !_.isEmpty(firstNameInvalidCharacter),
- Localize.translateLocal(
- 'personalDetails.error.hasInvalidCharacter',
- {invalidCharacter: firstNameInvalidCharacter},
- ),
- );
- this.assignError(
- errors,
- 'lastName',
- !_.isEmpty(lastNameInvalidCharacter),
- Localize.translateLocal(
- 'personalDetails.error.hasInvalidCharacter',
- {invalidCharacter: lastNameInvalidCharacter},
- ),
- );
- if (!_.isEmpty(errors)) {
- return errors;
+ // First we validate the first name field
+ if (!ValidationUtils.isValidDisplayName(values.firstName)) {
+ errors.firstName = this.props.translate('personalDetails.error.hasInvalidCharacter');
+ } else if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_FIRST_NAMES)) {
+ errors.firstName = this.props.translate('personalDetails.error.containsReservedWord');
}
- // Check the character limit for first and last name
- const characterLimitError = Localize.translateLocal('personalDetails.error.characterLimit', {limit: CONST.FORM_CHARACTER_LIMIT});
- const [hasFirstNameError, hasLastNameError] = ValidationUtils.doesFailCharacterLimitAfterTrim(
- CONST.FORM_CHARACTER_LIMIT,
- [values.firstName, values.lastName],
- );
- this.assignError(errors, 'firstName', hasFirstNameError, characterLimitError);
- this.assignError(errors, 'lastName', hasLastNameError, characterLimitError);
+ // Then we validate the last name field
+ if (!ValidationUtils.isValidDisplayName(values.lastName)) {
+ errors.lastName = this.props.translate('personalDetails.error.hasInvalidCharacter');
+ }
return errors;
}
- /**
- * @param {Object} errors
- * @param {String} errorKey
- * @param {Boolean} hasError
- * @param {String} errorCopy
- * @returns {Object} - An object containing the errors for each inputID
- */
- assignError(errors, errorKey, hasError, errorCopy) {
- const validateErrors = errors;
- if (hasError) {
- validateErrors[errorKey] = errorCopy;
- }
- return validateErrors;
- }
-
render() {
const currentUserDetails = this.props.currentUserPersonalDetails || {};
@@ -140,6 +100,7 @@ class DisplayNamePage extends Component {
label={this.props.translate('common.firstName')}
defaultValue={lodashGet(currentUserDetails, 'firstName', '')}
placeholder={this.props.translate('displayNamePage.john')}
+ maxLength={CONST.DISPLAY_NAME.MAX_LENGTH}
/>
@@ -149,6 +110,7 @@ class DisplayNamePage extends Component {
label={this.props.translate('common.lastName')}
defaultValue={lodashGet(currentUserDetails, 'lastName', '')}
placeholder={this.props.translate('displayNamePage.doe')}
+ maxLength={CONST.DISPLAY_NAME.MAX_LENGTH}
/>
diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
new file mode 100644
index 000000000000..848373fe2cc9
--- /dev/null
+++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
@@ -0,0 +1,226 @@
+import lodashGet from 'lodash/get';
+import _ from 'underscore';
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST';
+import {withOnyx} from 'react-native-onyx';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import ROUTES from '../../../../ROUTES';
+import Form from '../../../../components/Form';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import CONST from '../../../../CONST';
+import TextInput from '../../../../components/TextInput';
+import styles from '../../../../styles/styles';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import * as PersonalDetails from '../../../../libs/actions/PersonalDetails';
+import compose from '../../../../libs/compose';
+import AddressSearch from '../../../../components/AddressSearch';
+import CountryPicker from '../../../../components/CountryPicker';
+import StatePicker from '../../../../components/StatePicker';
+
+const propTypes = {
+ /* Onyx Props */
+
+ /** User's private personal details */
+ privatePersonalDetails: PropTypes.shape({
+ /** User's home address */
+ address: PropTypes.shape({
+ street: PropTypes.string,
+ city: PropTypes.string,
+ state: PropTypes.string,
+ zip: PropTypes.string,
+ country: PropTypes.string,
+ }),
+ }),
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ privatePersonalDetails: {
+ address: {
+ street: '',
+ city: '',
+ state: '',
+ zip: '',
+ country: '',
+ },
+ },
+};
+
+class AddressPage extends Component {
+ constructor(props) {
+ super(props);
+
+ this.validate = this.validate.bind(this);
+ this.updateAddress = this.updateAddress.bind(this);
+ this.onCountryUpdate = this.onCountryUpdate.bind(this);
+
+ const currentCountry = lodashGet(props.privatePersonalDetails, 'address.country') || '';
+ this.state = {
+ isUsaForm: currentCountry === CONST.USA_COUNTRY_NAME,
+ };
+ }
+
+ /**
+ * @param {String} newCountry - new country selected in form
+ */
+ onCountryUpdate(newCountry) {
+ if (newCountry === CONST.USA_COUNTRY_NAME) {
+ this.setState({isUsaForm: true});
+ } else {
+ this.setState({isUsaForm: false});
+ }
+ }
+
+ /**
+ * Submit form to update user's first and last legal name
+ * @param {Object} values - form input values
+ */
+ updateAddress(values) {
+ PersonalDetails.updateAddress(
+ values.addressLine1.trim(),
+ values.addressLine2.trim(),
+ values.city.trim(),
+ values.state.trim(),
+ values.zipPostCode,
+ values.country,
+ );
+ }
+
+ /**
+ * @param {Object} values - form input values
+ * @returns {Object} - An object containing the errors for each inputID
+ */
+ validate(values) {
+ const errors = {};
+
+ const requiredFields = [
+ 'addressLine1',
+ 'city',
+ 'zipPostCode',
+ 'country',
+ 'state',
+ ];
+
+ // Check "State" dropdown is a valid state if selected Country is USA.
+ if (this.state.isUsaForm && !COMMON_CONST.STATES[values.state]) {
+ errors.state = this.props.translate('common.error.fieldRequired');
+ }
+
+ // Add "Field required" errors if any required field is empty
+ _.each(requiredFields, (fieldKey) => {
+ if (!_.isEmpty(values[fieldKey])) {
+ return;
+ }
+ errors[fieldKey] = this.props.translate('common.error.fieldRequired');
+ });
+
+ return errors;
+ }
+
+ render() {
+ const address = lodashGet(this.props.privatePersonalDetails, 'address') || {};
+ const [street1, street2] = (address.street || '').split('\n');
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {this.state.isUsaForm ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+AddressPage.propTypes = propTypes;
+AddressPage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+ }),
+)(AddressPage);
diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
new file mode 100644
index 000000000000..8ca38db07014
--- /dev/null
+++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
@@ -0,0 +1,119 @@
+import _ from 'underscore';
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import ROUTES from '../../../../ROUTES';
+import Form from '../../../../components/Form';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import * as ValidationUtils from '../../../../libs/ValidationUtils';
+import styles from '../../../../styles/styles';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import * as PersonalDetails from '../../../../libs/actions/PersonalDetails';
+import compose from '../../../../libs/compose';
+import DatePicker from '../../../../components/DatePicker';
+
+const propTypes = {
+ /* Onyx Props */
+
+ /** User's private personal details */
+ privatePersonalDetails: PropTypes.shape({
+ dob: PropTypes.string,
+ }),
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ privatePersonalDetails: {
+ dob: '',
+ },
+};
+
+class DateOfBirthPage extends Component {
+ constructor(props) {
+ super(props);
+
+ this.validate = this.validate.bind(this);
+ this.updateDateOfBirth = this.updateDateOfBirth.bind(this);
+ }
+
+ /**
+ * Submit form to update user's first and last legal name
+ * @param {Object} values
+ * @param {String} values.dob - date of birth
+ */
+ updateDateOfBirth(values) {
+ PersonalDetails.updateDateOfBirth(
+ values.dob.trim(),
+ );
+ }
+
+ /**
+ * @param {Object} values
+ * @param {String} values.dob - date of birth
+ * @returns {Object} - An object containing the errors for each inputID
+ */
+ validate(values) {
+ const errors = {};
+ const minimumAge = 5;
+ const maximumAge = 150;
+
+ if (_.isEmpty(values.dob)) {
+ errors.dob = this.props.translate('common.error.fieldRequired');
+ }
+ const dateError = ValidationUtils.getAgeRequirementError(values.dob, minimumAge, maximumAge);
+ if (dateError) {
+ errors.dob = dateError;
+ }
+
+ return errors;
+ }
+
+ render() {
+ const privateDetails = this.props.privatePersonalDetails || {};
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+
+
+
+ );
+ }
+}
+
+DateOfBirthPage.propTypes = propTypes;
+DateOfBirthPage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+ }),
+)(DateOfBirthPage);
diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js
new file mode 100644
index 000000000000..dee460123f1e
--- /dev/null
+++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js
@@ -0,0 +1,137 @@
+import _ from 'underscore';
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import ROUTES from '../../../../ROUTES';
+import Form from '../../../../components/Form';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import CONST from '../../../../CONST';
+import * as ValidationUtils from '../../../../libs/ValidationUtils';
+import TextInput from '../../../../components/TextInput';
+import styles from '../../../../styles/styles';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import * as PersonalDetails from '../../../../libs/actions/PersonalDetails';
+import compose from '../../../../libs/compose';
+
+const propTypes = {
+ /* Onyx Props */
+
+ /** User's private personal details */
+ privatePersonalDetails: PropTypes.shape({
+ legalFirstName: PropTypes.string,
+ legalLastName: PropTypes.string,
+ }),
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ privatePersonalDetails: {
+ legalFirstName: '',
+ legalLastName: '',
+ },
+};
+
+class LegalNamePage extends Component {
+ constructor(props) {
+ super(props);
+
+ this.validate = this.validate.bind(this);
+ this.updateLegalName = this.updateLegalName.bind(this);
+ }
+
+ /**
+ * Submit form to update user's legal first and last name
+ * @param {Object} values
+ * @param {String} values.legalFirstName
+ * @param {String} values.legalLastName
+ */
+ updateLegalName(values) {
+ PersonalDetails.updateLegalName(
+ values.legalFirstName.trim(),
+ values.legalLastName.trim(),
+ );
+ }
+
+ /**
+ * @param {Object} values
+ * @param {String} values.legalFirstName
+ * @param {String} values.legalLastName
+ * @returns {Object} - An object containing the errors for each inputID
+ */
+ validate(values) {
+ const errors = {};
+
+ if (!ValidationUtils.isValidDisplayName(values.legalFirstName)) {
+ errors.legalFirstName = this.props.translate('personalDetails.error.hasInvalidCharacter');
+ } else if (_.isEmpty(values.legalFirstName)) {
+ errors.legalFirstName = this.props.translate('common.error.fieldRequired');
+ }
+
+ if (!ValidationUtils.isValidDisplayName(values.legalLastName)) {
+ errors.legalLastName = this.props.translate('personalDetails.error.hasInvalidCharacter');
+ } else if (_.isEmpty(values.legalLastName)) {
+ errors.legalLastName = this.props.translate('common.error.fieldRequired');
+ }
+
+ return errors;
+ }
+
+ render() {
+ const privateDetails = this.props.privatePersonalDetails || {};
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+LegalNamePage.propTypes = propTypes;
+LegalNamePage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+ }),
+)(LegalNamePage);
diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js
new file mode 100644
index 000000000000..4da6ec6134e5
--- /dev/null
+++ b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js
@@ -0,0 +1,141 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {ScrollView, View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import ROUTES from '../../../../ROUTES';
+import Text from '../../../../components/Text';
+import styles from '../../../../styles/styles';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import compose from '../../../../libs/compose';
+import MenuItemWithTopDescription from '../../../../components/MenuItemWithTopDescription';
+import * as PersonalDetails from '../../../../libs/actions/PersonalDetails';
+import ONYXKEYS from '../../../../ONYXKEYS';
+
+const propTypes = {
+ /* Onyx Props */
+
+ /** User's private personal details */
+ privatePersonalDetails: PropTypes.shape({
+ legalFirstName: PropTypes.string,
+ legalLastName: PropTypes.string,
+ dob: PropTypes.string,
+
+ /** User's home address */
+ address: PropTypes.shape({
+ street: PropTypes.string,
+ city: PropTypes.string,
+ state: PropTypes.string,
+ zip: PropTypes.string,
+ country: PropTypes.string,
+ }),
+ }),
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ privatePersonalDetails: {
+ legalFirstName: '',
+ legalLastName: '',
+ dob: '',
+ address: {
+ street: '',
+ street2: '',
+ city: '',
+ state: '',
+ zip: '',
+ country: '',
+ },
+ },
+};
+
+const PersonalDetailsInitialPage = (props) => {
+ PersonalDetails.openPersonalDetailsPage();
+
+ const privateDetails = props.privatePersonalDetails || {};
+ const address = privateDetails.address || {};
+ const legalName = `${privateDetails.legalFirstName || ''} ${privateDetails.legalLastName || ''}`.trim();
+
+ /**
+ * Applies common formatting to each piece of an address
+ *
+ * @param {String} piece
+ * @returns {String}
+ */
+ const formatPiece = piece => (piece ? `${piece}, ` : '');
+
+ /**
+ * Formats an address object into an easily readable string
+ *
+ * @returns {String}
+ */
+ const getFormattedAddress = () => {
+ const [street1, street2] = (address.street || '').split('\n');
+ const formattedAddress = formatPiece(street1)
+ + formatPiece(street2)
+ + formatPiece(address.city)
+ + formatPiece(address.state)
+ + formatPiece(address.zip)
+ + formatPiece(address.country);
+
+ // Remove the last comma of the address
+ return formattedAddress.trim().replace(/,$/, '');
+ };
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_PROFILE)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+
+ {props.translate('privatePersonalDetails.privateDataMessage')}
+
+
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME)}
+ />
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH)}
+ />
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)}
+ />
+
+
+
+ );
+};
+
+PersonalDetailsInitialPage.propTypes = propTypes;
+PersonalDetailsInitialPage.defaultProps = defaultProps;
+PersonalDetailsInitialPage.displayName = 'PersonalDetailsInitialPage';
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+ }),
+)(PersonalDetailsInitialPage);
diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js
index 7057bf514190..3af2527a7b3f 100755
--- a/src/pages/settings/Profile/ProfilePage.js
+++ b/src/pages/settings/Profile/ProfilePage.js
@@ -8,6 +8,7 @@ import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import AvatarWithImagePicker from '../../../components/AvatarWithImagePicker';
import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton';
+import MenuItem from '../../../components/MenuItem';
import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription';
import OfflineWithFeedback from '../../../components/OfflineWithFeedback';
import ScreenWrapper from '../../../components/ScreenWrapper';
@@ -22,6 +23,7 @@ import ONYXKEYS from '../../../ONYXKEYS';
import ROUTES from '../../../ROUTES';
import styles from '../../../styles/styles';
import LoginField from './LoginField';
+import * as Expensicons from '../../../components/Icon/Expensicons';
const propTypes = {
/* Onyx Props */
@@ -133,7 +135,7 @@ class ProfilePage extends Component {
},
];
return (
-
+
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)}
+ shouldShowRightIcon
+ />
);
diff --git a/src/pages/settings/Profile/PronounsPage.js b/src/pages/settings/Profile/PronounsPage.js
index 86651877898d..e6692a107b1f 100644
--- a/src/pages/settings/Profile/PronounsPage.js
+++ b/src/pages/settings/Profile/PronounsPage.js
@@ -53,23 +53,27 @@ const PronounsPage = (props) => {
return (
- Navigation.navigate(ROUTES.SETTINGS_PROFILE)}
- onCloseButtonPress={() => Navigation.dismissModal(true)}
- />
-
- {props.translate('pronounsPage.isShownOnProfile')}
-
- updatePronouns(option.value)}
- hideSectionHeaders
- optionHoveredStyle={styles.hoveredComponentBG}
- shouldHaveOptionSeparator
- contentContainerStyles={[styles.ph5]}
- />
+ {({safeAreaPaddingBottomStyle}) => (
+ <>
+ Navigation.navigate(ROUTES.SETTINGS_PROFILE)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+ {props.translate('pronounsPage.isShownOnProfile')}
+
+ updatePronouns(option.value)}
+ hideSectionHeaders
+ optionHoveredStyle={styles.hoveredComponentBG}
+ shouldHaveOptionSeparator
+ contentContainerStyles={[styles.ph5, safeAreaPaddingBottomStyle]}
+ />
+ >
+ )}
);
};
diff --git a/src/pages/settings/Profile/TimezoneSelectPage.js b/src/pages/settings/Profile/TimezoneSelectPage.js
index 7a89ac8ce73d..fd91f9a2e91f 100644
--- a/src/pages/settings/Profile/TimezoneSelectPage.js
+++ b/src/pages/settings/Profile/TimezoneSelectPage.js
@@ -76,21 +76,26 @@ class TimezoneSelectPage extends Component {
render() {
return (
- Navigation.navigate(ROUTES.SETTINGS_TIMEZONE)}
- onCloseButtonPress={() => Navigation.dismissModal(true)}
- />
-
+ {({safeAreaPaddingBottomStyle}) => (
+ <>
+ Navigation.navigate(ROUTES.SETTINGS_TIMEZONE)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+ >
+ )}
);
}
diff --git a/src/pages/signin/ChangeExpensifyLoginLink.js b/src/pages/signin/ChangeExpensifyLoginLink.js
index 8289a754deaf..fdc721317c9b 100755
--- a/src/pages/signin/ChangeExpensifyLoginLink.js
+++ b/src/pages/signin/ChangeExpensifyLoginLink.js
@@ -1,6 +1,7 @@
import React from 'react';
import {TouchableOpacity, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
import PropTypes from 'prop-types';
import Str from 'expensify-common/lib/str';
import Text from '../../components/Text';
@@ -30,14 +31,16 @@ const defaultProps = {
const ChangeExpensifyLoginLink = props => (
-
- {props.translate('common.not')}
-
- {Str.isSMSLogin(props.credentials.login || '')
- ? props.toLocalPhone(Str.removeSMSDomain(props.credentials.login || ''))
- : Str.removeSMSDomain(props.credentials.login || '')}
- {'? '}
-
+ {!_.isEmpty(props.credentials.login) && (
+
+ {props.translate('common.not')}
+
+ {Str.isSMSLogin(props.credentials.login || '')
+ ? props.toLocalPhone(Str.removeSMSDomain(props.credentials.login || ''))
+ : Str.removeSMSDomain(props.credentials.login || '')}
+ {'? '}
+
+ )}
@@ -183,6 +183,7 @@ class LoginForm extends React.Component {
autoCapitalize="none"
autoCorrect={false}
keyboardType={CONST.KEYBOARD_TYPE.EMAIL_ADDRESS}
+ errorText={formErrorText}
/>
{!_.isEmpty(this.props.account.success) && (
@@ -203,8 +204,8 @@ class LoginForm extends React.Component {
buttonText={this.props.translate('common.continue')}
isLoading={this.props.account.isLoading}
onSubmit={this.validateAndSubmitForm}
- message={error}
- isAlertVisible={!_.isEmpty(error)}
+ message={serverErrorText}
+ isAlertVisible={!_.isEmpty(serverErrorText)}
containerStyles={[styles.mh0]}
/>
diff --git a/src/pages/signin/PasswordForm.js b/src/pages/signin/PasswordForm.js
index 30ec93d71ec5..7d8813313089 100755
--- a/src/pages/signin/PasswordForm.js
+++ b/src/pages/signin/PasswordForm.js
@@ -24,6 +24,7 @@ import * as ErrorUtils from '../../libs/ErrorUtils';
import {withNetwork} from '../../components/OnyxProvider';
import networkPropTypes from '../../components/networkPropTypes';
import OfflineIndicator from '../../components/OfflineIndicator';
+import FormHelpMessage from '../../components/FormHelpMessage';
const propTypes = {
/* Onyx Props */
@@ -56,7 +57,7 @@ class PasswordForm extends React.Component {
this.clearSignInData = this.clearSignInData.bind(this);
this.state = {
- formError: false,
+ formError: {},
password: '',
twoFactorAuthCode: '',
};
@@ -84,6 +85,23 @@ class PasswordForm extends React.Component {
}
}
+ /**
+ * Handle text input and clear formError upon text change
+ *
+ * @param {String} text
+ * @param {String} key
+ */
+ onTextInput(text, key) {
+ this.setState({
+ [key]: text,
+ formError: {[key]: ''},
+ });
+
+ if (this.props.account.errors) {
+ Session.clearAccountMessages();
+ }
+ }
+
/**
* Clear Password from the state
*/
@@ -98,7 +116,7 @@ class PasswordForm extends React.Component {
if (this.input2FA) {
this.setState({twoFactorAuthCode: ''}, this.input2FA.clear);
}
- this.setState({formError: false});
+ this.setState({formError: {}});
Session.resetPassword();
}
@@ -106,7 +124,7 @@ class PasswordForm extends React.Component {
* Clears local and Onyx sign in states
*/
clearSignInData() {
- this.setState({twoFactorAuthCode: '', formError: false});
+ this.setState({twoFactorAuthCode: '', formError: {}});
Session.clearSignInData();
}
@@ -118,33 +136,28 @@ class PasswordForm extends React.Component {
const twoFactorCode = this.state.twoFactorAuthCode.trim();
const requiresTwoFactorAuth = this.props.account.requiresTwoFactorAuth;
- if (!password && requiresTwoFactorAuth && !twoFactorCode) {
- this.setState({formError: 'passwordForm.pleaseFillOutAllFields'});
- return;
- }
-
if (!password) {
- this.setState({formError: 'passwordForm.pleaseFillPassword'});
+ this.setState({formError: {password: 'passwordForm.pleaseFillPassword'}});
return;
}
if (!ValidationUtils.isValidPassword(password)) {
- this.setState({formError: 'passwordForm.error.incorrectPassword'});
+ this.setState({formError: {password: 'passwordForm.error.incorrectPassword'}});
return;
}
if (requiresTwoFactorAuth && !twoFactorCode) {
- this.setState({formError: 'passwordForm.pleaseFillTwoFactorAuth'});
+ this.setState({formError: {twoFactorAuthCode: 'passwordForm.pleaseFillTwoFactorAuth'}});
return;
}
if (requiresTwoFactorAuth && !ValidationUtils.isValidTwoFactorCode(twoFactorCode)) {
- this.setState({formError: 'passwordForm.error.incorrect2fa'});
+ this.setState({formError: {twoFactorAuthCode: 'passwordForm.error.incorrect2fa'}});
return;
}
this.setState({
- formError: null,
+ formError: {},
});
Session.signIn(password, '', twoFactorCode);
@@ -163,9 +176,10 @@ class PasswordForm extends React.Component {
nativeID="password"
name="password"
value={this.state.password}
- onChangeText={text => this.setState({password: text})}
+ onChangeText={text => this.onTextInput(text, 'password')}
onSubmitEditing={this.validateAndSubmitForm}
blurOnSubmit={false}
+ errorText={this.state.formError.password ? this.props.translate(this.state.formError.password) : ''}
/>
this.setState({twoFactorAuthCode: text})}
+ onChangeText={text => this.onTextInput(text, 'twoFactorAuthCode')}
onSubmitEditing={this.validateAndSubmitForm}
keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
blurOnSubmit={false}
maxLength={CONST.TFA_CODE_LENGTH}
+ errorText={this.state.formError.twoFactorAuthCode ? this.props.translate(this.state.formError.twoFactorAuthCode) : ''}
/>
)}
- {!this.state.formError && this.props.account && !_.isEmpty(this.props.account.errors) && (
-
- {ErrorUtils.getLatestErrorMessage(this.props.account)}
-
- )}
-
- {this.state.formError && (
-
- {this.props.translate(this.state.formError)}
-
+ {this.props.account && !_.isEmpty(this.props.account.errors) && (
+
)}
this.setState({twoFactorAuthCode: text})}
+ onChangeText={text => this.onTextInput(text, 'twoFactorAuthCode')}
onSubmitEditing={this.validateAndSubmitForm}
keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
blurOnSubmit={false}
maxLength={CONST.TFA_CODE_LENGTH}
+ errorText={this.state.formError.twoFactorAuthCode ? this.props.translate(this.state.formError.twoFactorAuthCode) : ''}
/>
) : (
this.inputValidateCode = el}
label={this.props.translate('common.magicCode')}
nativeID="validateCode"
name="validateCode"
value={this.state.validateCode}
- onChangeText={text => this.setState({validateCode: text})}
+ onChangeText={text => this.onTextInput(text, 'validateCode')}
onSubmitEditing={this.validateAndSubmitForm}
blurOnSubmit={false}
+ keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ errorText={this.state.formError.validateCode ? this.props.translate(this.state.formError.validateCode) : ''}
+ autoFocus
/>
)}
- {!this.state.formError && this.props.account && !_.isEmpty(this.props.account.errors) && (
-
- {ErrorUtils.getLatestErrorMessage(this.props.account)}
-
- )}
-
- {this.state.formError && (
-
- {this.props.translate(this.state.formError)}
-
+ {this.props.account && !_.isEmpty(this.props.account.errors) && (
+
)}
- Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)}
- >
- (
+ Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)}
- onCloseButtonPress={() => Navigation.dismissModal()}
- shouldShowThreeDotsButton
- shouldShowGetAssistanceButton
- guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_INITIAL}
- threeDotsMenuItems={[
- {
- icon: Expensicons.Trashcan,
- text: this.props.translate('workspace.common.delete'),
- onSelected: () => this.setState({isDeleteModalOpen: true}),
- },
- ]}
- threeDotsAnchorPosition={styles.threeDotsPopoverOffset}
- />
-
- Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)}
+ onCloseButtonPress={() => Navigation.dismissModal()}
+ shouldShowThreeDotsButton
+ shouldShowGetAssistanceButton
+ guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_INITIAL}
+ threeDotsMenuItems={[
+ {
+ icon: Expensicons.Trashcan,
+ text: this.props.translate('workspace.common.delete'),
+ onSelected: () => this.setState({isDeleteModalOpen: true}),
+ },
+ ]}
+ threeDotsAnchorPosition={styles.threeDotsPopoverOffset}
+ />
+
-
-
-
-
- {this.props.policy.avatar
- ? (
-
- )
- : (
-
- )}
-
- {!_.isEmpty(this.props.policy.name) && (
+
+
+
+
-
-
- {this.props.policy.name}
-
-
+ {this.props.policy.avatar
+ ? (
+
+ )
+ : (
+
+ )}
- )}
+ {!_.isEmpty(this.props.policy.name) && (
+
+
+
+ {this.props.policy.name}
+
+
+
+ )}
+
+ {_.map(menuItems, item => (
+ item.action()}
+ shouldShowRightIcon
+ brickRoadIndicator={item.brickRoadIndicator}
+ />
+ ))}
- {_.map(menuItems, item => (
- item.action()}
- shouldShowRightIcon
- brickRoadIndicator={item.brickRoadIndicator}
- />
- ))}
-
-
-
- this.toggleDeleteModal(false)}
- prompt={this.props.translate('workspace.common.deleteConfirmation')}
- confirmText={this.props.translate('common.delete')}
- cancelText={this.props.translate('common.cancel')}
- danger
- />
-
+
+
+ this.toggleDeleteModal(false)}
+ prompt={this.props.translate('workspace.common.deleteConfirmation')}
+ confirmText={this.props.translate('common.delete')}
+ cancelText={this.props.translate('common.cancel')}
+ danger
+ />
+
+ )}
);
}
diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js
index 71aaf2aa4195..6b4c50edf7fd 100644
--- a/src/pages/workspace/WorkspaceMembersPage.js
+++ b/src/pages/workspace/WorkspaceMembersPage.js
@@ -307,70 +307,73 @@ class WorkspaceMembersPage extends React.Component {
includeSafeAreaPaddingBottom={false}
style={[styles.defaultModalContainer]}
>
- Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)}
- >
- Navigation.dismissModal()}
- onBackButtonPress={() => Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policyID))}
- shouldShowGetAssistanceButton
- guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
- shouldShowBackButton
- />
- this.removeUsers()}
- onCancel={this.hideConfirmModal}
- prompt={this.props.translate('workspace.people.removeMembersPrompt')}
- confirmText={this.props.translate('common.remove')}
- cancelText={this.props.translate('common.cancel')}
- />
-
-
-
-
-
-
-
-
- _.contains(this.state.selectedEmployees, member))}
- onPress={() => this.toggleAllUsers()}
- />
-
-
-
- {this.props.translate('workspace.people.selectAll')}
-
+ {({safeAreaPaddingBottomStyle}) => (
+ Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)}
+ >
+ Navigation.dismissModal()}
+ onBackButtonPress={() => Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policyID))}
+ shouldShowGetAssistanceButton
+ guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
+ shouldShowBackButton
+ />
+ this.removeUsers()}
+ onCancel={this.hideConfirmModal}
+ prompt={this.props.translate('workspace.people.removeMembersPrompt')}
+ confirmText={this.props.translate('common.remove')}
+ cancelText={this.props.translate('common.cancel')}
+ />
+
+
+
+
+
+
+
+
+ _.contains(this.state.selectedEmployees, member))}
+ onPress={() => this.toggleAllUsers()}
+ />
+
+
+
+ {this.props.translate('workspace.people.selectAll')}
+
+
+ item.login}
+ showsVerticalScrollIndicator
+ style={[styles.ph5, styles.pb5]}
+ contentContainerStyle={safeAreaPaddingBottomStyle}
+ />
- item.login}
- showsVerticalScrollIndicator
- style={[styles.ph5, styles.pb5]}
- />
-
-
+
+ )}
);
}
diff --git a/src/pages/workspace/WorkspaceResetBankAccountModal.js b/src/pages/workspace/WorkspaceResetBankAccountModal.js
index 835bb43ee9cd..91059b737494 100644
--- a/src/pages/workspace/WorkspaceResetBankAccountModal.js
+++ b/src/pages/workspace/WorkspaceResetBankAccountModal.js
@@ -1,4 +1,5 @@
import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
import React from 'react';
import ConfirmModal from '../../components/ConfirmModal';
import * as BankAccounts from '../../libs/actions/BankAccounts';
@@ -12,9 +13,16 @@ const propTypes = {
/** Reimbursement account data */
reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes.isRequired,
+ /** Callback when the user confirms resetting the workspace bank account */
+ onConfirm: PropTypes.func,
+
...withLocalizePropTypes,
};
+const defaultProps = {
+ onConfirm: () => {},
+};
+
const WorkspaceResetBankAccountModal = (props) => {
const achData = lodashGet(props.reimbursementAccount, 'achData') || {};
const isInOpenState = achData.state === BankAccount.STATE.OPEN;
@@ -37,7 +45,10 @@ const WorkspaceResetBankAccountModal = (props) => {
) : props.translate('workspace.bankAccount.clearProgress')}
danger
onCancel={BankAccounts.cancelResetFreePlanBankAccount}
- onConfirm={() => BankAccounts.resetFreePlanBankAccount(bankAccountID)}
+ onConfirm={() => {
+ BankAccounts.resetFreePlanBankAccount(bankAccountID);
+ props.onConfirm();
+ }}
shouldShowCancelButton
isVisible
/>
@@ -46,5 +57,6 @@ const WorkspaceResetBankAccountModal = (props) => {
WorkspaceResetBankAccountModal.displayName = 'WorkspaceResetBankAccountModal';
WorkspaceResetBankAccountModal.propTypes = propTypes;
+WorkspaceResetBankAccountModal.defaultProps = defaultProps;
export default withLocalize(WorkspaceResetBankAccountModal);
diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js
index 7d609aabec9b..de014170abf9 100644
--- a/src/pages/workspace/WorkspaceSettingsPage.js
+++ b/src/pages/workspace/WorkspaceSettingsPage.js
@@ -126,6 +126,7 @@ class WorkspaceSettingsPage extends React.Component {
label={this.props.translate('workspace.editor.nameInputLabel')}
containerStyles={[styles.mt4]}
defaultValue={this.props.policy.name}
+ maxLength={CONST.WORKSPACE_NAME_CHARACTER_LIMIT}
/>
this.setUnit(value)}
+ backgroundColor={themeColors.cardBG}
/>
diff --git a/src/styles/getModalStyles/getBaseModalStyles.js b/src/styles/getModalStyles/getBaseModalStyles.js
index aea136f6818a..36c98880f69a 100644
--- a/src/styles/getModalStyles/getBaseModalStyles.js
+++ b/src/styles/getModalStyles/getBaseModalStyles.js
@@ -2,11 +2,12 @@ import CONST from '../../CONST';
import variables from '../variables';
import themeColors from '../themes/default';
-export default (type, windowDimensions, popoverAnchorPosition = {}, containerStyle = {}) => {
+export default (type, windowDimensions, popoverAnchorPosition = {}, innerContainerStyle = {}, outerStyle = {}) => {
const {isSmallScreenWidth, windowWidth} = windowDimensions;
let modalStyle = {
margin: 0,
+ ...outerStyle,
};
let modalContainerStyle;
@@ -213,7 +214,7 @@ export default (type, windowDimensions, popoverAnchorPosition = {}, containerSty
animationOut = 'slideOutDown';
}
- modalContainerStyle = {...modalContainerStyle, ...containerStyle};
+ modalContainerStyle = {...modalContainerStyle, ...innerContainerStyle};
return {
modalStyle,
diff --git a/src/styles/getModalStyles/index.android.js b/src/styles/getModalStyles/index.android.js
index 9f2b0dae0720..69606478cca8 100644
--- a/src/styles/getModalStyles/index.android.js
+++ b/src/styles/getModalStyles/index.android.js
@@ -1,8 +1,8 @@
import getBaseModalStyles from './getBaseModalStyles';
// Only apply top padding on iOS since it's the only platform using SafeAreaView
-export default (type, windowDimensions, popoverAnchorPosition = {}, containerStyle = {}) => ({
- ...getBaseModalStyles(type, windowDimensions, popoverAnchorPosition, containerStyle),
+export default (type, windowDimensions, popoverAnchorPosition = {}, innerContainerStyle = {}) => ({
+ ...getBaseModalStyles(type, windowDimensions, popoverAnchorPosition, innerContainerStyle),
shouldAddTopSafeAreaMargin: false,
shouldAddTopSafeAreaPadding: false,
});
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 2b2a3498d510..520ca2d0c14d 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -559,7 +559,7 @@ const styles = {
height: 140,
},
- pickerSmall: {
+ pickerSmall: (backgroundColor = themeColors.highlightBG) => ({
inputIOS: {
fontFamily: fontFamily.EXP_NEUE,
fontSize: variables.fontSizeSmall,
@@ -574,6 +574,16 @@ const styles = {
opacity: 1,
backgroundColor: 'transparent',
},
+ done: {
+ color: themeColors.text,
+ },
+ modalViewMiddle: {
+ backgroundColor: themeColors.border,
+ borderTopWidth: 0,
+ },
+ modalViewBottom: {
+ backgroundColor: themeColors.highlightBG,
+ },
inputWeb: {
fontFamily: fontFamily.EXP_NEUE,
fontSize: variables.fontSizeSmall,
@@ -588,7 +598,7 @@ const styles = {
height: 26,
opacity: 1,
cursor: 'pointer',
- backgroundColor: 'transparent',
+ backgroundColor,
},
inputAndroid: {
fontFamily: fontFamily.EXP_NEUE,
@@ -602,6 +612,7 @@ const styles = {
color: themeColors.text,
height: 26,
opacity: 1,
+ backgroundColor: 'transparent',
},
iconContainer: {
top: 7,
@@ -611,7 +622,7 @@ const styles = {
width: variables.iconSizeExtraSmall,
height: variables.iconSizeExtraSmall,
},
- },
+ }),
badge: {
backgroundColor: themeColors.border,
@@ -812,13 +823,19 @@ const styles = {
textAlignVertical: 'center',
},
- textInputPrefix: {
+ textInputPrefixWrapper: {
position: 'absolute',
left: 0,
top: 0,
- height: '100%',
+ height: variables.inputHeight,
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
paddingTop: 23,
paddingBottom: 8,
+ },
+
+ textInputPrefix: {
color: themeColors.text,
fontFamily: fontFamily.EXP_NEUE,
fontSize: variables.fontSizeNormal,
@@ -833,6 +850,7 @@ const styles = {
justifyContent: 'center',
backgroundColor: 'transparent',
height: variables.inputHeight,
+ overflow: 'hidden',
},
pickerContainerSmall: {
@@ -843,24 +861,36 @@ const styles = {
position: 'absolute',
left: 0,
top: 6,
+ zIndex: 1,
},
- picker: (disabled = false) => ({
+ picker: (disabled = false, backgroundColor = themeColors.appBG) => ({
iconContainer: {
top: Math.round(variables.inputHeight * 0.5) - 11,
right: 0,
- zIndex: -1,
+ ...pointerEventsNone,
},
inputWeb: {
appearance: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
...picker,
+ backgroundColor,
},
inputIOS: {
...picker,
},
+ done: {
+ color: themeColors.text,
+ },
+ modalViewMiddle: {
+ backgroundColor: themeColors.border,
+ borderTopWidth: 0,
+ },
+ modalViewBottom: {
+ backgroundColor: themeColors.highlightBG,
+ },
inputAndroid: {
...picker,
@@ -1460,13 +1490,6 @@ const styles = {
width: '100%',
},
- emojiHeaderStyle: {
- fontFamily: fontFamily.EXP_NEUE_BOLD,
- fontWeight: fontWeightBold,
- color: themeColors.heading,
- fontSize: variables.fontSizeSmall,
- },
-
emojiSkinToneTitle: {
backgroundColor: themeColors.componentBG,
width: '100%',
@@ -1508,6 +1531,15 @@ const styles = {
borderRadius: variables.buttonBorderRadius,
},
+ categoryShortcutButton: {
+ flex: 1,
+ borderRadius: 8,
+ paddingTop: 2,
+ paddingBottom: 2,
+ height: CONST.EMOJI_PICKER_ITEM_HEIGHT,
+ justifyContent: 'center',
+ },
+
chatItemEmojiButton: {
alignSelf: 'flex-end',
borderRadius: variables.buttonBorderRadius,
@@ -2878,6 +2910,13 @@ const styles = {
paddingTop: 80,
paddingBottom: 45,
},
+
+ magicCodeDigits: {
+ color: themeColors.text,
+ fontFamily: fontFamily.EXP_NEUE,
+ fontSize: variables.fontSizeXXLarge,
+ letterSpacing: 4,
+ },
};
export default styles;
diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js
index 823f58c598ce..618859ea6813 100644
--- a/src/styles/themes/default.js
+++ b/src/styles/themes/default.js
@@ -58,7 +58,7 @@ const darkTheme = {
dropUIBG: 'rgba(6,27,9,0.92)',
dropTransparentOverlay: 'rgba(255,255,255,0)',
checkBox: colors.green,
- pickerOptionsTextColor: colors.midnight,
+ pickerOptionsTextColor: colors.white,
imageCropBackgroundColor: colors.greenIcons,
};
diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js
index d1683cead9b6..2283bd63d046 100644
--- a/src/styles/utilities/spacing.js
+++ b/src/styles/utilities/spacing.js
@@ -45,6 +45,14 @@ export default {
marginHorizontal: 20,
},
+ mh8: {
+ marginHorizontal: 32,
+ },
+
+ mhn5: {
+ marginHorizontal: -20,
+ },
+
mv1: {
marginVertical: 4,
},
@@ -89,6 +97,10 @@ export default {
marginRight: 20,
},
+ mr8: {
+ marginRight: 32,
+ },
+
mrn5: {
marginRight: -20,
},
@@ -342,6 +354,10 @@ export default {
paddingTop: 0,
},
+ pt1: {
+ paddingTop: 4,
+ },
+
pt2: {
paddingTop: 8,
},
diff --git a/src/styles/variables.js b/src/styles/variables.js
index f02271a0dc20..a3710ff435e3 100644
--- a/src/styles/variables.js
+++ b/src/styles/variables.js
@@ -92,4 +92,9 @@ export default {
checkboxLabelActiveOpacity: 0.7,
avatarChatSpacing: 12,
chatInputSpacing: 52, // 40 + avatarChatSpacing
+ modalTopIconWidth: 200,
+ modalTopIconHeight: 164,
+ modalTopBigIconHeight: 244,
+ modalWordmarkWidth: 154,
+ modalWordmarkHeight: 34,
};
diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js
index e0ee0d6be6d6..17f7b8f2d471 100644
--- a/tests/actions/ReportTest.js
+++ b/tests/actions/ReportTest.js
@@ -147,7 +147,7 @@ describe('actions/Report', () => {
it('should update pins in Onyx when togglePinned is called', () => {
const TEST_USER_ACCOUNT_ID = 1;
const TEST_USER_LOGIN = 'test@test.com';
- const REPORT_ID = 1;
+ const REPORT_ID = '1';
const REPORT = {
reportID: REPORT_ID,
isPinned: false,
@@ -174,7 +174,7 @@ describe('actions/Report', () => {
it('Should not leave duplicate comments when logger sends packet because of calling process queue while processing the queue', () => {
const TEST_USER_ACCOUNT_ID = 1;
const TEST_USER_LOGIN = 'test@test.com';
- const REPORT_ID = 1;
+ const REPORT_ID = '1';
const LOGGER_MAX_LOG_LINES = 50;
// GIVEN a test user with initial data
@@ -206,7 +206,7 @@ describe('actions/Report', () => {
});
it('should be updated correctly when new comments are added, deleted or marked as unread', () => {
- const REPORT_ID = 1;
+ const REPORT_ID = '1';
let report;
let reportActionCreatedDate;
let currentTime;
@@ -276,6 +276,7 @@ describe('actions/Report', () => {
expect(ReportUtils.isUnread(report)).toBe(true);
// When the user visits the report
+ jest.advanceTimersByTime(10);
currentTime = DateUtils.getDBTime();
Report.openReport(REPORT_ID);
return waitForPromisesToResolve();
@@ -286,6 +287,7 @@ describe('actions/Report', () => {
expect(moment.utc(report.lastReadTime).valueOf()).toBeGreaterThanOrEqual(moment.utc(currentTime).valueOf());
// When the user manually marks a message as "unread"
+ jest.advanceTimersByTime(10);
Report.markCommentAsUnread(REPORT_ID, reportActionCreatedDate);
return waitForPromisesToResolve();
})
@@ -295,6 +297,7 @@ describe('actions/Report', () => {
expect(report.lastReadTime).toBe(DateUtils.subtractMillisecondsFromDateTime(reportActionCreatedDate, 1));
// When a new comment is added by the current user
+ jest.advanceTimersByTime(10);
currentTime = DateUtils.getDBTime();
Report.addComment(REPORT_ID, 'Current User Comment 1');
return waitForPromisesToResolve();
@@ -306,6 +309,7 @@ describe('actions/Report', () => {
expect(report.lastMessageText).toBe('Current User Comment 1');
// When another comment is added by the current user
+ jest.advanceTimersByTime(10);
currentTime = DateUtils.getDBTime();
Report.addComment(REPORT_ID, 'Current User Comment 2');
return waitForPromisesToResolve();
@@ -317,6 +321,7 @@ describe('actions/Report', () => {
expect(report.lastMessageText).toBe('Current User Comment 2');
// When another comment is added by the current user
+ jest.advanceTimersByTime(10);
currentTime = DateUtils.getDBTime();
Report.addComment(REPORT_ID, 'Current User Comment 3');
return waitForPromisesToResolve();
@@ -338,6 +343,7 @@ describe('actions/Report', () => {
created: DateUtils.getDBTime(Date.now() - 3),
};
+ jest.advanceTimersByTime(10);
const optimisticReportActions = {
onyxMethod: CONST.ONYX.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
@@ -362,6 +368,7 @@ describe('actions/Report', () => {
},
},
};
+ jest.advanceTimersByTime(10);
reportActionCreatedDate = DateUtils.getDBTime();
optimisticReportActions.value[400].created = reportActionCreatedDate;
@@ -462,7 +469,7 @@ describe('actions/Report', () => {
it('should show a notification for report action updates with shouldNotify', () => {
const TEST_USER_ACCOUNT_ID = 1;
- const REPORT_ID = 1;
+ const REPORT_ID = '1';
const REPORT_ACTION = {};
// Setup user and pusher listeners
@@ -487,7 +494,7 @@ describe('actions/Report', () => {
return waitForPromisesToResolve();
}).then(() => {
// Ensure we show a notification for this new report action
- expect(Report.showReportActionNotification).toBeCalledWith(String(REPORT_ID), REPORT_ACTION);
+ expect(Report.showReportActionNotification).toBeCalledWith(REPORT_ID, REPORT_ACTION);
});
});
});
diff --git a/tests/actions/SessionTest.js b/tests/actions/SessionTest.js
index 560deaa85341..abecb1300d50 100644
--- a/tests/actions/SessionTest.js
+++ b/tests/actions/SessionTest.js
@@ -7,7 +7,6 @@ import ONYXKEYS from '../../src/ONYXKEYS';
import * as TestHelper from '../utils/TestHelper';
import CONST from '../../src/CONST';
import PushNotification from '../../src/libs/Notification/PushNotification';
-import * as Session from '../../src/libs/actions/Session';
// We are mocking this method so that we can later test to see if it was called and what arguments it was called with.
// We test HttpUtils.xhr() since this means that our API command turned into a network request and isn't only queued.
@@ -23,81 +22,77 @@ Onyx.init({
beforeEach(() => Onyx.clear().then(waitForPromisesToResolve));
-test('Authenticate is called with saved credentials when a session expires', () => {
- // Given a test user and set of authToken with subscriptions to session and credentials
- const TEST_USER_LOGIN = 'test@testguy.com';
- const TEST_USER_ACCOUNT_ID = 1;
- const TEST_INITIAL_AUTH_TOKEN = 'initialAuthToken';
- const TEST_REFRESHED_AUTH_TOKEN = 'refreshedAuthToken';
-
- let credentials;
- Onyx.connect({
- key: ONYXKEYS.CREDENTIALS,
- callback: val => credentials = val || {},
- });
-
- let session;
- Onyx.connect({
- key: ONYXKEYS.SESSION,
- callback: val => session = val,
- });
-
- // When we sign in with the test user
- return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, 'Password1', TEST_INITIAL_AUTH_TOKEN)
- .then(() => {
- // Then our re-authentication credentials should be generated and our session data
- // have the correct information + initial authToken.
- expect(credentials.login).toBe(TEST_USER_LOGIN);
- expect(credentials.autoGeneratedLogin).not.toBeUndefined();
- expect(credentials.autoGeneratedPassword).not.toBeUndefined();
- expect(session.authToken).toBe(TEST_INITIAL_AUTH_TOKEN);
- expect(session.accountID).toBe(TEST_USER_ACCOUNT_ID);
- expect(session.email).toBe(TEST_USER_LOGIN);
-
- // At this point we have an authToken. To simulate it expiring we'll just make another
- // request and mock the response so it returns 407. Once this happens we should attempt
- // to Re-Authenticate with the stored credentials. Our next call will be to Authenticate
- // so we will mock that response with a new authToken and then verify that Onyx has our
- // data.
- HttpUtils.xhr
-
- // This will make the call to DeprecatedAPI.Get() below return with an expired session code
- .mockImplementationOnce(() => Promise.resolve({
- jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED,
- }))
-
- // The next call should be Authenticate since we are reauthenticating
- .mockImplementationOnce(() => Promise.resolve({
- jsonCode: CONST.JSON_CODE.SUCCESS,
- accountID: TEST_USER_ACCOUNT_ID,
- authToken: TEST_REFRESHED_AUTH_TOKEN,
- email: TEST_USER_LOGIN,
- }));
-
- // When we attempt to fetch the chatList via the API
- DeprecatedAPI.Get({returnValueList: 'chatList'});
- return waitForPromisesToResolve();
- })
- .then(() => {
- // Then it should fail and reauthenticate the user adding the new authToken to the session
- // data in Onyx
- expect(session.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN);
+describe('Session', () => {
+ test('Authenticate is called with saved credentials when a session expires', () => {
+ // Given a test user and set of authToken with subscriptions to session and credentials
+ const TEST_USER_LOGIN = 'test@testguy.com';
+ const TEST_USER_ACCOUNT_ID = 1;
+ const TEST_INITIAL_AUTH_TOKEN = 'initialAuthToken';
+ const TEST_REFRESHED_AUTH_TOKEN = 'refreshedAuthToken';
+
+ let credentials;
+ Onyx.connect({
+ key: ONYXKEYS.CREDENTIALS,
+ callback: val => credentials = val || {},
});
-});
-test('Push notifications are subscribed after signing in', () => {
- TestHelper.signInWithTestUser();
+ let session;
+ Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: val => session = val,
+ });
- return waitForPromisesToResolve().then(() => {
- expect(PushNotification.register).toBeCalled();
+ // When we sign in with the test user
+ return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, 'Password1', TEST_INITIAL_AUTH_TOKEN)
+ .then(() => {
+ // Then our re-authentication credentials should be generated and our session data
+ // have the correct information + initial authToken.
+ expect(credentials.login).toBe(TEST_USER_LOGIN);
+ expect(credentials.autoGeneratedLogin).not.toBeUndefined();
+ expect(credentials.autoGeneratedPassword).not.toBeUndefined();
+ expect(session.authToken).toBe(TEST_INITIAL_AUTH_TOKEN);
+ expect(session.accountID).toBe(TEST_USER_ACCOUNT_ID);
+ expect(session.email).toBe(TEST_USER_LOGIN);
+
+ // At this point we have an authToken. To simulate it expiring we'll just make another
+ // request and mock the response so it returns 407. Once this happens we should attempt
+ // to Re-Authenticate with the stored credentials. Our next call will be to Authenticate
+ // so we will mock that response with a new authToken and then verify that Onyx has our
+ // data.
+ HttpUtils.xhr
+
+ // This will make the call to DeprecatedAPI.Get() below return with an expired session code
+ .mockImplementationOnce(() => Promise.resolve({
+ jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED,
+ }))
+
+ // The next call should be Authenticate since we are reauthenticating
+ .mockImplementationOnce(() => Promise.resolve({
+ jsonCode: CONST.JSON_CODE.SUCCESS,
+ accountID: TEST_USER_ACCOUNT_ID,
+ authToken: TEST_REFRESHED_AUTH_TOKEN,
+ email: TEST_USER_LOGIN,
+ }));
+
+ // When we attempt to fetch the chatList via the API
+ DeprecatedAPI.Get({returnValueList: 'chatList'});
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // Then it should fail and reauthenticate the user adding the new authToken to the session
+ // data in Onyx
+ expect(session.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN);
+ });
});
-});
-test('Push notifications are unsubscribed after signing out', () => {
- TestHelper.signInWithTestUser()
- .then(() => Session.signOut());
+ test('Push notifications are subscribed after signing in', () => (
+ TestHelper.signInWithTestUser()
+ .then(() => expect(PushNotification.register).toBeCalled())
+ ));
- return waitForPromisesToResolve().then(() => {
- expect(PushNotification.deregister).toBeCalled();
- });
+ test('Push notifications are unsubscribed after signing out', () => (
+ TestHelper.signInWithTestUser()
+ .then(TestHelper.signOutTestUser)
+ .then(() => expect(PushNotification.deregister).toBeCalled())
+ ));
});
diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js
index 6ab1d001ca0e..8a948069a085 100644
--- a/tests/ui/UnreadIndicatorsTest.js
+++ b/tests/ui/UnreadIndicatorsTest.js
@@ -1,7 +1,9 @@
import React from 'react';
import Onyx from 'react-native-onyx';
import {Linking, AppState} from 'react-native';
-import {fireEvent, render} from '@testing-library/react-native';
+import {
+ fireEvent, render, screen, waitFor,
+} from '@testing-library/react-native';
import lodashGet from 'lodash/get';
import moment from 'moment';
import App from '../../src/App';
@@ -23,6 +25,9 @@ import * as Pusher from '../../src/libs/Pusher/pusher';
import PusherConnectionManager from '../../src/libs/PusherConnectionManager';
import CONFIG from '../../src/CONFIG';
+// We need a large timeout here as we are lazy loading React Navigation screens and this test is running against the entire mounted App
+jest.setTimeout(30000);
+
jest.mock('../../src/libs/Notification/LocalNotification');
beforeAll(() => {
@@ -33,9 +38,7 @@ beforeAll(() => {
// simulate data arriving we will just set it into Onyx directly with Onyx.merge() or Onyx.set() etc.
global.fetch = TestHelper.getGlobalFetchMock();
- // We need a large timeout here as we are lazy loading React Navigation screens and this test is running against the entire mounted App
- jest.setTimeout(30000);
- Linking.setInitialURL('https://new.expensify.com/r/1');
+ Linking.setInitialURL('https://new.expensify.com/r');
appSetup();
// Connect to Pusher
@@ -47,11 +50,8 @@ beforeAll(() => {
});
});
-/**
- * @param {RenderAPI} renderedApp
- */
-function scrollUpToRevealNewMessagesBadge(renderedApp) {
- fireEvent.scroll(renderedApp.queryByA11yLabel('List of chat messages'), {
+function scrollUpToRevealNewMessagesBadge() {
+ fireEvent.scroll(screen.queryByLabelText('List of chat messages'), {
nativeEvent: {
contentOffset: {
y: 250,
@@ -68,48 +68,40 @@ function scrollUpToRevealNewMessagesBadge(renderedApp) {
},
},
});
-
- // We advance the timer since we must wait for the animation to end
- // and the new style to be reflected
- jest.advanceTimersByTime(100);
}
/**
- * @param {RenderAPI} renderedApp
* @return {Boolean}
*/
-function isNewMessagesBadgeVisible(renderedApp) {
- const badge = renderedApp.queryByA11yHint('Scroll to newest messages');
- return badge.props.style.transform[0].translateY === 10;
+function isNewMessagesBadgeVisible() {
+ const badge = screen.queryByAccessibilityHint('Scroll to newest messages');
+ return Math.round(badge.props.style.transform[0].translateY) === 10;
}
/**
- * @param {RenderAPI} renderedApp
* @return {Promise}
*/
-function navigateToSidebar(renderedApp) {
- const reportHeaderBackButton = renderedApp.queryByA11yHint('Navigate back to chats list');
+function navigateToSidebar() {
+ const reportHeaderBackButton = screen.queryByAccessibilityHint('Navigate back to chats list');
fireEvent(reportHeaderBackButton, 'press');
return waitForPromisesToResolve();
}
/**
- * @param {RenderAPI} renderedApp
* @param {Number} index
* @return {Promise}
*/
-function navigateToSidebarOption(renderedApp, index) {
- const optionRows = renderedApp.queryAllByA11yHint('Navigates to a chat');
+function navigateToSidebarOption(index) {
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
fireEvent(optionRows[index], 'press');
return waitForPromisesToResolve();
}
/**
- * @param {RenderAPI} renderedApp
* @return {Boolean}
*/
-function isDrawerOpen(renderedApp) {
- const sidebarLinks = renderedApp.queryAllByA11yLabel('List of chats');
+function isDrawerOpen() {
+ const sidebarLinks = screen.queryAllByLabelText('List of chats');
return !lodashGet(sidebarLinks, [0, 'props', 'accessibilityElementsHidden']);
}
@@ -127,14 +119,14 @@ let reportAction9CreatedDate;
/**
* Sets up a test with a logged in user that has one unread chat from another user. Returns the test instance.
*
- * @returns {RenderAPI}
+ * @returns {Promise}
*/
function signInAndGetAppWithUnreadChat() {
// Render the App and sign in as a test user.
- const renderedApp = render( );
+ render( );
return waitForPromisesToResolveWithAct()
.then(() => {
- const loginForm = renderedApp.queryAllByA11yLabel('Login form');
+ const loginForm = screen.queryAllByLabelText('Login form');
expect(loginForm).toHaveLength(1);
return TestHelper.signInWithTestUser(USER_A_ACCOUNT_ID, USER_A_EMAIL, undefined, undefined, 'A');
@@ -194,8 +186,7 @@ function signInAndGetAppWithUnreadChat() {
// We manually setting the sidebar as loaded since the onLayout event does not fire in tests
AppActions.setSidebarLoaded(true);
return waitForPromisesToResolve();
- })
- .then(() => renderedApp);
+ });
}
describe('Unread Indicators', () => {
@@ -204,325 +195,293 @@ describe('Unread Indicators', () => {
Onyx.clear();
});
- it('Display bold in the LHN for unread chat and new line indicator above the chat message when we navigate to it', () => {
- let renderedApp;
- return signInAndGetAppWithUnreadChat()
- .then((testInstance) => {
- renderedApp = testInstance;
-
- // Verify no notifications are created for these older messages
- expect(LocalNotification.showCommentNotification.mock.calls).toHaveLength(0);
+ it('Display bold in the LHN for unread chat and new line indicator above the chat message when we navigate to it', () => signInAndGetAppWithUnreadChat()
+ .then(() => {
+ // Verify no notifications are created for these older messages
+ expect(LocalNotification.showCommentNotification.mock.calls).toHaveLength(0);
- // Verify the sidebar links are rendered
- const sidebarLinks = renderedApp.queryAllByA11yLabel('List of chats');
- expect(sidebarLinks).toHaveLength(1);
- expect(isDrawerOpen(renderedApp)).toBe(true);
+ // Verify the sidebar links are rendered
+ const sidebarLinks = screen.queryAllByLabelText('List of chats');
+ expect(sidebarLinks).toHaveLength(1);
+ expect(isDrawerOpen()).toBe(true);
- // Verify there is only one option in the sidebar
- const optionRows = renderedApp.queryAllByA11yHint('Navigates to a chat');
- expect(optionRows).toHaveLength(1);
+ // Verify there is only one option in the sidebar
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
+ expect(optionRows).toHaveLength(1);
- // And that the text is bold
- const displayNameText = renderedApp.queryByA11yLabel('Chat user display names');
- expect(lodashGet(displayNameText, ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
+ // And that the text is bold
+ const displayNameText = screen.queryByLabelText('Chat user display names');
+ expect(lodashGet(displayNameText, ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
- return navigateToSidebarOption(renderedApp, 0);
- })
- .then(() => {
- // Verify that the report screen is rendered and the drawer is closed
- expect(isDrawerOpen(renderedApp)).toBe(false);
-
- // That the report actions are visible along with the created action
- const createdAction = renderedApp.queryByA11yLabel('Chat welcome message');
- expect(createdAction).toBeTruthy();
- const reportComments = renderedApp.queryAllByA11yLabel('Chat message');
- expect(reportComments).toHaveLength(9);
-
- // Since the last read timestamp is the timestamp of action 3 we should have an unread indicator above the next "unread" action which will
- // have actionID of 4
- const unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(1);
- const reportActionID = lodashGet(unreadIndicator, [0, 'props', 'data-action-id']);
- expect(reportActionID).toBe('4');
-
- // Scroll up and verify that the "New messages" badge appears
- scrollUpToRevealNewMessagesBadge(renderedApp);
- expect(isNewMessagesBadgeVisible(renderedApp)).toBe(true);
- });
- });
+ return navigateToSidebarOption(0);
+ })
+ .then(() => {
+ // Verify that the report screen is rendered and the drawer is closed
+ expect(isDrawerOpen()).toBe(false);
+
+ // That the report actions are visible along with the created action
+ const createdAction = screen.queryByLabelText('Chat welcome message');
+ expect(createdAction).toBeTruthy();
+ const reportComments = screen.queryAllByLabelText('Chat message');
+ expect(reportComments).toHaveLength(9);
+
+ // Since the last read timestamp is the timestamp of action 3 we should have an unread indicator above the next "unread" action which will
+ // have actionID of 4
+ const unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(1);
+ const reportActionID = lodashGet(unreadIndicator, [0, 'props', 'data-action-id']);
+ expect(reportActionID).toBe('4');
+
+ // Scroll up and verify that the "New messages" badge appears
+ scrollUpToRevealNewMessagesBadge();
+ return waitFor(() => expect(isNewMessagesBadgeVisible()).toBe(true));
+ }));
+
+ it('Clear the new line indicator and bold when we navigate away from a chat that is now read', () => signInAndGetAppWithUnreadChat()
+
+ // Navigate to the unread chat from the sidebar
+ .then(() => navigateToSidebarOption(0))
+ .then(() => {
+ expect(isDrawerOpen()).toBe(false);
- it('Clear the new line indicator and bold when we navigate away from a chat that is now read', () => {
- let renderedApp;
- return signInAndGetAppWithUnreadChat()
- .then((testInstance) => {
- renderedApp = testInstance;
+ // Then navigate back to the sidebar
+ return navigateToSidebar();
+ })
+ .then(() => {
+ // Verify the LHN is now open
+ expect(isDrawerOpen()).toBe(true);
- // Navigate to the unread chat from the sidebar
- return navigateToSidebarOption(renderedApp, 0);
- })
- .then(() => {
- expect(isDrawerOpen(renderedApp)).toBe(false);
+ // Verify that the option row in the LHN is no longer bold (since OpenReport marked it as read)
+ const updatedDisplayNameText = screen.queryByLabelText('Chat user display names');
+ expect(lodashGet(updatedDisplayNameText, ['props', 'style', 0, 'fontWeight'])).toBe(undefined);
- // Then navigate back to the sidebar
- return navigateToSidebar(renderedApp);
- })
- .then(() => {
- // Verify the LHN is now open
- expect(isDrawerOpen(renderedApp)).toBe(true);
+ // Tap on the chat again
+ return navigateToSidebarOption(0);
+ })
+ .then(() => {
+ // Verify the unread indicator is not present
+ const unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(0);
+ expect(isDrawerOpen()).toBe(false);
- // Verify that the option row in the LHN is no longer bold (since OpenReport marked it as read)
- const updatedDisplayNameText = renderedApp.queryByA11yLabel('Chat user display names');
- expect(lodashGet(updatedDisplayNameText, ['props', 'style', 0, 'fontWeight'])).toBe(undefined);
+ // Scroll and verify that the new messages badge is also hidden
+ scrollUpToRevealNewMessagesBadge();
+ return waitFor(() => expect(isNewMessagesBadgeVisible()).toBe(false));
+ }));
- // Tap on the chat again
- return navigateToSidebarOption(renderedApp, 0);
- })
- .then(() => {
- // Verify the unread indicator is not present
- const unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(0);
- expect(isDrawerOpen(renderedApp)).toBe(false);
-
- // Scroll and verify that the new messages badge is also hidden
- scrollUpToRevealNewMessagesBadge(renderedApp);
- expect(isNewMessagesBadgeVisible(renderedApp)).toBe(false);
- });
- });
+ it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => signInAndGetAppWithUnreadChat()
+ .then(() => {
+ // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant
+ // We set the created moment 5 seconds in the past to ensure that time has passed when we open the report
+ const NEW_REPORT_ID = '2';
+ const NEW_REPORT_CREATED_MOMENT = moment().subtract(5, 'seconds');
+ const NEW_REPORT_FIST_MESSAGE_CREATED_MOMENT = NEW_REPORT_CREATED_MOMENT.add(1, 'seconds');
- it('Shows a browser notification and bold text when a new message arrives for a chat that is read', () => {
- let renderedApp;
- return signInAndGetAppWithUnreadChat()
- .then((testInstance) => {
- renderedApp = testInstance;
-
- // Simulate a new report arriving via Pusher along with reportActions and personalDetails for the other participant
- // We set the created moment 5 seconds in the past to ensure that time has passed when we open the report
- const NEW_REPORT_ID = '2';
- const NEW_REPORT_CREATED_MOMENT = moment().subtract(5, 'seconds');
- const NEW_REPORT_FIST_MESSAGE_CREATED_MOMENT = NEW_REPORT_CREATED_MOMENT.add(1, 'seconds');
-
- const createdReportActionID = NumberUtils.rand64();
- const commentReportActionID = NumberUtils.rand64();
- const channel = Pusher.getChannel(`${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}${USER_A_ACCOUNT_ID}${CONFIG.PUSHER.SUFFIX}`);
- channel.emit(Pusher.TYPE.ONYX_API_UPDATE, [
- {
- onyxMethod: CONST.ONYX.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${NEW_REPORT_ID}`,
- value: {
- reportID: NEW_REPORT_ID,
- reportName: CONST.REPORT.DEFAULT_REPORT_NAME,
- lastReadTime: '',
- lastActionCreated: DateUtils.getDBTime(NEW_REPORT_FIST_MESSAGE_CREATED_MOMENT.utc().valueOf()),
- lastMessageText: 'Comment 1',
- participants: [USER_C_EMAIL],
- },
+ const createdReportActionID = NumberUtils.rand64();
+ const commentReportActionID = NumberUtils.rand64();
+ const channel = Pusher.getChannel(`${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}${USER_A_ACCOUNT_ID}${CONFIG.PUSHER.SUFFIX}`);
+ channel.emit(Pusher.TYPE.ONYX_API_UPDATE, [
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${NEW_REPORT_ID}`,
+ value: {
+ reportID: NEW_REPORT_ID,
+ reportName: CONST.REPORT.DEFAULT_REPORT_NAME,
+ lastReadTime: '',
+ lastActionCreated: DateUtils.getDBTime(NEW_REPORT_FIST_MESSAGE_CREATED_MOMENT.utc().valueOf()),
+ lastMessageText: 'Comment 1',
+ participants: [USER_C_EMAIL],
},
- {
- onyxMethod: CONST.ONYX.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${NEW_REPORT_ID}`,
- value: {
- [createdReportActionID]: {
- actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
- automatic: false,
- created: NEW_REPORT_CREATED_MOMENT.format(MOMENT_FORMAT),
- reportActionID: createdReportActionID,
- },
- [commentReportActionID]: {
- actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
- actorEmail: USER_C_EMAIL,
- actorAccountID: USER_C_ACCOUNT_ID,
- person: [{type: 'TEXT', style: 'strong', text: 'User C'}],
- created: NEW_REPORT_FIST_MESSAGE_CREATED_MOMENT.format(MOMENT_FORMAT),
- message: [{type: 'COMMENT', html: 'Comment 1', text: 'Comment 1'}],
- reportActionID: commentReportActionID,
- },
+ },
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${NEW_REPORT_ID}`,
+ value: {
+ [createdReportActionID]: {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ automatic: false,
+ created: NEW_REPORT_CREATED_MOMENT.format(MOMENT_FORMAT),
+ reportActionID: createdReportActionID,
},
- shouldNotify: true,
- },
- {
- onyxMethod: CONST.ONYX.METHOD.MERGE,
- key: ONYXKEYS.PERSONAL_DETAILS,
- value: {
- [USER_C_EMAIL]: TestHelper.buildPersonalDetails(USER_C_EMAIL, USER_C_ACCOUNT_ID, 'C'),
+ [commentReportActionID]: {
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ actorEmail: USER_C_EMAIL,
+ actorAccountID: USER_C_ACCOUNT_ID,
+ person: [{type: 'TEXT', style: 'strong', text: 'User C'}],
+ created: NEW_REPORT_FIST_MESSAGE_CREATED_MOMENT.format(MOMENT_FORMAT),
+ message: [{type: 'COMMENT', html: 'Comment 1', text: 'Comment 1'}],
+ reportActionID: commentReportActionID,
},
},
- ]);
- return waitForPromisesToResolve();
- })
- .then(() => {
- // Verify notification was created
- expect(LocalNotification.showCommentNotification).toBeCalled();
-
- // // Navigate back to the sidebar
- return navigateToSidebar(renderedApp);
- })
- .then(() => {
- // // Verify the new report option appears in the LHN
- const optionRows = renderedApp.queryAllByA11yHint('Navigates to a chat');
- expect(optionRows).toHaveLength(2);
-
- // Verify the text for both chats are bold indicating that nothing has not yet been read
- const displayNameTexts = renderedApp.queryAllByA11yLabel('Chat user display names');
- expect(displayNameTexts).toHaveLength(2);
- const firstReportOption = displayNameTexts[0];
- expect(lodashGet(firstReportOption, ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
- expect(lodashGet(firstReportOption, ['props', 'children'])).toBe('C User');
-
- const secondReportOption = displayNameTexts[1];
- expect(lodashGet(secondReportOption, ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
- expect(lodashGet(secondReportOption, ['props', 'children'])).toBe('B User');
-
- // Tap the new report option and navigate back to the sidebar again via the back button
- return navigateToSidebarOption(renderedApp, 0);
- })
- .then(() => {
- // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread
- const displayNameTexts = renderedApp.queryAllByA11yLabel('Chat user display names');
- expect(displayNameTexts).toHaveLength(2);
- expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(undefined);
- expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('C User');
- expect(lodashGet(displayNameTexts[1], ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
- expect(lodashGet(displayNameTexts[1], ['props', 'children'])).toBe('B User');
- });
- });
-
- it('Manually marking a chat message as unread shows the new line indicator and updates the LHN', () => {
- let renderedApp;
- return signInAndGetAppWithUnreadChat()
- .then((testInstance) => {
- renderedApp = testInstance;
-
- // Navigate to the unread report
- return navigateToSidebarOption(renderedApp, 0);
- })
- .then(() => {
- // It's difficult to trigger marking a report comment as unread since we would have to mock the long press event and then
- // another press on the context menu item so we will do it via the action directly and then test if the UI has updated properly
- Report.markCommentAsUnread(REPORT_ID, reportAction3CreatedDate);
- return waitForPromisesToResolve();
- })
- .then(() => {
- // Verify the indicator appears above the last action
- const unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(1);
- const reportActionID = lodashGet(unreadIndicator, [0, 'props', 'data-action-id']);
- expect(reportActionID).toBe('3');
-
- // Scroll up and verify the new messages badge appears
- scrollUpToRevealNewMessagesBadge(renderedApp);
- expect(isNewMessagesBadgeVisible(renderedApp)).toBe(true);
-
- // Navigate to the sidebar
- return navigateToSidebar(renderedApp);
- })
- .then(() => {
- // Verify the report is marked as unread in the sidebar
- const displayNameTexts = renderedApp.queryAllByA11yLabel('Chat user display names');
- expect(displayNameTexts).toHaveLength(1);
- expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
- expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('B User');
-
- // Navigate to the report again and back to the sidebar
- return navigateToSidebarOption(renderedApp, 0);
- })
- .then(() => navigateToSidebar(renderedApp))
- .then(() => {
- // Verify the report is now marked as read
- const displayNameTexts = renderedApp.queryAllByA11yLabel('Chat user display names');
- expect(displayNameTexts).toHaveLength(1);
- expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(undefined);
- expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('B User');
-
- // Navigate to the report again and verify the new line indicator is missing
- return navigateToSidebarOption(renderedApp, 0);
- })
- .then(() => {
- const unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(0);
+ shouldNotify: true,
+ },
+ {
+ onyxMethod: CONST.ONYX.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_DETAILS,
+ value: {
+ [USER_C_EMAIL]: TestHelper.buildPersonalDetails(USER_C_EMAIL, USER_C_ACCOUNT_ID, 'C'),
+ },
+ },
+ ]);
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // Verify notification was created
+ expect(LocalNotification.showCommentNotification).toBeCalled();
- // Scroll up and verify the "New messages" badge is hidden
- scrollUpToRevealNewMessagesBadge(renderedApp);
- expect(isNewMessagesBadgeVisible(renderedApp)).toBe(false);
- });
- });
+ // // Navigate back to the sidebar
+ return navigateToSidebar();
+ })
+ .then(() => {
+ // // Verify the new report option appears in the LHN
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
+ expect(optionRows).toHaveLength(2);
+
+ // Verify the text for both chats are bold indicating that nothing has not yet been read
+ const displayNameTexts = screen.queryAllByLabelText('Chat user display names');
+ expect(displayNameTexts).toHaveLength(2);
+ const firstReportOption = displayNameTexts[0];
+ expect(lodashGet(firstReportOption, ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
+ expect(lodashGet(firstReportOption, ['props', 'children'])).toBe('C User');
+
+ const secondReportOption = displayNameTexts[1];
+ expect(lodashGet(secondReportOption, ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
+ expect(lodashGet(secondReportOption, ['props', 'children'])).toBe('B User');
+
+ // Tap the new report option and navigate back to the sidebar again via the back button
+ return navigateToSidebarOption(0);
+ })
+ .then(() => {
+ // Verify that report we navigated to appears in a "read" state while the original unread report still shows as unread
+ const displayNameTexts = screen.queryAllByLabelText('Chat user display names');
+ expect(displayNameTexts).toHaveLength(2);
+ expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(undefined);
+ expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('C User');
+ expect(lodashGet(displayNameTexts[1], ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
+ expect(lodashGet(displayNameTexts[1], ['props', 'children'])).toBe('B User');
+ }));
+
+ it('Manually marking a chat message as unread shows the new line indicator and updates the LHN', () => signInAndGetAppWithUnreadChat()
+
+ // Navigate to the unread report
+ .then(() => navigateToSidebarOption(0))
+ .then(() => {
+ // It's difficult to trigger marking a report comment as unread since we would have to mock the long press event and then
+ // another press on the context menu item so we will do it via the action directly and then test if the UI has updated properly
+ Report.markCommentAsUnread(REPORT_ID, reportAction3CreatedDate);
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // Verify the indicator appears above the last action
+ const unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(1);
+ const reportActionID = lodashGet(unreadIndicator, [0, 'props', 'data-action-id']);
+ expect(reportActionID).toBe('3');
+
+ // Scroll up and verify the new messages badge appears
+ scrollUpToRevealNewMessagesBadge();
+ return waitFor(() => expect(isNewMessagesBadgeVisible()).toBe(true));
+ })
- it('Removes the new line indicator when a new message is created by the current user', () => {
- let renderedApp;
- return signInAndGetAppWithUnreadChat()
- .then((testInstance) => {
- renderedApp = testInstance;
+ // Navigate to the sidebar
+ .then(navigateToSidebar)
+ .then(() => {
+ // Verify the report is marked as unread in the sidebar
+ const displayNameTexts = screen.queryAllByLabelText('Chat user display names');
+ expect(displayNameTexts).toHaveLength(1);
+ expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
+ expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('B User');
+
+ // Navigate to the report again and back to the sidebar
+ return navigateToSidebarOption(0);
+ })
+ .then(() => navigateToSidebar())
+ .then(() => {
+ // Verify the report is now marked as read
+ const displayNameTexts = screen.queryAllByLabelText('Chat user display names');
+ expect(displayNameTexts).toHaveLength(1);
+ expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(undefined);
+ expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('B User');
+
+ // Navigate to the report again and verify the new line indicator is missing
+ return navigateToSidebarOption(0);
+ })
+ .then(() => {
+ const unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(0);
- // Verify we are on the LHN and that the chat shows as unread in the LHN
- expect(isDrawerOpen(renderedApp)).toBe(true);
+ // Scroll up and verify the "New messages" badge is hidden
+ scrollUpToRevealNewMessagesBadge();
+ return waitFor(() => expect(isNewMessagesBadgeVisible()).toBe(false));
+ }));
- const displayNameTexts = renderedApp.queryAllByA11yLabel('Chat user display names');
- expect(displayNameTexts).toHaveLength(1);
- expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('B User');
- expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
+ it('Removes the new line indicator when a new message is created by the current user', () => signInAndGetAppWithUnreadChat()
+ .then(() => {
+ // Verify we are on the LHN and that the chat shows as unread in the LHN
+ expect(isDrawerOpen()).toBe(true);
- // Navigate to the report and verify the indicator is present
- return navigateToSidebarOption(renderedApp, 0);
- })
- .then(() => {
- const unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(1);
+ const displayNameTexts = screen.queryAllByLabelText('Chat user display names');
+ expect(displayNameTexts).toHaveLength(1);
+ expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('B User');
+ expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
- // Leave a comment as the current user and verify the indicator is removed
- Report.addComment(REPORT_ID, 'Current User Comment 1');
- return waitForPromisesToResolve();
- })
- .then(() => {
- const unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(0);
- });
- });
+ // Navigate to the report and verify the indicator is present
+ return navigateToSidebarOption(0);
+ })
+ .then(() => {
+ const unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(1);
- it('Clears the new line indicator when the user moves the App to the background', () => {
- let renderedApp;
- return signInAndGetAppWithUnreadChat()
- .then((testInstance) => {
- renderedApp = testInstance;
+ // Leave a comment as the current user and verify the indicator is removed
+ Report.addComment(REPORT_ID, 'Current User Comment 1');
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ const unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(0);
+ }));
- // Verify we are on the LHN and that the chat shows as unread in the LHN
- expect(isDrawerOpen(renderedApp)).toBe(true);
+ it('Keeps the new line indicator when the user moves the App to the background', () => signInAndGetAppWithUnreadChat()
+ .then(() => {
+ // Verify we are on the LHN and that the chat shows as unread in the LHN
+ expect(isDrawerOpen()).toBe(true);
- const displayNameTexts = renderedApp.queryAllByA11yLabel('Chat user display names');
- expect(displayNameTexts).toHaveLength(1);
- expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('B User');
- expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
+ const displayNameTexts = screen.queryAllByLabelText('Chat user display names');
+ expect(displayNameTexts).toHaveLength(1);
+ expect(lodashGet(displayNameTexts[0], ['props', 'children'])).toBe('B User');
+ expect(lodashGet(displayNameTexts[0], ['props', 'style', 0, 'fontWeight'])).toBe(fontWeightBold);
- // Navigate to the chat and verify the new line indicator is present
- return navigateToSidebarOption(renderedApp, 0);
- })
- .then(() => {
- const unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(1);
+ // Navigate to the chat and verify the new line indicator is present
+ return navigateToSidebarOption(0);
+ })
+ .then(() => {
+ const unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(1);
- // Then back to the LHN - then back to the chat again and verify the new line indicator has cleared
- return navigateToSidebar(renderedApp);
- })
- .then(() => navigateToSidebarOption(renderedApp, 0))
- .then(() => {
- const unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(0);
+ // Then back to the LHN - then back to the chat again and verify the new line indicator has cleared
+ return navigateToSidebar();
+ })
+ .then(() => navigateToSidebarOption(0))
+ .then(() => {
+ const unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(0);
- // Mark a previous comment as unread and verify the unread action indicator returns
- Report.markCommentAsUnread(REPORT_ID, reportAction9CreatedDate);
- return waitForPromisesToResolve();
- })
- .then(() => {
- let unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(1);
+ // Mark a previous comment as unread and verify the unread action indicator returns
+ Report.markCommentAsUnread(REPORT_ID, reportAction9CreatedDate);
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ let unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(1);
- // Trigger the app going inactive and active again
- AppState.emitCurrentTestState('background');
- AppState.emitCurrentTestState('active');
+ // Trigger the app going inactive and active again
+ AppState.emitCurrentTestState('background');
+ AppState.emitCurrentTestState('active');
- // Verify the new line is cleared
- unreadIndicator = renderedApp.queryAllByA11yLabel('New message line indicator');
- expect(unreadIndicator).toHaveLength(0);
- });
- });
+ // Verify the new line is still present
+ unreadIndicator = screen.queryAllByLabelText('New message line indicator');
+ expect(unreadIndicator).toHaveLength(1);
+ }));
it('Displays the correct chat message preview in the LHN when a comment is added then deleted', () => {
let reportActions;
@@ -531,14 +490,10 @@ describe('Unread Indicators', () => {
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
callback: val => reportActions = val,
});
- let renderedApp;
return signInAndGetAppWithUnreadChat()
- .then((testInstance) => {
- renderedApp = testInstance;
- // Navigate to the chat and simulate leaving a comment from the current user
- return navigateToSidebarOption(renderedApp, 0);
- })
+ // Navigate to the chat and simulate leaving a comment from the current user
+ .then(() => navigateToSidebarOption(0))
.then(() => {
// Leave a comment as the current user
Report.addComment(REPORT_ID, 'Current User Comment 1');
@@ -557,7 +512,7 @@ describe('Unread Indicators', () => {
})
.then(() => {
// Verify the chat preview text matches the last comment from the current user
- const alternateText = renderedApp.queryAllByA11yLabel('Last chat message preview');
+ const alternateText = screen.queryAllByLabelText('Last chat message preview');
expect(alternateText).toHaveLength(1);
expect(alternateText[0].props.children).toBe('Current User Comment 1');
@@ -565,7 +520,7 @@ describe('Unread Indicators', () => {
return waitForPromisesToResolve();
})
.then(() => {
- const alternateText = renderedApp.queryAllByA11yLabel('Last chat message preview');
+ const alternateText = screen.queryAllByLabelText('Last chat message preview');
expect(alternateText).toHaveLength(1);
expect(alternateText[0].props.children).toBe('Comment 9');
});
diff --git a/tests/unit/GooglePlacesUtilsTest.js b/tests/unit/GooglePlacesUtilsTest.js
index 2993254a491b..aae23d64ae3e 100644
--- a/tests/unit/GooglePlacesUtilsTest.js
+++ b/tests/unit/GooglePlacesUtilsTest.js
@@ -1,39 +1,192 @@
import * as GooglePlacesUtils from '../../src/libs/GooglePlacesUtils';
+const standardObjectToFind = {
+ sublocality: 'long_name',
+ administrative_area_level_1: 'short_name',
+ postal_code: 'long_name',
+ 'doesnt-exist': 'long_name',
+};
+
+const objectWithCountryToFind = {
+ sublocality: 'long_name',
+ administrative_area_level_1: 'short_name',
+ postal_code: 'long_name',
+ 'doesnt-exist': 'long_name',
+ country: 'long_name',
+};
+
+const bigObjectToFind = {
+ sublocality: 'long_name',
+ administrative_area_level_1: 'short_name',
+ postal_code: 'long_name',
+ 'doesnt-exist': 'long_name',
+ s1ublocality: 'long_name',
+ a1dministrative_area_level_1: 'short_name',
+ p1ostal_code: 'long_name',
+ '1doesnt-exist': 'long_name',
+ s2ublocality: 'long_name',
+ a2dministrative_area_level_1: 'short_name',
+ p2ostal_code: 'long_name',
+ '2doesnt-exist': 'long_name',
+ s3ublocality: 'long_name',
+ a3dministrative_area_level_1: 'short_name',
+ p3ostal_code: 'long_name',
+ '3doesnt-exist': 'long_name',
+ s4ublocality: 'long_name',
+ a4dministrative_area_level_1: 'short_name',
+ p4ostal_code: 'long_name',
+ '4doesnt-exist': 'long_name',
+ s5ublocality: 'long_name',
+ a5dministrative_area_level_1: 'short_name',
+ p5ostal_code: 'long_name',
+ '5doesnt-exist': 'long_name',
+ s6ublocality: 'long_name',
+ a6dministrative_area_level_1: 'short_name',
+ p6ostal_code: 'long_name',
+ '6doesnt-exist': 'long_name',
+ s7ublocality: 'long_name',
+ a7dministrative_area_level_1: 'short_name',
+ p7ostal_code: 'long_name',
+ '7doesnt-exist': 'long_name',
+ s8ublocality: 'long_name',
+ a8dministrative_area_level_1: 'short_name',
+ p8ostal_code: 'long_name',
+ '8doesnt-exist': 'long_name',
+ s9ublocality: 'long_name',
+ a9dministrative_area_level_1: 'short_name',
+ p9ostal_code: 'long_name',
+ '9doesnt-exist': 'long_name',
+ s10ublocality: 'long_name',
+ a10dministrative_area_level_1: 'short_name',
+ p10ostal_code: 'long_name',
+ '10doesnt-exist': 'long_name',
+ s11ublocality: 'long_name',
+ a11dministrative_area_level_1: 'short_name',
+ p11ostal_code: 'long_name',
+ '11doesnt-exist': 'long_name',
+ s12ublocality: 'long_name',
+ a12dministrative_area_level_1: 'short_name',
+ p12ostal_code: 'long_name',
+ '12doesnt-exist': 'long_name',
+ s13ublocality: 'long_name',
+ a13dministrative_area_level_1: 'short_name',
+ p13ostal_code: 'long_name',
+ '13doesnt-exist': 'long_name',
+ s14ublocality: 'long_name',
+ a14dministrative_area_level_1: 'short_name',
+ p14ostal_code: 'long_name',
+ '14doesnt-exist': 'long_name',
+ s15ublocality: 'long_name',
+ a15dministrative_area_level_1: 'short_name',
+ p15ostal_code: 'long_name',
+ '15doesnt-exist': 'long_name',
+ s16ublocality: 'long_name',
+ a16dministrative_area_level_1: 'short_name',
+ p16ostal_code: 'long_name',
+ '16doesnt-exist': 'long_name',
+ s17ublocality: 'long_name',
+ a17dministrative_area_level_1: 'short_name',
+ p17ostal_code: 'long_name',
+ '17doesnt-exist': 'long_name',
+ s18ublocality: 'long_name',
+ a18dministrative_area_level_1: 'short_name',
+ p18ostal_code: 'long_name',
+ '18doesnt-exist': 'long_name',
+ s19ublocality: 'long_name',
+ a19dministrative_area_level_1: 'short_name',
+ p19ostal_code: 'long_name',
+ '19doesnt-exist': 'long_name',
+ s20ublocality: 'long_name',
+ a20dministrative_area_level_1: 'short_name',
+ p20ostal_code: 'long_name',
+ '20doesnt-exist': 'long_name',
+};
+
+const addressComponents = [
+ {
+ long_name: 'Bushwick',
+ short_name: 'Bushwick',
+ types: ['neighborhood', 'political'],
+ },
+ {
+ long_name: 'Brooklyn',
+ short_name: 'Brooklyn',
+ types: ['sublocality_level_1', 'sublocality', 'political'],
+ },
+ {
+ long_name: 'New York',
+ short_name: 'NY',
+ types: ['administrative_area_level_1', 'political'],
+ },
+ {
+ long_name: 'United States',
+ short_name: 'US',
+ types: ['country', 'political'],
+ },
+ {
+ long_name: '11206',
+ short_name: '11206',
+ types: ['postal_code'],
+ },
+];
describe('GooglePlacesUtilsTest', () => {
- describe('getAddressComponent', () => {
+ describe('getAddressComponents', () => {
+ it('should find address components by type', () => {
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, {sublocality: 'long_name'})).toStrictEqual({sublocality: 'Brooklyn'});
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, {administrative_area_level_1: 'short_name'})).toStrictEqual({administrative_area_level_1: 'NY'});
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, {postal_code: 'long_name'})).toStrictEqual({postal_code: '11206'});
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, {'doesnt-exist': 'long_name'})).toStrictEqual({'doesnt-exist': ''});
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, standardObjectToFind)).toStrictEqual({
+ sublocality: 'Brooklyn',
+ administrative_area_level_1: 'NY',
+ postal_code: '11206',
+ 'doesnt-exist': '',
+ });
+ });
+ });
+ describe('getAddressComponentsWithCountry', () => {
it('should find address components by type', () => {
- const addressComponents = [
- {
- long_name: 'Bushwick',
- short_name: 'Bushwick',
- types: ['neighborhood', 'political'],
- },
- {
- long_name: 'Brooklyn',
- short_name: 'Brooklyn',
- types: ['sublocality_level_1', 'sublocality', 'political'],
- },
- {
- long_name: 'New York',
- short_name: 'NY',
- types: ['administrative_area_level_1', 'political'],
- },
- {
- long_name: 'United States',
- short_name: 'US',
- types: ['country', 'political'],
- },
- {
- long_name: '11206',
- short_name: '11206',
- types: ['postal_code'],
- },
- ];
- expect(GooglePlacesUtils.getAddressComponent(addressComponents, 'sublocality', 'long_name')).toStrictEqual('Brooklyn');
- expect(GooglePlacesUtils.getAddressComponent(addressComponents, 'administrative_area_level_1', 'short_name')).toStrictEqual('NY');
- expect(GooglePlacesUtils.getAddressComponent(addressComponents, 'postal_code', 'long_name')).toStrictEqual('11206');
- expect(GooglePlacesUtils.getAddressComponent(addressComponents, 'doesn-exist', 'long_name')).toStrictEqual(undefined);
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, {sublocality: 'long_name'})).toStrictEqual({sublocality: 'Brooklyn'});
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, {administrative_area_level_1: 'short_name'})).toStrictEqual({administrative_area_level_1: 'NY'});
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, {postal_code: 'long_name'})).toStrictEqual({postal_code: '11206'});
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, {'doesnt-exist': 'long_name'})).toStrictEqual({'doesnt-exist': ''});
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, {country: 'long_name'})).toStrictEqual({country: 'United States'});
+ expect(GooglePlacesUtils.getAddressComponents(addressComponents, objectWithCountryToFind)).toStrictEqual({
+ sublocality: 'Brooklyn',
+ administrative_area_level_1: 'NY',
+ postal_code: '11206',
+ 'doesnt-exist': '',
+ country: 'United States',
+ });
+ });
+ });
+ describe('getAddressComponents small data set timing', () => {
+ it('should not be slow when executing', () => {
+ const startTime = performance.now();
+ for (let i = 100; i > 0; i--) {
+ GooglePlacesUtils.getAddressComponents(addressComponents, standardObjectToFind);
+ }
+ const endTime = performance.now();
+ const executionTime = endTime - startTime;
+
+ // When timing this method it was roughly 0.45087499999999636ms so this would be almost twice as slow
+ // which I think is a meaningful regression we should avoid
+ expect(executionTime).toBeLessThan(1.0);
+ });
+ });
+ describe('getAddressComponents big data set timing', () => {
+ it('should not be slow when executing', () => {
+ const startTime = performance.now();
+ for (let i = 100; i > 0; i--) {
+ GooglePlacesUtils.getAddressComponents(addressComponents, bigObjectToFind);
+ }
+ const endTime = performance.now();
+ const executionTime = endTime - startTime;
+
+ // When timing this method it was roughly 1.211708999999928ms locally
+ // but 3.2214480000000094ms on github actions so using 5ms arbitrarily here for now
+ // and we can change if needed later.
+ expect(executionTime).toBeLessThan(5.00);
});
});
});
diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js
index 00ef88973ce3..3f3bd02a1e4f 100644
--- a/tests/unit/MigrationTest.js
+++ b/tests/unit/MigrationTest.js
@@ -11,10 +11,6 @@ import ONYXKEYS from '../../src/ONYXKEYS';
jest.mock('../../src/libs/getPlatform');
-// Using fake timers is causing problems with promises getting timed out
-// This seems related: https://github.com/facebook/jest/issues/11876
-jest.useRealTimers();
-
let LogSpy;
describe('Migrations', () => {
@@ -153,7 +149,7 @@ describe('Migrations', () => {
waitForCollectionCallback: true,
callback: (allReports) => {
Onyx.disconnect(connectionID);
- expect(allReports).toBeEmpty();
+ expect(_.compact(_.values(allReports))).toEqual([]);
},
});
})
diff --git a/tests/unit/NetworkTest.js b/tests/unit/NetworkTest.js
index 0a02132af77e..73c5f300f71d 100644
--- a/tests/unit/NetworkTest.js
+++ b/tests/unit/NetworkTest.js
@@ -19,8 +19,6 @@ import * as SequentialQueue from '../../src/libs/Network/SequentialQueue';
import * as MainQueue from '../../src/libs/Network/MainQueue';
import * as Request from '../../src/libs/Request';
-jest.useFakeTimers();
-
Onyx.init({
keys: ONYXKEYS,
});
diff --git a/tests/unit/SidebarFilterTest.js b/tests/unit/SidebarFilterTest.js
index 48b91067e896..426f37c99b34 100644
--- a/tests/unit/SidebarFilterTest.js
+++ b/tests/unit/SidebarFilterTest.js
@@ -1,4 +1,4 @@
-import {cleanup} from '@testing-library/react-native';
+import {cleanup, screen} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
import * as LHNTestUtils from '../utils/LHNTestUtils';
@@ -39,7 +39,7 @@ describe('Sidebar', () => {
describe('in default (most recent) mode', () => {
it('excludes a report with no participants', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given a report with no participants
const report = LHNTestUtils.getFakeReport([]);
@@ -53,13 +53,36 @@ describe('Sidebar', () => {
// Then no reports are rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
+ expect(optionRows).toHaveLength(0);
+ });
+ });
+
+ it('excludes a report with no message', () => {
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
+
+ // Given a report with no message
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ maxSequenceNumber: 1,
+ };
+
+ return waitForPromisesToResolve()
+
+ // When Onyx is updated to contain that report
+ .then(() => Onyx.multiSet({
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ }))
+
+ // Then no reports are rendered in the LHN
+ .then(() => {
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(0);
});
});
it('includes or excludes policy expense chats depending on the beta', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given a policy expense report
// and the user not being in any betas
@@ -79,7 +102,7 @@ describe('Sidebar', () => {
// Then no reports are rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(0);
})
@@ -90,13 +113,13 @@ describe('Sidebar', () => {
// Then there is one report rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(1);
});
});
it('includes or excludes user created policy rooms depending on the beta', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given a user created policy room report
// and the user not being in any betas
@@ -116,7 +139,7 @@ describe('Sidebar', () => {
// Then no reports are rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(0);
})
@@ -127,13 +150,13 @@ describe('Sidebar', () => {
// Then there is one report rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(1);
});
});
it('includes or excludes default policy rooms depending on the beta', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given three reports with the three different types of default policy rooms
// and the user not being in any betas
@@ -163,7 +186,7 @@ describe('Sidebar', () => {
// Then no reports are rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(0);
})
@@ -174,13 +197,13 @@ describe('Sidebar', () => {
// Then all three reports are showing in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(3);
});
});
it('includes default policy rooms for free policies, regardless of the beta', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given a default policy room report on a free policy
// and the user not being in any betas
@@ -206,7 +229,7 @@ describe('Sidebar', () => {
// Then the report is rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(1);
})
@@ -215,7 +238,7 @@ describe('Sidebar', () => {
// Then the report is not rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(0);
});
});
@@ -277,7 +300,7 @@ describe('Sidebar', () => {
...LHNTestUtils.getAdvancedFakeReport(...boolArr),
policyID: policy.policyID,
};
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
return waitForPromisesToResolve()
@@ -295,13 +318,13 @@ describe('Sidebar', () => {
.then(() => {
if (booleansWhichRemovesActiveReport.indexOf(JSON.stringify(boolArr)) > -1) {
// Only one report visible
- expect(sidebarLinks.queryAllByA11yHint('Navigates to a chat')).toHaveLength(1);
- expect(sidebarLinks.queryAllByA11yLabel('Chat user display names')).toHaveLength(1);
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ expect(screen.queryAllByAccessibilityHint('Navigates to a chat')).toHaveLength(1);
+ expect(screen.queryAllByLabelText('Chat user display names')).toHaveLength(1);
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Three, Four');
} else {
// Both reports visible
- expect(sidebarLinks.queryAllByA11yHint('Navigates to a chat')).toHaveLength(2);
+ expect(screen.queryAllByAccessibilityHint('Navigates to a chat')).toHaveLength(2);
}
});
});
@@ -316,7 +339,7 @@ describe('Sidebar', () => {
const report1 = LHNTestUtils.getFakeReport(['email1@test.com', 'email2@test.com'], 0, true);
const report2 = LHNTestUtils.getFakeReport(['email3@test.com', 'email4@test.com'], 0, true);
const report3 = LHNTestUtils.getFakeReport(['email5@test.com', 'email6@test.com']);
- let sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
return waitForPromisesToResolve()
@@ -331,18 +354,21 @@ describe('Sidebar', () => {
// Then the reports 1 and 2 are shown and 3 is not
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(2);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('One, Two');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Three, Four');
})
// When report3 becomes unread
- .then(() => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`, {lastActionCreated: DateUtils.getDBTime()}))
+ .then(() => {
+ jest.advanceTimersByTime(10);
+ return Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`, {lastActionCreated: DateUtils.getDBTime()});
+ })
// Then all three chats are showing
.then(() => {
- expect(sidebarLinks.queryAllByA11yHint('Navigates to a chat')).toHaveLength(3);
+ expect(screen.queryAllByAccessibilityHint('Navigates to a chat')).toHaveLength(3);
})
// When report 1 becomes read (it's the active report)
@@ -350,19 +376,19 @@ describe('Sidebar', () => {
// Then all three chats are still showing
.then(() => {
- expect(sidebarLinks.queryAllByA11yHint('Navigates to a chat')).toHaveLength(3);
+ expect(screen.queryAllByAccessibilityHint('Navigates to a chat')).toHaveLength(3);
})
// When report 2 becomes the active report
.then(() => {
- sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(report2.reportID);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(report2.reportID);
return waitForPromisesToResolve();
})
// Then report 1 should now disappear
.then(() => {
- expect(sidebarLinks.queryAllByA11yHint('Navigates to a chat')).toHaveLength(2);
- expect(sidebarLinks.queryAllByText(/One, Two/)).toHaveLength(0);
+ expect(screen.queryAllByAccessibilityHint('Navigates to a chat')).toHaveLength(2);
+ expect(screen.queryAllByText(/One, Two/)).toHaveLength(0);
});
});
@@ -376,7 +402,7 @@ describe('Sidebar', () => {
...LHNTestUtils.getFakeReport(['email3@test.com', 'email4@test.com']),
isPinned: true,
};
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(draftReport.reportID);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(draftReport.reportID);
return waitForPromisesToResolve()
@@ -390,7 +416,7 @@ describe('Sidebar', () => {
// Then both reports are visible
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(2);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Three, Four');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('One, Two');
@@ -416,7 +442,7 @@ describe('Sidebar', () => {
statusNum: CONST.REPORT.STATUS.CLOSED,
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
};
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
return waitForPromisesToResolve()
@@ -431,14 +457,17 @@ describe('Sidebar', () => {
// Then neither reports are visible
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(0);
})
// When they have unread messages
- .then(() => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${archivedReport.reportID}`, {
- lastActionCreated: DateUtils.getDBTime(),
- }))
+ .then(() => {
+ jest.advanceTimersByTime(10);
+ return Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${archivedReport.reportID}`, {
+ lastActionCreated: DateUtils.getDBTime(),
+ });
+ })
.then(() => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${archivedPolicyRoomReport.reportID}`, {
lastActionCreated: DateUtils.getDBTime(),
}))
@@ -448,7 +477,7 @@ describe('Sidebar', () => {
// Then they are all visible
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
});
});
@@ -463,7 +492,7 @@ describe('Sidebar', () => {
...LHNTestUtils.getFakeReport(['email1@test.com', 'email2@test.com']),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
};
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
return waitForPromisesToResolve()
@@ -477,21 +506,24 @@ describe('Sidebar', () => {
// Then neither reports are visible
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(0);
})
// When they both have unread messages
- .then(() => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${policyRoomReport.reportID}`, {
- lastActionCreated: DateUtils.getDBTime(),
- }))
+ .then(() => {
+ jest.advanceTimersByTime(10);
+ return Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${policyRoomReport.reportID}`, {
+ lastActionCreated: DateUtils.getDBTime(),
+ });
+ })
.then(() => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${userCreatedPolicyRoomReport.reportID}`, {
lastActionCreated: DateUtils.getDBTime(),
}))
// Then both rooms are visible
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(2);
});
});
@@ -554,7 +586,7 @@ describe('Sidebar', () => {
...LHNTestUtils.getAdvancedFakeReport(...boolArr),
policyID: policy.policyID,
};
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
return waitForPromisesToResolve()
@@ -572,13 +604,13 @@ describe('Sidebar', () => {
.then(() => {
if (booleansWhichRemovesActiveReport.indexOf(JSON.stringify(boolArr)) > -1) {
// Only one report visible
- expect(sidebarLinks.queryAllByA11yHint('Navigates to a chat')).toHaveLength(1);
- expect(sidebarLinks.queryAllByA11yLabel('Chat user display names')).toHaveLength(1);
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ expect(screen.queryAllByAccessibilityHint('Navigates to a chat')).toHaveLength(1);
+ expect(screen.queryAllByLabelText('Chat user display names')).toHaveLength(1);
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Three, Four');
} else {
// Both reports visible
- expect(sidebarLinks.queryAllByA11yHint('Navigates to a chat')).toHaveLength(2);
+ expect(screen.queryAllByAccessibilityHint('Navigates to a chat')).toHaveLength(2);
}
});
});
@@ -588,7 +620,7 @@ describe('Sidebar', () => {
describe('Archived chat', () => {
describe('in default (most recent) mode', () => {
it('is shown regardless if it has comments or not', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given an archived report with no comments
const report = {
@@ -617,7 +649,7 @@ describe('Sidebar', () => {
// Then the report is rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(1);
})
@@ -628,7 +660,7 @@ describe('Sidebar', () => {
// Then the report is rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(1);
});
});
@@ -636,7 +668,7 @@ describe('Sidebar', () => {
describe('in GSD (focus) mode', () => {
it('is shown when it is unread', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given an archived report that has all comments read
const report = {
@@ -664,22 +696,25 @@ describe('Sidebar', () => {
// Then the report is not rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(0);
})
// When the report has a new comment and is now unread
- .then(() => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, {lastActionCreated: DateUtils.getDBTime()}))
+ .then(() => {
+ jest.advanceTimersByTime(10);
+ return Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, {lastActionCreated: DateUtils.getDBTime()});
+ })
// Then the report is rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(1);
});
});
it('is shown when it is pinned', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given an archived report that is not pinned
const report = {
@@ -708,7 +743,7 @@ describe('Sidebar', () => {
// Then the report is not rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(0);
})
@@ -717,13 +752,13 @@ describe('Sidebar', () => {
// Then the report is rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(1);
});
});
it('is shown when it is the active report', () => {
- let sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given an archived report that is not the active report
const report = {
@@ -751,19 +786,19 @@ describe('Sidebar', () => {
// Then the report is not rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(0);
})
// When sidebar is rendered with the active report ID matching the archived report in Onyx
.then(() => {
- sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(report.reportID);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(report.reportID);
return waitForPromisesToResolve();
})
// Then the report is rendered in the LHN
.then(() => {
- const optionRows = sidebarLinks.queryAllByA11yHint('Navigates to a chat');
+ const optionRows = screen.queryAllByAccessibilityHint('Navigates to a chat');
expect(optionRows).toHaveLength(1);
});
});
diff --git a/tests/unit/SidebarOrderTest.js b/tests/unit/SidebarOrderTest.js
index 2b44f2f06243..cedf8e91d205 100644
--- a/tests/unit/SidebarOrderTest.js
+++ b/tests/unit/SidebarOrderTest.js
@@ -1,5 +1,5 @@
import Onyx from 'react-native-onyx';
-import {cleanup} from '@testing-library/react-native';
+import {cleanup, screen} from '@testing-library/react-native';
import lodashGet from 'lodash/get';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
import * as LHNTestUtils from '../utils/LHNTestUtils';
@@ -41,16 +41,16 @@ describe('Sidebar', () => {
it('is not rendered when there are no props passed to it', () => {
// Given all the default props are passed to SidebarLinks
// When it is rendered
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Then it should render nothing and be null
// This is expected because there is an early return when there are no personal details
- expect(sidebarLinks.toJSON()).toBe(null);
+ expect(screen.toJSON()).toBe(null);
});
it('is rendered with an empty list when personal details exist', () => {
// Given the sidebar is rendered with default props
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
return waitForPromisesToResolve()
@@ -61,15 +61,15 @@ describe('Sidebar', () => {
// Then the component should be rendered with an empty list since it will get past the early return
.then(() => {
- expect(sidebarLinks.toJSON()).not.toBe(null);
- expect(sidebarLinks.queryAllByA11yHint('Navigates to a chat')).toHaveLength(0);
+ expect(screen.toJSON()).not.toBe(null);
+ expect(screen.queryAllByAccessibilityHint('Navigates to a chat')).toHaveLength(0);
});
});
it('contains one report when a report is in Onyx', () => {
// Given a single report
const report = LHNTestUtils.getFakeReport(['email1@test.com', 'email2@test.com']);
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(report.reportID);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(report.reportID);
return waitForPromisesToResolve()
@@ -82,12 +82,12 @@ describe('Sidebar', () => {
// Then the component should be rendered with an item for the report
.then(() => {
- expect(sidebarLinks.queryAllByText('One, Two')).toHaveLength(1);
+ expect(screen.queryAllByText('One, Two')).toHaveLength(1);
});
});
it('orders items with most recently updated on top', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given three unread reports in the recently updated order of 3, 2, 1
const report1 = {
@@ -113,7 +113,7 @@ describe('Sidebar', () => {
// Then the component should be rendered with the mostly recently updated report first
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Three, Four');
@@ -132,7 +132,7 @@ describe('Sidebar', () => {
const report2 = LHNTestUtils.getFakeReport(['email3@test.com', 'email4@test.com'], 2);
const report3 = LHNTestUtils.getFakeReport(['email5@test.com', 'email6@test.com'], 1);
const reportIDFromRoute = report1.reportID;
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(reportIDFromRoute);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(reportIDFromRoute);
return waitForPromisesToResolve()
// When Onyx is updated with the data and the sidebar re-renders
@@ -147,10 +147,10 @@ describe('Sidebar', () => {
// Then there should be a pencil icon and report one should be the first one because putting a draft on the active report should change its location
// in the ordered list
.then(() => {
- const pencilIcon = sidebarLinks.getAllByAccessibilityHint('Pencil Icon');
+ const pencilIcon = screen.getAllByAccessibilityHint('Pencil Icon');
expect(pencilIcon).toHaveLength(1);
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('One, Two'); // this has `hasDraft` flag enabled so it will be on top
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Five, Six');
@@ -159,7 +159,7 @@ describe('Sidebar', () => {
});
it('reorders the reports to always have the most recently updated one on top', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given three reports in the recently updated order of 3, 2, 1
const report1 = LHNTestUtils.getFakeReport(['email1@test.com', 'email2@test.com'], 3);
@@ -185,7 +185,7 @@ describe('Sidebar', () => {
// Then the order of the reports should be 1 > 3 > 2
// ^--- (1 goes to the front and pushes other two down)
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('One, Two');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Five, Six');
@@ -204,7 +204,7 @@ describe('Sidebar', () => {
};
const report3 = LHNTestUtils.getFakeReport(['email5@test.com', 'email6@test.com'], 1);
const reportIDFromRoute = report2.reportID;
- let sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(reportIDFromRoute);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(reportIDFromRoute);
return waitForPromisesToResolve()
@@ -221,14 +221,14 @@ describe('Sidebar', () => {
.then(() => {
// The changing of a route itself will re-render the component in the App, but since we are not performing this test
// inside the navigator and it has no access to the routes we need to trigger an update to the SidebarLinks manually.
- sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks('1');
+ LHNTestUtils.getDefaultRenderedSidebarLinks('1');
return waitForPromisesToResolve();
})
// Then the order of the reports should be 2 > 3 > 1
// ^--- (2 goes to the front and pushes 3 down)
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Three, Four');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Five, Six');
@@ -237,7 +237,7 @@ describe('Sidebar', () => {
});
it('removes the pencil icon when draft is removed', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given a single report
// And the report has a draft
@@ -257,7 +257,7 @@ describe('Sidebar', () => {
// Then there should be a pencil icon showing
.then(() => {
- expect(sidebarLinks.getAllByAccessibilityHint('Pencil Icon')).toHaveLength(1);
+ expect(screen.getAllByAccessibilityHint('Pencil Icon')).toHaveLength(1);
})
// When the draft is removed
@@ -265,12 +265,12 @@ describe('Sidebar', () => {
// Then the pencil icon goes away
.then(() => {
- expect(sidebarLinks.queryAllByAccessibilityHint('Pencil Icon')).toHaveLength(0);
+ expect(screen.queryAllByAccessibilityHint('Pencil Icon')).toHaveLength(0);
});
});
it('removes the pin icon when chat is unpinned', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given a single report
// And the report is pinned
@@ -290,7 +290,7 @@ describe('Sidebar', () => {
// Then there should be a pencil icon showing
.then(() => {
- expect(sidebarLinks.getAllByAccessibilityHint('Pin Icon')).toHaveLength(1);
+ expect(screen.getAllByAccessibilityHint('Pin Icon')).toHaveLength(1);
})
// When the draft is removed
@@ -298,7 +298,7 @@ describe('Sidebar', () => {
// Then the pencil icon goes away
.then(() => {
- expect(sidebarLinks.queryAllByAccessibilityHint('Pin Icon')).toHaveLength(0);
+ expect(screen.queryAllByAccessibilityHint('Pin Icon')).toHaveLength(0);
});
});
@@ -333,7 +333,7 @@ describe('Sidebar', () => {
report3.iouReportID = iouReport.reportID;
const reportIDFromRoute = report2.reportID;
const currentlyLoggedInUserEmail = 'email9@test.com';
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks(reportIDFromRoute);
+ LHNTestUtils.getDefaultRenderedSidebarLinks(reportIDFromRoute);
return waitForPromisesToResolve()
@@ -352,10 +352,10 @@ describe('Sidebar', () => {
// there is a pencil icon
// there is a pinned icon
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
- expect(sidebarLinks.getAllByAccessibilityHint('Pin Icon')).toHaveLength(1);
- expect(sidebarLinks.getAllByAccessibilityHint('Pencil Icon')).toHaveLength(1);
+ expect(screen.getAllByAccessibilityHint('Pin Icon')).toHaveLength(1);
+ expect(screen.getAllByAccessibilityHint('Pencil Icon')).toHaveLength(1);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('One, Two');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [2, 'props', 'children'])).toBe('Three, Four');
@@ -381,7 +381,7 @@ describe('Sidebar', () => {
...LHNTestUtils.getFakeReport(['email7@test.com', 'email8@test.com'], 0),
isPinned: true,
};
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+ LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return waitForPromisesToResolve()
// When Onyx is updated with the data and the sidebar re-renders
@@ -395,7 +395,7 @@ describe('Sidebar', () => {
// Then the reports are in alphabetical order
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('One, Two');
@@ -407,7 +407,7 @@ describe('Sidebar', () => {
// Then they are still in alphabetical order
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(4);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('One, Two');
@@ -435,7 +435,7 @@ describe('Sidebar', () => {
...LHNTestUtils.getFakeReport(['email7@test.com', 'email8@test.com'], 0),
hasDraft: true,
};
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+ LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return waitForPromisesToResolve()
// When Onyx is updated with the data and the sidebar re-renders
@@ -449,7 +449,7 @@ describe('Sidebar', () => {
// Then the reports are in alphabetical order
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('One, Two');
@@ -461,7 +461,7 @@ describe('Sidebar', () => {
// Then they are still in alphabetical order
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(4);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('One, Two');
@@ -487,7 +487,7 @@ describe('Sidebar', () => {
CONST.BETAS.POLICY_ROOMS,
CONST.BETAS.POLICY_EXPENSE_CHAT,
];
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+ LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return waitForPromisesToResolve()
// When Onyx is updated with the data and the sidebar re-renders
@@ -502,7 +502,7 @@ describe('Sidebar', () => {
// Then the first report is in last position
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Three, Four');
@@ -513,7 +513,7 @@ describe('Sidebar', () => {
describe('in #focus mode', () => {
it('alphabetizes chats', () => {
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks();
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
const report1 = LHNTestUtils.getFakeReport(['email1@test.com', 'email2@test.com'], 3, true);
const report2 = LHNTestUtils.getFakeReport(['email3@test.com', 'email4@test.com'], 2, true);
@@ -534,7 +534,7 @@ describe('Sidebar', () => {
// Then the reports are in alphabetical order
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('One, Two');
@@ -546,7 +546,7 @@ describe('Sidebar', () => {
// Then they are still in alphabetical order
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(4);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('One, Two');
@@ -572,7 +572,7 @@ describe('Sidebar', () => {
CONST.BETAS.POLICY_ROOMS,
CONST.BETAS.POLICY_EXPENSE_CHAT,
];
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+ LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return waitForPromisesToResolve()
// When Onyx is updated with the data and the sidebar re-renders
@@ -587,7 +587,7 @@ describe('Sidebar', () => {
// Then the first report is in last position
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Three, Four');
@@ -648,7 +648,7 @@ describe('Sidebar', () => {
report3.iouReportID = iouReport3.reportID;
const currentlyLoggedInUserEmail = 'email13@test.com';
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+ LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return waitForPromisesToResolve()
// When Onyx is updated with the data and the sidebar re-renders
@@ -666,7 +666,7 @@ describe('Sidebar', () => {
// Then the reports are ordered alphabetically since their amounts are the same
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('One, Two');
@@ -690,7 +690,7 @@ describe('Sidebar', () => {
lastActionCreated,
};
- const sidebarLinks = LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+ LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return waitForPromisesToResolve()
// When Onyx is updated with the data and the sidebar re-renders
@@ -704,7 +704,7 @@ describe('Sidebar', () => {
// Then the reports are ordered alphabetically since their lastActionCreated are the same
.then(() => {
- const displayNames = sidebarLinks.queryAllByA11yLabel('Chat user display names');
+ const displayNames = screen.queryAllByLabelText('Chat user display names');
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('One, Two');
diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js
index 0079313b9cc0..428da5872c55 100644
--- a/tests/utils/LHNTestUtils.js
+++ b/tests/utils/LHNTestUtils.js
@@ -109,7 +109,6 @@ function getAdvancedFakeReport(isArchived, isUserCreatedPolicyRoom, hasAddWorksp
/**
* @param {String} [reportIDFromRoute]
- * @returns {RenderAPI}
*/
function getDefaultRenderedSidebarLinks(reportIDFromRoute = '') {
// An ErrorBoundary needs to be added to the rendering so that any errors that happen while the component
@@ -138,7 +137,7 @@ function getDefaultRenderedSidebarLinks(reportIDFromRoute = '') {
// are passed to the component. If this is not done, then all the locale props are missing
// and there are a lot of render warnings. It needs to be done like this because normally in
// our app (App.js) is when the react application is wrapped in the context providers
- return render((
+ render((
Promise.resolve({
+ HttpUtils.xhr.mockResolvedValue({
onyxData: [
{
onyxMethod: CONST.ONYX.METHOD.MERGE,
@@ -70,7 +68,7 @@ function signInWithTestUser(accountID = 1, login = 'test@user.com', password = '
},
],
jsonCode: 200,
- }));
+ });
// Simulate user entering their login and populating the credentials.login
Session.beginSignIn(login);
@@ -78,7 +76,7 @@ function signInWithTestUser(accountID = 1, login = 'test@user.com', password = '
.then(() => {
// Response is the same for calls to Authenticate and BeginSignIn
HttpUtils.xhr
- .mockImplementation(() => Promise.resolve({
+ .mockResolvedValue({
onyxData: [
{
onyxMethod: CONST.ONYX.METHOD.MERGE,
@@ -112,15 +110,24 @@ function signInWithTestUser(accountID = 1, login = 'test@user.com', password = '
},
],
jsonCode: 200,
- }));
- Session.signIn(password);
- return waitForPromisesToResolve()
- .then(() => {
- HttpUtils.xhr = originalXhr;
});
+ Session.signIn(password);
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ HttpUtils.xhr = originalXhr;
});
}
+function signOutTestUser() {
+ const originalXhr = HttpUtils.xhr;
+ HttpUtils.xhr = jest.fn();
+ HttpUtils.xhr.mockResolvedValue({jsonCode: 200});
+ Session.signOutAndRedirectToSignIn();
+ return waitForPromisesToResolve()
+ .then(() => HttpUtils.xhr = originalXhr);
+}
+
/**
* Use for situations where fetch() is required.
*
@@ -177,6 +184,7 @@ function buildTestReportComment(actorEmail, created, actorAccountID, actionID =
export {
getGlobalFetchMock,
signInWithTestUser,
+ signOutTestUser,
setPersonalDetails,
buildPersonalDetails,
buildTestReportComment,
diff --git a/web/index.html b/web/index.html
index e11198fe9403..0091748cb5f6 100644
--- a/web/index.html
+++ b/web/index.html
@@ -24,7 +24,7 @@
height: 100% !important;
}
:root {
- color-scheme: dark;
+ color-scheme: dark !important;
}
body {
overflow: hidden;
diff --git a/web/splash/splash.js b/web/splash/splash.js
index 178419878970..99cee2206e14 100644
--- a/web/splash/splash.js
+++ b/web/splash/splash.js
@@ -2,10 +2,15 @@ import './splash.css';
import newExpensifyLogo from 'logo?raw';
import themeColors from '../../src/styles/themes/default';
+let areFontsReady = false;
+document.fonts.ready.then(() => {
+ areFontsReady = true;
+});
+
document.addEventListener('DOMContentLoaded', () => {
const minMilisecondsToWait = 1.5 * 1000;
let passedMiliseconds = 0;
- let rootMounted = false;
+ let isRootMounted = false;
const splash = document.getElementById('splash');
const splashLogo = document.querySelector('.splash-logo');
const root = document.getElementById('root');
@@ -19,8 +24,8 @@ document.addEventListener('DOMContentLoaded', () => {
const intervalId = setInterval(() => {
passedMiliseconds += 250;
- rootMounted = root.children.length > 0;
- if (passedMiliseconds >= minMilisecondsToWait && rootMounted) {
+ isRootMounted = root.children.length > 0;
+ if (passedMiliseconds >= minMilisecondsToWait && isRootMounted && areFontsReady) {
splash.parentNode.removeChild(splash);
clearInterval(intervalId);
}