diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml
index 350380aed2b9..1e986010c8dd 100644
--- a/.github/workflows/cherryPick.yml
+++ b/.github/workflows/cherryPick.yml
@@ -205,7 +205,7 @@ jobs:
- name: Auto-merge the PR
# Important: only auto-merge if there was no merge conflict and the PR is mergable (not blocked by a missing status check)!
if: ${{ fromJSON(steps.cherryPick.outputs.SHOULD_AUTOMERGE) && fromJSON(steps.isPullRequestMergeable.outputs.IS_MERGEABLE) }}
- run: gh pr merge --merge --delete-branch
+ run: gh pr merge ${{ steps.createPullRequest.outputs.pr_number }} --merge --delete-branch
env:
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index c0c9fe98676d..608aa21204e1 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -10,7 +10,7 @@ on:
env:
SHOULD_DEPLOY_PRODUCTION: ${{ github.event_name == 'release' }}
- DEVELOPER_DIR: /Applications/Xcode_12.5.1.app/Contents/Developer
+ DEVELOPER_DIR: /Applications/Xcode_13.2.1.app/Contents/Developer
jobs:
validateActor:
diff --git a/.github/workflows/updateProtectedBranch.yml b/.github/workflows/updateProtectedBranch.yml
index 40f00fb63cd0..93c84a2b14c8 100644
--- a/.github/workflows/updateProtectedBranch.yml
+++ b/.github/workflows/updateProtectedBranch.yml
@@ -129,7 +129,7 @@ jobs:
run: exit 1
- name: Auto-merge the PR
- run: gh pr merge --merge --delete-branch
+ run: gh pr merge ${{ steps.createPullRequest.outputs.PR_NUMBER }} --merge --delete-branch
env:
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e750248c7e0b..e50f0418dfa2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -69,6 +69,9 @@ Please follow these steps to propose a job:
## Working on Expensify Jobs
*Reminder: For technical guidance please refer to the [README](https://github.com/Expensify/App/blob/main/README.md)*.
+## Posting Ideas
+Additionally if you want to discuss an idea with the community without having a P/S statement yet, you can post it in #expensify-open-source with the prefix `IDEA:`. All ideas to build the future of Expensify are always welcome! i.e.: "`IDEA:` I don't have a P/S for this yet, but just kicking the idea around... what if we [insert crazy idea]?".
+
#### Make sure you can test on all platforms
* Expensify requires that you can test the app on iOS, MacOS, Android, Web, and mWeb.
* You'll need a Mac to test the iOS and MacOS app.
@@ -83,7 +86,7 @@ Please follow these steps to propose a job:
3. If you cannot reproduce the problem, pause on this step and add a comment to the issue explaining where you are stuck or that you don't think the issue can be reproduced.
#### Propose a solution for the job
-4. After you reproduce the issue, make a proposal for your solution and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). Your solution proposal should include a brief technical explanation of the changes you will make.
+4. After you reproduce the issue, make a proposal for your solution and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). Your solution proposal should include a brief written technical explanation of the changes you will make. Include "Proposal" as the first word in your comment.
- Note: Issues that have not had the `External` label applied have not yet been approved for implementation. This means, if you propose a solution to an issue without the `External` label (which you are allowed to do) it is possible that the issue will be fixed internally. If the `External` label has not yet been applied, Expensify has the right to use your proposal to fix said issue, without providing compensation for your solution. This process covers the very rare instance where we need or want to fix an issue internally.
- Note: Before submitting a proposal on an issue, be sure to read any other existing proposals. Any new proposal should be substantively different from existing proposals.
5. Pause at this step until someone from the Contributor-Plus team and / or someone from Expensify provides feedback on your proposal (do not create a pull request yet).
diff --git a/FORMS.md b/FORMS.md
index 8fc47b8a9b17..65e9cfaeae0c 100644
--- a/FORMS.md
+++ b/FORMS.md
@@ -21,15 +21,19 @@ Labels and hints are enabled by passing the appropriate props to each input:
### Character Limits
-If a field has a character limit we should give that field a max limit and let the user know how many characters there are left outside of the input and below it. This is done by passing the maxLength prop to TextInput.
+If a field has a character limit we should give that field a max limit. This is done by passing the maxLength prop to TextInput.
```jsx
```
+Note: We shouldn't place a max limit on a field if the entered value can be formatted. eg: Phone number.
+The phone number can be formatted in different ways.
-![char-limit](https://user-images.githubusercontent.com/22219519/156266959-945c6d26-be9b-426b-9399-98d31ea214c9.png)
+- 2109400803
+- +12109400803
+- (210)-940-0803
### Native Keyboards
@@ -66,7 +70,9 @@ All forms should define an order in which the inputs should be filled out, and u
3. Add an event listener to the page/component we are creating and update the tab index state on tab/shift + tab key press
4. Set focus to the input with that tab index.
-Additionally, ressing the enter key on any focused field should submit the form.
+Additionally, pressing the enter key on any focused field should submit the form.
+
+Note: This doesn't apply to the multiline fields. To keep the browser behavior consistent, pressing enter on the multiline should not be intercepted. It should follow the default browser behavior (such as adding a newline).
### Modifying User Input on Change
@@ -181,7 +187,6 @@ function onSubmit(values) {
label="Routing number"
inputID="routingNumber"
maxLength={8}
- isFormInput
shouldSaveDraft
/>
@@ -189,19 +194,17 @@ function onSubmit(values) {
label="Account number"
inputID="accountNumber"
containerStyles={[styles.mt4]}
- isFormInput
/>
```
### Props provided to Form inputs
-The following props are available to form inputs:
+The following prop is available to form inputs:
- inputID: An unique identifier for the input.
-- isFormInput: A flag that indicates that this input is being used with Form.js.
-Form.js will automatically provide the following props to any input flagged with the isFormInput prop.
+Form.js will automatically provide the following props to any input with the inputID prop.
- ref: A React ref that must be attached to the input.
- defaultValue: The input default value.
diff --git a/README.md b/README.md
index ce4e9d0d063e..194d402c2f93 100644
--- a/README.md
+++ b/README.md
@@ -128,7 +128,7 @@ This is a persistent storage solution wrapped in a Pub/Sub library. In general t
- Collections of data are usually not stored as a single key (eg. an array with multiple objects), but as individual keys+ID (eg. `report_1234`, `report_4567`, etc.). Store collections as individual keys when a component will bind directly to one of those keys. For example: reports are stored as individual keys because `OptionRow.js` binds to the individual report keys for each link. However, report actions are stored as an array of objects because nothing binds directly to a single report action.
- Onyx allows other code to subscribe to changes in data, and then publishes change events whenever data is changed
- Anything needing to read Onyx data needs to:
- 1. Know what key the data is stored in (for web, you can find this by looking in the JS console > Application > local storage)
+ 1. Know what key the data is stored in (for web, you can find this by looking in the JS console > Application > IndexedDB > OnyxDB > keyvaluepairs)
2. Subscribe to changes of the data for a particular key or set of keys. React components use `withOnyx()` and non-React libs use `Onyx.connect()`.
3. Get initialized with the current value of that key from persistent storage (Onyx does this by calling `setState()` or triggering the `callback` with the values currently on disk as part of the connection process)
- Subscribing to Onyx keys is done using a constant defined in `ONYXKEYS`. Each Onyx key represents either a collection of items or a specific entry in storage. For example, since all reports are stored as individual keys like `report_1234`, if code needs to know about all the reports (eg. display a list of them in the nav menu), then it would subscribe to the key `ONYXKEYS.COLLECTION.REPORT`.
@@ -160,7 +160,7 @@ That action will then call `Onyx.merge()` to [set default data and a loading sta
```js
function signIn(password, twoFactorAuthCode) {
Onyx.merge(ONYXKEYS.ACCOUNT, {loading: true});
- API.Authenticate({
+ Authentication.Authenticate({
...defaultParams,
password,
twoFactorAuthCode,
diff --git a/__mocks__/@react-native-community/netinfo.js b/__mocks__/@react-native-community/netinfo.js
index e6a61dcab80c..13051cd21321 100644
--- a/__mocks__/@react-native-community/netinfo.js
+++ b/__mocks__/@react-native-community/netinfo.js
@@ -1,6 +1,19 @@
-export default {
+const defaultState = {
+ type: 'cellular',
+ isConnected: true,
+ isInternetReachable: true,
+ details: {
+ isConnectionExpensive: true,
+ cellularGeneration: '3g',
+ },
+};
+
+const RNCNetInfoMock = {
configure: () => {},
- fetch: () => {},
- addEventListener: () => {},
+ fetch: () => Promise.resolve(defaultState),
+ refresh: () => Promise.resolve(defaultState),
+ addEventListener: () => (() => {}),
useNetInfo: () => {},
};
+
+export default RNCNetInfoMock;
diff --git a/android/app/build.gradle b/android/app/build.gradle
index fc26ff18e021..6536d77a4f38 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -152,8 +152,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001015600
- versionName "1.1.56-0"
+ versionCode 1001016103
+ versionName "1.1.61-3"
}
splits {
abi {
@@ -239,8 +239,10 @@ dependencies {
implementation jscFlavor
}
+ // Firebase libraries (using the Firebase BoM for consistency - see https://firebase.google.com/docs/android/learn-more#bom)
implementation platform("com.google.firebase:firebase-bom:29.0.3")
implementation "com.google.firebase:firebase-perf"
+ implementation "com.google.firebase:firebase-crashlytics"
// GIF support
implementation 'com.facebook.fresco:fresco:2.5.0'
@@ -252,9 +254,6 @@ dependencies {
// Multi Dex Support: https://developer.android.com/studio/build/multidex#mdex-gradle
implementation 'com.android.support:multidex:1.0.3'
- // Crashlytics
- implementation 'com.google.firebase:firebase-crashlytics:17.2.2'
-
// Plaid SDK
implementation project(':react-native-plaid-link-sdk')
// This okhttp3 dependency prevents the app from crashing - See https://github.com/plaid/react-native-plaid-link-sdk/issues/74#issuecomment-648435002
diff --git a/assets/images/avatars/domain-room.svg b/assets/images/avatars/domain-room.svg
new file mode 100644
index 000000000000..6dcc6407d880
--- /dev/null
+++ b/assets/images/avatars/domain-room.svg
@@ -0,0 +1,16 @@
+
+
+
diff --git a/assets/images/connect.svg b/assets/images/connect.svg
new file mode 100644
index 000000000000..6d4e9eb21d97
--- /dev/null
+++ b/assets/images/connect.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/assets/images/offline.svg b/assets/images/offline.svg
index a4d539125f31..f3b58e11221f 100644
--- a/assets/images/offline.svg
+++ b/assets/images/offline.svg
@@ -1,5 +1,5 @@
diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js
index 1b856a3039c7..99cc83dfc5b9 100644
--- a/config/webpack/webpack.common.js
+++ b/config/webpack/webpack.common.js
@@ -56,7 +56,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({
{from: 'assets/css', to: 'css'},
{from: 'node_modules/react-pdf/dist/esm/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'},
{from: 'assets/images/shadow.png', to: 'images/shadow.png'},
- {from: '.well-known/apple-app-site-association', to: '.well-known/apple-app-site-association'},
+ {from: '.well-known/apple-app-site-association', to: '.well-known/apple-app-site-association', toType: 'file'},
// These files are copied over as per instructions here
// https://github.com/wojtekmaj/react-pdf#copying-cmaps
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index a8d116a440df..11022248ac59 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.1.56
+ 1.1.61
CFBundleSignature
????
CFBundleURLTypes
@@ -30,7 +30,7 @@
CFBundleVersion
- 1.1.56.0
+ 1.1.61.3
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 5427c2240f23..aeca405ac99a 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.1.56
+ 1.1.61
CFBundleSignature
????
CFBundleVersion
- 1.1.56.0
+ 1.1.61.3
diff --git a/package-lock.json b/package-lock.json
index ec9b7ab8d060..f05b89eb37fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.1.56-0",
+ "version": "1.1.61-3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -5345,6 +5345,11 @@
"deep-assign": "^3.0.0"
}
},
+ "@react-native-community/cameraroll": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@react-native-community/cameraroll/-/cameraroll-4.1.2.tgz",
+ "integrity": "sha512-jkdhMByMKD2CZ/5MPeBieYn8vkCfC4MOTouPpBpps3I8N6HUYJk+1JnDdktVYl2WINnqXpQptDA2YptVyifYAg=="
+ },
"@react-native-community/cli": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-6.2.0.tgz",
@@ -6663,9 +6668,9 @@
"dev": true
},
"@react-native-community/netinfo": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-8.0.0.tgz",
- "integrity": "sha512-8cjkbOWe55vzzc64hfjDv6GWSY8+kfEnxRbwTf9l3hFYDIUMRmMoW+SwxE+QoAfMY32nbEERDy68iev3busRFQ=="
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-8.3.0.tgz",
+ "integrity": "sha512-VlmjD7Vg1BacbNhwuJCel1eeD8N2Ps6BEcZe9qoSoeIptpCbC86o4ZqD0meSjJzioKSvgalrkmPgMaVYsVipKw=="
},
"@react-native-community/progress-bar-android": {
"version": "1.0.4",
@@ -23774,8 +23779,8 @@
}
},
"expensify-common": {
- "version": "git+https://github.com/Expensify/expensify-common.git#f77bb4710c13d01153716df7fb087b637ba3b8bd",
- "from": "git+https://github.com/Expensify/expensify-common.git#f77bb4710c13d01153716df7fb087b637ba3b8bd",
+ "version": "git+https://github.com/Expensify/expensify-common.git#427295da130a4eacc184d38693664280d020dffd",
+ "from": "git+https://github.com/Expensify/expensify-common.git#427295da130a4eacc184d38693664280d020dffd",
"requires": {
"classnames": "2.3.1",
"clipboard": "2.0.4",
@@ -23795,14 +23800,6 @@
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
"integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
},
- "lru-cache": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
- "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "requires": {
- "yallist": "^4.0.0"
- }
- },
"react": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz",
@@ -23825,17 +23822,12 @@
}
},
"semver": {
- "version": "7.3.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
- "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
+ "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"requires": {
"lru-cache": "^6.0.0"
}
- },
- "yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
},
@@ -30725,6 +30717,14 @@
"highlight.js": "~10.7.0"
}
},
+ "lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ },
"make-cancellable-promise": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-1.0.0.tgz",
@@ -35562,9 +35562,9 @@
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"pusher-js": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-7.0.0.tgz",
- "integrity": "sha512-2ZSw8msMe6EKNTebQSthRInrWUK9bo3zXPmQx0bfeDFJdSnTWUROhdAhmpRQREHzqrL+l4imv/3uwgIQHUO0oQ==",
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-7.0.6.tgz",
+ "integrity": "sha512-I44FTlF2OfGNg/4xcxmFq/JqFzJswoQWtWCPq+DkCh31MFg3Qkm3bNFvTXU+c5KR19TyBZ9SYlYq2rrpJZzbIA==",
"requires": {
"tweetnacl": "^1.0.3"
}
@@ -36520,8 +36520,8 @@
}
},
"react-native-onyx": {
- "version": "git+https://github.com/Expensify/react-native-onyx.git#2d42566779884bb445aa59dd48b5bcdff6c0a4e6",
- "from": "git+https://github.com/Expensify/react-native-onyx.git#2d42566779884bb445aa59dd48b5bcdff6c0a4e6",
+ "version": "git+https://github.com/Expensify/react-native-onyx.git#7ab6aed5ce9158f7017ee1c9fd8b5d725d57db73",
+ "from": "git+https://github.com/Expensify/react-native-onyx.git#7ab6aed5ce9158f7017ee1c9fd8b5d725d57db73",
"requires": {
"ascii-table": "0.0.9",
"expensify-common": "git+https://github.com/Expensify/expensify-common.git#2e5cff552cf132da90a3fb9756e6b4fb6ae7b40c",
@@ -38815,11 +38815,6 @@
"dev": true,
"optional": true
},
- "smoothscroll-polyfill": {
- "version": "0.4.4",
- "resolved": "https://registry.npmjs.org/smoothscroll-polyfill/-/smoothscroll-polyfill-0.4.4.tgz",
- "integrity": "sha512-TK5ZA9U5RqCwMpfoMq/l1mrH0JAR7y7KRvOBx0n2869aLxch+gT9GhN3yUfjiw+d/DiF1mKo14+hd62JyMmoBg=="
- },
"snapdragon": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
@@ -43082,6 +43077,11 @@
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
+ "yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+ },
"yaml": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz",
diff --git a/package.json b/package.json
index 34dea81d749a..e3974a4f014f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.1.56-0",
+ "version": "1.1.61-3",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -9,15 +9,17 @@
"scripts": {
"postinstall": "scripts/react-native-web.sh && cd desktop && npm install",
"clean": "react-native clean-project-auto",
- "android": "npm run check-metro-bundler-port && react-native run-android",
- "ios": "npm run check-metro-bundler-port && react-native run-ios",
+ "android": "npm run check-metro-bundler-port && scripts/set-pusher-suffix.sh && react-native run-android",
+ "ios": "npm run check-metro-bundler-port && scripts/set-pusher-suffix.sh && react-native run-ios",
"ipad": "npm run check-metro-bundler-port && react-native run-ios --simulator=\"iPad Pro (12.9-inch) (4th generation)\"",
"ipad-sm": "npm run check-metro-bundler-port && react-native run-ios --simulator=\"iPad Pro (9.7-inch)\"",
"start": "react-native start",
- "web": "node web/proxy.js & webpack-dev-server --open --config config/webpack/webpack.dev.js",
+ "web": "scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server",
+ "web-proxy": "node web/proxy.js",
+ "web-server": "webpack-dev-server --open --config config/webpack/webpack.dev.js",
"build": "webpack --config config/webpack/webpack.common.js --env.envFile=.env.production",
"build-staging": "webpack --config config/webpack/webpack.common.js --env.envFile=.env.staging",
- "desktop": "node desktop/start.js",
+ "desktop": "scripts/set-pusher-suffix.sh && node desktop/start.js",
"desktop-build": "scripts/build-desktop.sh production",
"desktop-build-staging": "scripts/build-desktop.sh staging",
"ios-build": "fastlane ios build",
@@ -39,10 +41,11 @@
"@formatjs/intl-pluralrules": "^4.0.13",
"@onfido/react-native-sdk": "^2.2.0",
"@react-native-async-storage/async-storage": "^1.15.5",
+ "@react-native-community/cameraroll": "^4.1.2",
"@react-native-community/cli": "6.2.0",
"@react-native-community/clipboard": "^1.5.1",
"@react-native-community/datetimepicker": "^3.5.2",
- "@react-native-community/netinfo": "^8.0.0",
+ "@react-native-community/netinfo": "^8.3.0",
"@react-native-community/progress-bar-android": "^1.0.4",
"@react-native-community/progress-view": "^1.2.3",
"@react-native-firebase/analytics": "^12.3.0",
@@ -59,7 +62,7 @@
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
"dotenv": "^8.2.0",
- "expensify-common": "git+https://github.com/Expensify/expensify-common.git#f77bb4710c13d01153716df7fb087b637ba3b8bd",
+ "expensify-common": "git+https://github.com/Expensify/expensify-common.git#427295da130a4eacc184d38693664280d020dffd",
"fbjs": "^3.0.2",
"file-loader": "^6.0.0",
"html-entities": "^1.3.1",
@@ -71,7 +74,7 @@
"moment-timezone": "^0.5.31",
"onfido-sdk-ui": "^6.15.2",
"prop-types": "^15.7.2",
- "pusher-js": "^7.0.0",
+ "pusher-js": "^7.0.6",
"react": "^17.0.2",
"react-collapse": "^5.1.0",
"react-dom": "^17.0.2",
@@ -88,7 +91,7 @@
"react-native-image-size": "^1.1.3",
"react-native-keyboard-spacer": "^0.4.1",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "git+https://github.com/Expensify/react-native-onyx.git#2d42566779884bb445aa59dd48b5bcdff6c0a4e6",
+ "react-native-onyx": "git+https://github.com/Expensify/react-native-onyx.git#7ab6aed5ce9158f7017ee1c9fd8b5d725d57db73",
"react-native-pdf": "^6.2.2",
"react-native-performance": "^2.0.0",
"react-native-permissions": "^3.0.1",
@@ -107,7 +110,6 @@
"rn-fetch-blob": "^0.12.0",
"save": "^2.4.0",
"shim-keyboard-event-key": "^1.0.3",
- "smoothscroll-polyfill": "^0.4.4",
"underscore": "^1.13.1",
"urbanairship-react-native": "^11.0.2"
},
diff --git a/scripts/set-pusher-suffix.sh b/scripts/set-pusher-suffix.sh
new file mode 100755
index 000000000000..4f59e3f87cb6
--- /dev/null
+++ b/scripts/set-pusher-suffix.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# a script that sets Pusher room suffix (for internal usage)
+
+# config file to be parsed for the suffix (relative to current project root)
+CONFIG_FILE="../Web-Expensify/_config.local.php"
+
+# use the suffix only when the config file can be found
+if [ -f "$CONFIG_FILE" ]; then
+ echo "Using PUSHER_DEV_SUFFIX from $CONFIG_FILE"
+
+ PATTERN="PUSHER_DEV_SUFFIX.*'(.+)'"
+ while read -r line; do
+ if [[ $line =~ $PATTERN ]]; then
+ PUSHER_DEV_SUFFIX=${BASH_REMATCH[1]}
+ echo "Found suffix: $PUSHER_DEV_SUFFIX"
+ echo "Updating .env"
+
+ # delete any old suffix value and append the new one
+ sed -i '' '/^PUSHER_DEV_SUFFIX/d' '.env' || true
+ # a dash '-' is prepended to separate the suffix from trailing channel IDs (accountID, reportID, etc).
+ echo "PUSHER_DEV_SUFFIX=-${PUSHER_DEV_SUFFIX}" >> .env
+ fi
+ done < "$CONFIG_FILE"
+fi
diff --git a/src/App.js b/src/App.js
index 677e93ad7a32..bcbd148aa63d 100644
--- a/src/App.js
+++ b/src/App.js
@@ -10,7 +10,6 @@ import OnyxProvider from './components/OnyxProvider';
import HTMLEngineProvider from './components/HTMLEngineProvider';
import ComposeProviders from './components/ComposeProviders';
import SafeArea from './components/SafeArea';
-import initializeiOSSafariAutoScrollback from './libs/iOSSafariAutoScrollback';
LogBox.ignoreLogs([
// Basically it means that if the app goes in the background and back to foreground on Android,
@@ -41,6 +40,4 @@ const App = () => (
App.displayName = 'App';
-initializeiOSSafariAutoScrollback();
-
export default App;
diff --git a/src/CONFIG.js b/src/CONFIG.js
index 7818606c9b4a..6f47755cca22 100644
--- a/src/CONFIG.js
+++ b/src/CONFIG.js
@@ -53,6 +53,7 @@ export default {
IS_USING_LOCAL_WEB: useNgrok || expensifyURLRoot.includes('dev'),
PUSHER: {
APP_KEY: lodashGet(Config, 'PUSHER_APP_KEY', '268df511a204fbb60884'),
+ SUFFIX: lodashGet(Config, 'PUSHER_DEV_SUFFIX', ''),
CLUSTER: 'mt1',
},
SITE_TITLE: 'New Expensify',
diff --git a/src/CONST.js b/src/CONST.js
index 4a7a3f87e0d8..c0e26e78fca7 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -86,7 +86,6 @@ const CONST = {
PENDING: 'PENDING',
},
MAX_LENGTH: {
- TAX_ID_NUMBER: 9,
SSN: 4,
ZIP_CODE: 5,
},
@@ -111,7 +110,6 @@ const CONST = {
IOU_SEND: 'sendMoney',
POLICY_ROOMS: 'policyRooms',
POLICY_EXPENSE_CHAT: 'policyExpenseChat',
- MONTHLY_SETTLEMENTS: 'monthlySettlements',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -315,14 +313,16 @@ const CONST = {
SUCCESS: 200,
NOT_AUTHENTICATED: 407,
EXP_ERROR: 666,
+ UNABLE_TO_RETRY: 'unableToRetry',
},
ERROR: {
XHR_FAILED: 'xhrFailed',
- API_OFFLINE: 'session.offlineMessageRetry',
UNKNOWN_ERROR: 'Unknown error',
REQUEST_CANCELLED: 'AbortError',
FAILED_TO_FETCH: 'Failed to fetch',
ENSURE_BUGBOT: 'ENSURE_BUGBOT',
+ PUSHER_ERROR: 'PusherError',
+ WEB_SOCKET_ERROR: 'WebSocketError',
NETWORK_REQUEST_FAILED: 'Network request failed',
SAFARI_DOCUMENT_LOAD_ABORTED: 'cancelled',
FIREFOX_DOCUMENT_LOAD_ABORTED: 'NetworkError when attempting to fetch resource.',
@@ -387,6 +387,10 @@ const CONST = {
EMOJI_FREQUENT_ROW_COUNT: 3,
+ EMOJI_INVISIBLE_CODEPOINT: 'fe0f',
+
+ TOOLTIP_MAX_LINES: 3,
+
LOGIN_TYPE: {
PHONE: 'phone',
EMAIL: 'email',
@@ -399,12 +403,25 @@ const CONST = {
},
ATTACHMENT_SOURCE_ATTRIBUTE: 'data-expensify-source',
+ ATTACHMENT_PREVIEW_ATTRIBUTE: 'src',
+ ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE: 'data-name',
ATTACHMENT_PICKER_TYPE: {
FILE: 'file',
IMAGE: 'image',
},
+ ATTACHMENT_FILE_TYPE: {
+ FILE: 'file',
+ IMAGE: 'image',
+ VIDEO: 'video',
+ },
+
+ FILE_TYPE_REGEX: {
+ IMAGE: /\.(jpg|jpeg|png|webp|avif|gif|tiff|wbmp|ico|jng|bmp|heic|svg|svg2)$/,
+ VIDEO: /\.(3gp|h261|h263|h264|m4s|jpgv|jpm|jpgm|mp4|mp4v|mpg4|mpeg|mpg|ogv|ogg|mov|qt|webm|flv|mkv|wmv|wav|avi|movie|f4v|avchd|mp2|mpe|mpv|m4v|swf)$/,
+ },
+ IOS_CAMERAROLL_ACCESS_ERROR: 'Access to photo library was denied',
ADD_PAYMENT_MENU_POSITION_Y: 226,
ADD_PAYMENT_MENU_POSITION_X: 356,
EMOJI_PICKER_SIZE: {
@@ -415,7 +432,7 @@ const CONST = {
EMOJI_PICKER_ITEM_HEIGHT: 40,
EMOJI_PICKER_HEADER_HEIGHT: 38,
- COMPOSER_MAX_HEIGHT: 116,
+ COMPOSER_MAX_HEIGHT: 125,
EMAIL: {
CONCIERGE: 'concierge@expensify.com',
@@ -608,11 +625,10 @@ const CONST = {
COMPACT: 'compact',
DEFAULT: 'default',
},
- PHONE_MAX_LENGTH: 15,
- PHONE_MIN_LENGTH: 5,
REGEX: {
SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g,
US_PHONE: /^\+1\d{10}$/,
+ US_PHONE_WITH_OPTIONAL_COUNTRY_CODE: /^(\+1)?\d{10}$/,
DIGITS_AND_PLUS: /^\+?[0-9]*$/,
PHONE_E164_PLUS: /^\+?[1-9]\d{1,14}$/,
PHONE_WITH_SPECIAL_CHARS: /^[+]*[(]{0,1}[0-9]{1,3}[)]{0,1}[-\s\\./0-9]{0,12}$/,
@@ -630,7 +646,7 @@ const CONST = {
CARD_SECURITY_CODE: /^[0-9]{3,4}$/,
CARD_EXPIRATION_DATE: /^(0[1-9]|1[0-2])([^0-9])?([0-9]{4}|([0-9]{2}))$/,
PAYPAL_ME_USERNAME: /^[a-zA-Z0-9]+$/,
- RATE_VALUE: /^\d+(\.\d*)?$/,
+ RATE_VALUE: /^\d{1,8}(\.\d*)?$/,
// Adapted from: https://gist.github.com/dperini/729294
// eslint-disable-next-line max-len
@@ -638,6 +654,8 @@ const CONST = {
// eslint-disable-next-line max-len, no-misleading-character-class
EMOJIS: /(?:\uD83D(?:\uDC41\u200D\uD83D\uDDE8|\uDC68\u200D\uD83D[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uDC69\u200D\uD83D\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|[\ud83c\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|[\ud83c\ude32-\ude3a]|[\ud83c\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/g,
+ TAX_ID: /^\d{9}$/,
+ NON_NUMERIC: /\D/g,
},
PRONOUNS: {
@@ -675,6 +693,9 @@ const CONST = {
this.EMAIL.ADMIN,
];
},
+
+ // There's a limit of 60k characters in Auth - https://github.com/Expensify/Auth/blob/198d59547f71fdee8121325e8bc9241fc9c3236a/auth/lib/Request.h#L28
+ MAX_COMMENT_LENGTH: 60000,
};
export default CONST;
diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js
index 0bb4ed4ea3f4..e3ec98b6052e 100755
--- a/src/ONYXKEYS.js
+++ b/src/ONYXKEYS.js
@@ -170,6 +170,9 @@ export default {
// Is report data loading?
IS_LOADING_REPORT_DATA: 'isLoadingReportData',
+ // Is policy data loading?
+ IS_LOADING_POLICY_DATA: 'isLoadingPolicyData',
+
// Are we loading the create policy room command
IS_LOADING_CREATE_POLICY_ROOM: 'isLoadingCratePolicyRoom',
diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js
index 39d1a8f9dd09..73fb51e1a929 100644
--- a/src/components/AddPlaidBankAccount.js
+++ b/src/components/AddPlaidBankAccount.js
@@ -118,6 +118,7 @@ class AddPlaidBankAccount extends React.Component {
this.clearError = inputKey => ReimbursementAccountUtils.clearError(this.props, inputKey);
this.getErrorText = inputKey => ReimbursementAccountUtils.getErrorText(this.props, {
password: 'passwordForm.error.incorrectPassword',
+ selectedBank: 'bankAccount.error.noBankAccountSelected',
}, inputKey);
}
@@ -253,7 +254,7 @@ class AddPlaidBankAccount extends React.Component {
label: this.props.translate('bankAccount.chooseAnAccount'),
} : {}}
value={this.state.selectedIndex}
- hasError={this.getErrors().selectedBank}
+ errorText={this.getErrorText('selectedBank')}
/>
{!_.isUndefined(this.state.selectedIndex) && this.props.isPasswordRequired && (
diff --git a/src/components/AddressSearch.js b/src/components/AddressSearch.js
index a8d069e77cb6..9458a85361f4 100644
--- a/src/components/AddressSearch.js
+++ b/src/components/AddressSearch.js
@@ -1,5 +1,5 @@
import _ from 'underscore';
-import React, {useRef, useState} from 'react';
+import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {LogBox, ScrollView, View} from 'react-native';
import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete';
@@ -9,7 +9,6 @@ import styles from '../styles/styles';
import TextInput from './TextInput';
import Log from '../libs/Log';
import * as GooglePlacesUtils from '../libs/GooglePlacesUtils';
-import * as FormUtils from '../libs/FormUtils';
// The error that's being thrown below will be ignored until we fork the
// react-native-google-places-autocomplete repo and replace the
@@ -17,16 +16,8 @@ import * as FormUtils from '../libs/FormUtils';
LogBox.ignoreLogs(['VirtualizedLists should never be nested']);
const propTypes = {
- /** Indicates that the input is being used with the Form component */
- isFormInput: PropTypes.bool,
-
- /**
- * The ID used to uniquely identify the input
- *
- * @param {Object} props - props passed to the input
- * @returns {Object} - returns an Error object if isFormInput is supplied but inputID is falsey or not a string
- */
- inputID: props => FormUtils.validateInputIDProps(props),
+ /** 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,
@@ -37,12 +28,18 @@ const propTypes = {
/** Error text to display */
errorText: PropTypes.string,
+ /** Hint text to display */
+ hint: PropTypes.string,
+
/** The label to display for the field */
label: PropTypes.string.isRequired,
/** The value to set the field to initially */
value: PropTypes.string,
+ /** The value to set the field to initially */
+ defaultValue: PropTypes.string,
+
/** A callback function when the value of this field has changed */
onInputChange: PropTypes.func.isRequired,
@@ -53,12 +50,13 @@ const propTypes = {
};
const defaultProps = {
- isFormInput: false,
inputID: undefined,
shouldSaveDraft: false,
onBlur: () => {},
errorText: '',
+ hint: '',
value: undefined,
+ defaultValue: undefined,
containerStyles: [],
};
@@ -68,11 +66,6 @@ const defaultProps = {
const AddressSearch = (props) => {
const [displayListViewBorder, setDisplayListViewBorder] = useState(false);
- // We use `skippedFirstOnChangeTextRef` to work around a feature of the library:
- // The library is calling onChangeText with '' at the start and we don't need this
- // https://github.com/FaridSafi/react-native-google-places-autocomplete/blob/47d7223dd48f85da97e80a0729a985bbbcee353f/GooglePlacesAutocomplete.js#L148
- const skippedFirstOnChangeTextRef = useRef(false);
-
const saveLocationDetails = (details) => {
const addressComponents = details.address_components;
if (!addressComponents) {
@@ -168,17 +161,18 @@ const AddressSearch = (props) => {
label: props.label,
containerStyles: props.containerStyles,
errorText: props.errorText,
+ hint: props.hint,
value: props.value,
- isFormInput: props.isFormInput,
+ defaultValue: props.defaultValue,
inputID: props.inputID,
shouldSaveDraft: props.shouldSaveDraft,
onBlur: props.onBlur,
autoComplete: 'off',
- onChangeText: (text) => {
- if (skippedFirstOnChangeTextRef.current) {
- props.onInputChange({street: text});
+ onInputChange: (text) => {
+ if (props.inputID) {
+ props.onInputChange(text);
} else {
- skippedFirstOnChangeTextRef.current = true;
+ props.onInputChange({street: text});
}
// If the text is empty, we set displayListViewBorder to false to prevent UI flickering
diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js
index c673677b73f2..dab9d2ea718f 100644
--- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js
+++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly/index.js
@@ -10,6 +10,9 @@ import * as ReportActionContextMenu from '../../../pages/home/report/ContextMenu
import * as ContextMenuActions from '../../../pages/home/report/ContextMenu/ContextMenuActions';
import AttachmentView from '../../AttachmentView';
import fileDownload from '../../../libs/fileDownload';
+import Tooltip from '../../Tooltip';
+import canUseTouchScreen from '../../../libs/canUseTouchscreen';
+import styles from '../../../styles/styles';
/*
* This is a default anchor component for regular links.
@@ -38,6 +41,8 @@ class BaseAnchorForCommentsOnly extends React.Component {
render() {
let linkRef;
const rest = _.omit(this.props, _.keys(propTypes));
+ const defaultTextStyle = canUseTouchScreen() || this.props.isSmallScreenWidth ? {} : styles.userSelectText;
+
return (
this.props.isAttachment
? (
@@ -70,20 +75,22 @@ class BaseAnchorForCommentsOnly extends React.Component {
}
}
>
- linkRef = el}
- style={StyleSheet.flatten(this.props.style)}
- accessibilityRole="link"
- href={this.props.href}
- hrefAttrs={{
- rel: this.props.rel,
- target: this.props.target,
- }}
- // eslint-disable-next-line react/jsx-props-no-spreading
- {...rest}
- >
- {this.props.children}
-
+
+ linkRef = el}
+ style={StyleSheet.flatten([this.props.style, defaultTextStyle])}
+ accessibilityRole="link"
+ href={this.props.href}
+ hrefAttrs={{
+ rel: this.props.rel,
+ target: this.props.target,
+ }}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...rest}
+ >
+ {this.props.children}
+
+
)
);
diff --git a/src/components/ArchivedReportFooter.js b/src/components/ArchivedReportFooter.js
index cafc106ce2a4..bcb204c98369 100644
--- a/src/components/ArchivedReportFooter.js
+++ b/src/components/ArchivedReportFooter.js
@@ -8,7 +8,7 @@ import withLocalize, {withLocalizePropTypes} from './withLocalize';
import compose from '../libs/compose';
import personalDetailsPropType from '../pages/personalDetailsPropType';
import ONYXKEYS from '../ONYXKEYS';
-import * as ReportUtils from '../libs/reportUtils';
+import * as ReportUtils from '../libs/ReportUtils';
const propTypes = {
/** The reason this report was archived */
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 2568e634e884..9737a3377ed7 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -18,6 +18,7 @@ import fileDownload from '../libs/fileDownload';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import ConfirmModal from './ConfirmModal';
import TextWithEllipsis from './TextWithEllipsis';
+import HeaderGap from './HeaderGap';
/**
* Modal render prop component that exposes modal launching triggers that can be used
@@ -25,9 +26,6 @@ import TextWithEllipsis from './TextWithEllipsis';
*/
const propTypes = {
- /** Determines title of the modal header depending on if we are uploading an attachment or not */
- isUploadingAttachment: PropTypes.bool,
-
/** Optional source URL for the image shown. If not passed in via props must be specified when modal is opened. */
sourceURL: PropTypes.string,
@@ -46,17 +44,24 @@ const propTypes = {
/** Do the urls require an authToken? */
isAuthTokenRequired: PropTypes.bool,
+ /** Determines if download Button should be shown or not */
+ allowDownload: PropTypes.bool,
+
+ /** Title shown in the header of the modal */
+ headerTitle: PropTypes.string,
+
...withLocalizePropTypes,
...windowDimensionsPropTypes,
};
const defaultProps = {
- isUploadingAttachment: false,
sourceURL: null,
onConfirm: null,
originalFileName: null,
isAuthTokenRequired: false,
+ allowDownload: false,
+ headerTitle: null,
onModalHide: () => {},
};
@@ -145,6 +150,7 @@ class AttachmentModal extends PureComponent {
: [styles.imageModalImageCenterContainer, styles.p5];
const {fileName, fileExtension} = this.splitExtensionFromFileName();
+
return (
<>
+ {this.props.isSmallScreenWidth && }
fileDownload(sourceURL, this.props.originalFileName)}
onCloseButtonPress={() => this.setState({isModalOpen: false})}
- subtitle={(
+ subtitle={fileName ? (
- )}
+ ) : ''}
/>
{this.state.sourceURL && (
diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js
index 7ac5303b02b8..02644c75fec2 100644
--- a/src/components/AvatarWithImagePicker.js
+++ b/src/components/AvatarWithImagePicker.js
@@ -220,8 +220,6 @@ class AvatarWithImagePicker extends React.Component {
onItemSelected={() => this.setState({isMenuVisible: false})}
menuItems={this.createMenuItems(openPicker)}
anchorPosition={this.props.anchorPosition}
- animationIn="fadeInDown"
- animationOut="fadeOutUp"
/>
>
)
diff --git a/src/components/Button.js b/src/components/Button.js
index d192c43f7738..85254d4c1e75 100644
--- a/src/components/Button.js
+++ b/src/components/Button.js
@@ -1,4 +1,5 @@
import React, {Component} from 'react';
+import {withNavigationFocus} from '@react-navigation/compat';
import {Pressable, ActivityIndicator, View} from 'react-native';
import PropTypes from 'prop-types';
import styles from '../styles/styles';
@@ -10,14 +11,30 @@ import Icon from './Icon';
import CONST from '../CONST';
import * as StyleUtils from '../styles/StyleUtils';
import HapticFeedback from '../libs/HapticFeedback';
+import withNavigationFallback from './withNavigationFallback';
+import compose from '../libs/compose';
+import * as Expensicons from './Icon/Expensicons';
+import colors from '../styles/colors';
const propTypes = {
/** The text for the button label */
text: PropTypes.string,
+ /** Boolean whether to display the right icon */
+ shouldShowRightIcon: PropTypes.bool,
+
/** The icon asset to display to the left of the text */
icon: PropTypes.func,
+ /** The icon asset to display to the right of the text */
+ iconRight: PropTypes.func,
+
+ /** The fill color to pass into the icon. */
+ iconFill: PropTypes.string,
+
+ /** Any additional styles to pass to the icon container. */
+ iconStyles: PropTypes.arrayOf(PropTypes.object),
+
/** Small sized button */
small: PropTypes.bool,
@@ -27,6 +44,9 @@ const propTypes = {
/** medium sized button */
medium: PropTypes.bool,
+ /** Extra large sized button */
+ extraLarge: PropTypes.bool,
+
/** Indicates whether the button should be disabled and in the loading state */
isLoading: PropTypes.bool,
@@ -80,16 +100,24 @@ const propTypes = {
/** Should enable the haptic feedback? */
shouldEnableHapticFeedback: PropTypes.bool,
+
+ /** Whether Button is on active screen */
+ isFocused: PropTypes.bool.isRequired,
};
const defaultProps = {
text: '',
+ shouldShowRightIcon: false,
icon: null,
+ iconRight: Expensicons.ArrowRight,
+ iconFill: colors.white,
+ iconStyles: [],
isLoading: false,
isDisabled: false,
small: false,
large: false,
medium: false,
+ extraLarge: false,
onPress: () => {},
onLongPress: () => {},
onPressIn: () => {},
@@ -124,7 +152,7 @@ class Button extends Component {
// Setup and attach keypress handler for pressing the button with Enter key
this.unsubscribe = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, (e) => {
- if (this.props.isDisabled || this.props.isLoading || (e && e.target.nodeName === 'TEXTAREA')) {
+ if (!this.props.isFocused || this.props.isDisabled || this.props.isLoading || (e && e.target.nodeName === 'TEXTAREA')) {
return;
}
this.props.onPress();
@@ -158,6 +186,7 @@ class Button extends Component {
this.props.small && styles.buttonSmallText,
this.props.medium && styles.buttonMediumText,
this.props.large && styles.buttonLargeText,
+ this.props.extraLarge && styles.buttonExtraLargeText,
this.props.success && styles.buttonSuccessText,
this.props.danger && styles.buttonDangerText,
...this.props.textStyles,
@@ -169,15 +198,29 @@ class Button extends Component {
if (this.props.icon) {
return (
-
-
-
+
+
+
+
+
+ {textComponent}
- {textComponent}
+ {this.props.shouldShowRightIcon && (
+
+
+
+ )}
);
}
@@ -216,6 +259,7 @@ class Button extends Component {
this.props.small ? styles.buttonSmall : undefined,
this.props.medium ? styles.buttonMedium : undefined,
this.props.large ? styles.buttonLarge : undefined,
+ this.props.extraLarge ? styles.buttonExtraLarge : undefined,
this.props.success ? styles.buttonSuccess : undefined,
this.props.danger ? styles.buttonDanger : undefined,
(this.props.isDisabled && this.props.success) ? styles.buttonSuccessDisabled : undefined,
@@ -239,4 +283,7 @@ class Button extends Component {
Button.propTypes = propTypes;
Button.defaultProps = defaultProps;
-export default Button;
+export default compose(
+ withNavigationFallback,
+ withNavigationFocus,
+)(Button);
diff --git a/src/components/ButtonWithMenu.js b/src/components/ButtonWithMenu.js
index c597098f968a..e9fafa75095e 100644
--- a/src/components/ButtonWithMenu.js
+++ b/src/components/ButtonWithMenu.js
@@ -82,8 +82,6 @@ class ButtonWithMenu extends PureComponent {
onClose={() => this.setMenuVisibility(false)}
onItemSelected={() => this.setMenuVisibility(false)}
anchorPosition={styles.createMenuPositionRightSidepane}
- animationIn="fadeInUp"
- animationOut="fadeOutDown"
headerText={this.props.menuHeaderText}
menuItems={_.map(this.props.options, item => ({
...item,
diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js
index 0b355d05fa21..d92a7061ae5d 100644
--- a/src/components/CheckboxWithLabel.js
+++ b/src/components/CheckboxWithLabel.js
@@ -6,7 +6,6 @@ import styles from '../styles/styles';
import Checkbox from './Checkbox';
import Text from './Text';
import InlineErrorText from './InlineErrorText';
-import * as FormUtils from '../libs/FormUtils';
const propTypes = {
/** Whether the checkbox is checked */
@@ -27,29 +26,20 @@ const propTypes = {
/** Error text to display */
errorText: PropTypes.string,
- /** Indicates that the input is being used with the Form component */
- isFormInput: PropTypes.bool,
-
/** The default value for the checkbox */
defaultValue: PropTypes.bool,
/** React ref being forwarded to the Checkbox input */
forwardedRef: PropTypes.func,
- /**
- * The ID used to uniquely identify the input
- *
- * @param {Object} props - props passed to the input
- * @returns {Object} - returns an Error object if isFormInput is supplied but inputID is falsey or not a string
- */
- inputID: props => FormUtils.validateInputIDProps(props),
+ /** 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,
};
const defaultProps = {
- isFormInput: false,
inputID: undefined,
style: [],
label: undefined,
@@ -86,7 +76,6 @@ const CheckboxWithLabel = (props) => {
label={props.label}
hasError={Boolean(props.errorText)}
forwardedRef={props.forwardedRef}
- isFormInput={props.isFormInput}
inputID={props.inputID}
shouldSaveDraft={props.shouldSaveDraft}
/>
diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js
index ed23d98020f2..687ac17ff91b 100644
--- a/src/components/Composer/index.android.js
+++ b/src/components/Composer/index.android.js
@@ -64,6 +64,7 @@ class Composer extends React.Component {
render() {
return (
this.textInput = el}
maxHeight={CONST.COMPOSER_MAX_HEIGHT}
diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js
index 73784ace874e..d61339f18f3f 100644
--- a/src/components/Composer/index.ios.js
+++ b/src/components/Composer/index.ios.js
@@ -76,6 +76,7 @@ class Composer extends React.Component {
const propsToPass = _.omit(this.props, 'selection');
return (
this.textInput = el}
maxHeight={CONST.COMPOSER_MAX_HEIGHT}
diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js
index caec71317e84..740d28757a6f 100755
--- a/src/components/Composer/index.js
+++ b/src/components/Composer/index.js
@@ -348,6 +348,7 @@ class Composer extends React.Component {
const propsWithoutStyles = _.omit(this.props, 'style');
return (
this.textInput = el}
selection={this.state.selection}
diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.js
index 58b361fca823..339e36afd6eb 100644
--- a/src/components/ContextMenuItem.js
+++ b/src/components/ContextMenuItem.js
@@ -29,6 +29,9 @@ const propTypes = {
/** Automatically reset the success status */
autoReset: PropTypes.bool,
+
+ /** A description text to show under the title */
+ description: PropTypes.string,
};
const defaultProps = {
@@ -36,6 +39,7 @@ const defaultProps = {
successIcon: null,
successText: '',
autoReset: false,
+ description: '',
};
class ContextMenuItem extends Component {
@@ -109,6 +113,7 @@ class ContextMenuItem extends Component {
onPress={this.triggerPressAndUpdateSuccess}
wrapperStyle={styles.pr9}
success={this.state.success}
+ description={this.props.description}
/>
)
);
diff --git a/src/components/CopySelectionHelper.js b/src/components/CopySelectionHelper.js
new file mode 100644
index 000000000000..5f00bab3146b
--- /dev/null
+++ b/src/components/CopySelectionHelper.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import CONST from '../CONST';
+import KeyboardShortcut from '../libs/KeyboardShortcut';
+import Clipboard from '../libs/Clipboard';
+import SelectionScraper from '../libs/SelectionScraper';
+
+class CopySelectionHelper extends React.Component {
+ componentDidMount() {
+ const copyShortcutConfig = CONST.KEYBOARD_SHORTCUTS.COPY;
+ this.unsubscribeCopyShortcut = KeyboardShortcut.subscribe(
+ copyShortcutConfig.shortcutKey,
+ this.copySelectionToClipboard,
+ copyShortcutConfig.descriptionKey,
+ copyShortcutConfig.modifiers,
+ false,
+ );
+ }
+
+ componentWillUnmount() {
+ if (!this.unsubscribeCopyShortcut) {
+ return;
+ }
+
+ this.unsubscribeCopyShortcut();
+ }
+
+ copySelectionToClipboard() {
+ const selectionMarkdown = SelectionScraper.getAsMarkdown();
+ Clipboard.setString(selectionMarkdown);
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default CopySelectionHelper;
diff --git a/src/components/DatePicker/datepickerPropTypes.js b/src/components/DatePicker/datepickerPropTypes.js
index 8f3bd03c75e0..4a681ec90c49 100644
--- a/src/components/DatePicker/datepickerPropTypes.js
+++ b/src/components/DatePicker/datepickerPropTypes.js
@@ -9,10 +9,16 @@ const propTypes = {
/**
* The datepicker supports any value that `moment` can parse.
- * `onChange` would always be called with a Date (or null)
+ * `onInputChange` would always be called with a Date (or null)
*/
value: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]),
+ /**
+ * The datepicker supports any defaultValue that `moment` can parse.
+ * `onInputChange` would always be called with a Date (or null)
+ */
+ defaultValue: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]),
+
/* Restricts for selectable max date range for the picker */
maximumDate: PropTypes.instanceOf(Date),
};
diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js
index bc6a8ea54222..77d7bbc705a2 100644
--- a/src/components/DatePicker/index.android.js
+++ b/src/components/DatePicker/index.android.js
@@ -1,6 +1,7 @@
import React from 'react';
import RNDatePicker from '@react-native-community/datetimepicker';
import moment from 'moment';
+import _ from 'underscore';
import TextInput from '../TextInput';
import CONST from '../../CONST';
import {propTypes, defaultProps} from './datepickerPropTypes';
@@ -14,31 +15,31 @@ class DatePicker extends React.Component {
};
this.showPicker = this.showPicker.bind(this);
- this.raiseDateChange = this.raiseDateChange.bind(this);
- }
-
- /**
- * @param {Event} event
- */
- showPicker(event) {
- this.setState({isPickerVisible: true});
- event.preventDefault();
+ this.setDate = this.setDate.bind(this);
}
/**
* @param {Event} event
* @param {Date} selectedDate
*/
- raiseDateChange(event, selectedDate) {
+ setDate(event, selectedDate) {
if (event.type === 'set') {
- this.props.onChange(selectedDate);
+ this.props.onInputChange(selectedDate);
}
this.setState({isPickerVisible: false});
}
+ /**
+ * @param {Event} event
+ */
+ showPicker(event) {
+ this.setState({isPickerVisible: true});
+ event.preventDefault();
+ }
+
render() {
- const dateAsText = this.props.value ? moment(this.props.value).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
+ const dateAsText = this.props.defaultValue ? moment(this.props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
return (
<>
@@ -46,18 +47,24 @@ class DatePicker extends React.Component {
label={this.props.label}
value={dateAsText}
placeholder={this.props.placeholder}
- hasError={this.props.hasError}
errorText={this.props.errorText}
containerStyles={this.props.containerStyles}
onPress={this.showPicker}
editable={false}
disabled={this.props.disabled}
+ onBlur={this.props.onBlur}
+ ref={(el) => {
+ if (!_.isFunction(this.props.innerRef)) {
+ return;
+ }
+ this.props.innerRef(el);
+ }}
/>
{this.state.isPickerVisible && (
)}
@@ -69,4 +76,7 @@ class DatePicker extends React.Component {
DatePicker.propTypes = propTypes;
DatePicker.defaultProps = defaultProps;
-export default DatePicker;
+export default React.forwardRef((props, ref) => (
+ /* eslint-disable-next-line react/jsx-props-no-spreading */
+
+));
diff --git a/src/components/DatePicker/index.ios.js b/src/components/DatePicker/index.ios.js
index 6a1bd98de987..879a102ac1ad 100644
--- a/src/components/DatePicker/index.ios.js
+++ b/src/components/DatePicker/index.ios.js
@@ -3,6 +3,7 @@ import React from 'react';
import {Button, View} from 'react-native';
import RNDatePicker from '@react-native-community/datetimepicker';
import moment from 'moment';
+import _ from 'underscore';
import TextInput from '../TextInput';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import Popover from '../Popover';
@@ -16,13 +17,13 @@ const datepickerPropTypes = {
...withLocalizePropTypes,
};
-class Datepicker extends React.Component {
+class DatePicker extends React.Component {
constructor(props) {
super(props);
this.state = {
isPickerVisible: false,
- selectedDate: props.value ? moment(props.value).toDate() : new Date(),
+ selectedDate: props.defaultValue ? moment(props.defaultValue).toDate() : new Date(),
};
this.showPicker = this.showPicker.bind(this);
@@ -49,11 +50,11 @@ class Datepicker extends React.Component {
/**
* Accept the current spinner changes, close the spinner and propagate the change
- * to the parent component (props.onChange)
+ * to the parent component (props.onInputChange)
*/
selectDate() {
this.setState({isPickerVisible: false});
- this.props.onChange(this.state.selectedDate);
+ this.props.onInputChange(this.state.selectedDate);
}
/**
@@ -65,19 +66,25 @@ class Datepicker extends React.Component {
}
render() {
- const dateAsText = this.props.value ? moment(this.props.value).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
+ const dateAsText = this.props.defaultValue ? moment(this.props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
return (
<>
{
+ if (!_.isFunction(this.props.innerRef)) {
+ return;
+ }
+ this.props.innerRef(el);
+ }}
/>
(
+ /* eslint-disable-next-line react/jsx-props-no-spreading */
+
+)));
diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js
index a324c25ae878..0077e4cf9add 100644
--- a/src/components/DatePicker/index.js
+++ b/src/components/DatePicker/index.js
@@ -1,5 +1,6 @@
import React from 'react';
import moment from 'moment';
+import _ from 'underscore';
import TextInput from '../TextInput';
import CONST from '../../CONST';
import {propTypes, defaultProps} from './datepickerPropTypes';
@@ -12,18 +13,18 @@ const datePickerPropTypes = {
...windowDimensionsPropTypes,
};
-class Datepicker extends React.Component {
+class DatePicker extends React.Component {
constructor(props) {
super(props);
- this.raiseDateChange = this.raiseDateChange.bind(this);
+ this.setDate = this.setDate.bind(this);
this.showDatepicker = this.showDatepicker.bind(this);
/* We're using uncontrolled input otherwise it wont be possible to
* raise change events with a date value - each change will produce a date
* and make us reset the text input */
- this.defaultValue = props.value
- ? moment(props.value).format(CONST.DATE.MOMENT_FORMAT_STRING)
+ this.defaultValue = props.defaultValue
+ ? moment(props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING)
: '';
}
@@ -40,15 +41,15 @@ class Datepicker extends React.Component {
* Trigger the `onChange` handler when the user input has a complete date or is cleared
* @param {String} text
*/
- raiseDateChange(text) {
+ setDate(text) {
if (!text) {
- this.props.onChange(null);
+ this.props.onInputChange(null);
return;
}
const asMoment = moment(text);
if (asMoment.isValid()) {
- this.props.onChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING));
+ this.props.onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING));
}
}
@@ -69,22 +70,31 @@ class Datepicker extends React.Component {
return (
this.inputRef = input}
+ ref={(el) => {
+ this.inputRef = el;
+
+ if (_.isFunction(this.props.innerRef)) {
+ this.props.innerRef(el);
+ }
+ }}
onFocus={this.showDatepicker}
label={this.props.label}
- onChangeText={this.raiseDateChange}
+ onInputChange={this.setDate}
defaultValue={this.defaultValue}
placeholder={this.props.placeholder}
- hasError={this.props.hasError}
errorText={this.props.errorText}
containerStyles={this.props.containerStyles}
disabled={this.props.disabled}
+ onBlur={this.props.onBlur}
/>
);
}
}
-Datepicker.propTypes = datePickerPropTypes;
-Datepicker.defaultProps = defaultProps;
+DatePicker.propTypes = datePickerPropTypes;
+DatePicker.defaultProps = defaultProps;
-export default withWindowDimensions(Datepicker);
+export default withWindowDimensions(React.forwardRef((props, ref) => (
+ /* eslint-disable-next-line react/jsx-props-no-spreading */
+
+)));
diff --git a/src/components/EmojiPicker/index.js b/src/components/EmojiPicker/EmojiPicker.js
similarity index 100%
rename from src/components/EmojiPicker/index.js
rename to src/components/EmojiPicker/EmojiPicker.js
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index 21ec2d724f15..2d557eb75d5c 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -352,7 +352,7 @@ class EmojiPickerMenu extends Component {
this.setState({
filteredEmojis: this.emojis,
headerIndices: this.unfilteredHeaderIndices,
- highlightedIndex: this.numColumns,
+ highlightedIndex: -1,
});
return;
}
diff --git a/src/components/Form.js b/src/components/Form.js
index a648abceb0a3..41fe3bc1b480 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -135,9 +135,9 @@ class Form extends React.Component {
});
}
- // We check if the child has the isFormInput prop.
+ // We check if the child has the inputID prop.
// We don't want to pass form props to non form components, e.g. View, Text, etc
- if (!child.props.isFormInput) {
+ if (!child.props.inputID) {
return child;
}
diff --git a/src/components/SignInPageForm/BaseForm.js b/src/components/FormElement.js
similarity index 63%
rename from src/components/SignInPageForm/BaseForm.js
rename to src/components/FormElement.js
index 21a4e19b1f9b..f46b24708c4c 100644
--- a/src/components/SignInPageForm/BaseForm.js
+++ b/src/components/FormElement.js
@@ -1,8 +1,8 @@
import React, {forwardRef} from 'react';
import {View} from 'react-native';
-import * as ComponentUtils from '../../libs/ComponentUtils';
+import * as ComponentUtils from '../libs/ComponentUtils';
-const BaseForm = forwardRef((props, ref) => (
+const FormElement = forwardRef((props, ref) => (
(
/>
));
-BaseForm.displayName = 'BaseForm';
-export default BaseForm;
+FormElement.displayName = 'BaseForm';
+export default FormElement;
diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
index 37a9f693cf2f..a7e4c910f938 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
@@ -36,7 +36,7 @@ const customHTMLElementModels = {
}),
};
-const defaultViewProps = {style: {alignItems: 'flex-start'}};
+const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText]};
// We are using the explicit composite architecture for performance gains.
// Configuration for RenderHTML is handled in a top-level component providing
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
index 650e387a436b..de7aafa20c03 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
@@ -49,6 +49,7 @@ const ImageRenderer = (props) => {
return (
{
+ const TDefaultRenderer = props.TDefaultRenderer;
+ const defaultRendererProps = _.omit(props, ['TDefaultRenderer']);
+
+ return (
+
+ true}>
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */}
+
+
+
+ );
+});
+
+BasePreRenderer.displayName = 'BasePreRenderer';
+BasePreRenderer.propTypes = htmlRendererPropTypes;
+
+export default withLocalize(BasePreRenderer);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js
new file mode 100644
index 000000000000..ee3981fc1541
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js
@@ -0,0 +1,55 @@
+import React from 'react';
+import withLocalize from '../../../withLocalize';
+import htmlRendererPropTypes from '../htmlRendererPropTypes';
+import BasePreRenderer from './BasePreRenderer';
+
+class PreRenderer extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.scrollNode = this.scrollNode.bind(this);
+ }
+
+ componentDidMount() {
+ if (!this.ref) {
+ return;
+ }
+ this.ref.getScrollableNode()
+ .addEventListener('wheel', this.scrollNode);
+ }
+
+ componentWillUnmount() {
+ this.ref.getScrollableNode()
+ .removeEventListener('wheel', this.scrollNode);
+ }
+
+ /**
+ * Manually scrolls the code block if code block horizontal scrollable, then prevents the event from being passed up to the parent.
+ * @param {Object} event native event
+ */
+ scrollNode(event) {
+ const node = this.ref.getScrollableNode();
+ const horizontalOverflow = node.scrollWidth > node.offsetWidth;
+
+ if ((event.currentTarget === node) && horizontalOverflow) {
+ node.scrollLeft += event.deltaX;
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+
+ render() {
+ return (
+ this.ref = el}
+ />
+ );
+ }
+}
+
+PreRenderer.propTypes = htmlRendererPropTypes;
+PreRenderer.displayName = 'PreRenderer';
+
+export default withLocalize(PreRenderer);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.native.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.native.js
new file mode 100644
index 000000000000..0ae9457f1df0
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.native.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import withLocalize from '../../../withLocalize';
+import htmlRendererPropTypes from '../htmlRendererPropTypes';
+import BasePreRenderer from './BasePreRenderer';
+
+const PreRenderer = props => (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+);
+
+PreRenderer.propTypes = htmlRendererPropTypes;
+PreRenderer.displayName = 'PreRenderer';
+
+export default withLocalize(PreRenderer);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/index.js
index 8186dd57841b..4b9d0fc85962 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/index.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.js
@@ -2,6 +2,7 @@ import AnchorRenderer from './AnchorRenderer';
import CodeRenderer from './CodeRenderer';
import EditedRenderer from './EditedRenderer';
import ImageRenderer from './ImageRenderer';
+import PreRenderer from './PreRenderer';
/**
* This collection defines our custom renderers. It is a mapping from HTML tag type to the corresponding component.
@@ -14,4 +15,5 @@ export default {
// Custom tag renderers
edited: EditedRenderer,
+ pre: PreRenderer,
};
diff --git a/src/components/HeaderWithCloseButton.js b/src/components/HeaderWithCloseButton.js
index 4680d1974083..ef8588709c34 100755
--- a/src/components/HeaderWithCloseButton.js
+++ b/src/components/HeaderWithCloseButton.js
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
- View, TouchableOpacity,
+ View, TouchableOpacity, Keyboard,
} from 'react-native';
import styles from '../styles/styles';
import Header from './Header';
@@ -12,6 +12,7 @@ import * as Expensicons from './Icon/Expensicons';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import Tooltip from './Tooltip';
import ThreeDotsMenu, {ThreeDotsMenuItemPropTypes} from './ThreeDotsMenu';
+import VirtualKeyboard from '../libs/VirtualKeyboard';
const propTypes = {
/** Title of the Header */
@@ -113,7 +114,12 @@ const HeaderWithCloseButton = props => (
{props.shouldShowBackButton && (
{
+ if (VirtualKeyboard.isOpen()) {
+ Keyboard.dismiss();
+ }
+ props.onBackButtonPress();
+ }}
style={[styles.touchableButtonImage]}
>
diff --git a/src/components/IOUConfirmationList.js b/src/components/IOUConfirmationList.js
index a02a75595805..84de26b310f1 100755
--- a/src/components/IOUConfirmationList.js
+++ b/src/components/IOUConfirmationList.js
@@ -20,6 +20,8 @@ import ButtonWithMenu from './ButtonWithMenu';
import Log from '../libs/Log';
import SettlementButton from './SettlementButton';
import ROUTES from '../ROUTES';
+import networkPropTypes from './networkPropTypes';
+import {withNetwork} from './OnyxProvider';
const propTypes = {
/** Callback to inform parent modal of success */
@@ -93,10 +95,7 @@ const propTypes = {
}),
/** Information about the network */
- network: PropTypes.shape({
- /** Is the network currently offline or not */
- isOffline: PropTypes.bool,
- }),
+ network: networkPropTypes.isRequired,
/** Current user session */
session: PropTypes.shape({
@@ -110,7 +109,6 @@ const defaultProps = {
},
onUpdateComment: null,
comment: '',
- network: {},
myPersonalDetails: {},
iouType: CONST.IOU.IOU_TYPE.REQUEST,
};
@@ -416,6 +414,7 @@ IOUConfirmationList.defaultProps = defaultProps;
export default compose(
withLocalize,
withWindowDimensions,
+ withNetwork(),
withOnyx({
iou: {key: ONYXKEYS.IOU},
myPersonalDetails: {
@@ -424,9 +423,6 @@ export default compose(
session: {
key: ONYXKEYS.SESSION,
},
- network: {
- key: ONYXKEYS.NETWORK,
- },
betas: {
key: ONYXKEYS.BETAS,
},
diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js
index 4dc6b42dff85..90c47808b188 100644
--- a/src/components/Icon/Expensicons.js
+++ b/src/components/Icon/Expensicons.js
@@ -74,6 +74,8 @@ import ActiveRoomAvatar from '../../../assets/images/avatars/room.svg';
import DeletedRoomAvatar from '../../../assets/images/avatars/deleted-room.svg';
import AdminRoomAvatar from '../../../assets/images/avatars/admin-room.svg';
import AnnounceRoomAvatar from '../../../assets/images/avatars/announce-room.svg';
+import Connect from '../../../assets/images/connect.svg';
+import DomainRoomAvatar from '../../../assets/images/avatars/domain-room.svg';
export {
ActiveRoomAvatar,
@@ -98,8 +100,10 @@ export {
Close,
ClosedSign,
Concierge,
+ Connect,
CreditCard,
DeletedRoomAvatar,
+ DomainRoomAvatar,
DownArrow,
Download,
Emoji,
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index 6c5c20eb008e..9e40683893ba 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -59,7 +59,7 @@ const MenuItem = props => (
>
{({hovered, pressed}) => (
<>
-
+
{(props.icon && props.iconType === CONST.ICON_TYPE_ICON) && (
(
/>
)}
-
+
(
>
{props.title}
- {props.description && (
-
+ {Boolean(props.description) && (
+
{props.description}
)}
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js
index 9d8a4119eb4c..0eff2e8c23cd 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.js
@@ -7,7 +7,6 @@ import {
View,
StyleSheet,
} from 'react-native';
-import Str from 'expensify-common/lib/str';
import styles from '../styles/styles';
import * as StyleUtils from '../styles/StyleUtils';
import optionPropTypes from './optionPropTypes';
@@ -23,6 +22,7 @@ import Text from './Text';
import SelectCircle from './SelectCircle';
import SubscriptAvatar from './SubscriptAvatar';
import CONST from '../CONST';
+import * as ReportUtils from '../libs/ReportUtils';
const propTypes = {
/** Background Color of the Option Row */
@@ -113,21 +113,9 @@ const OptionRow = (props) => {
: props.backgroundColor;
const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
const isMultipleParticipant = lodashGet(props.option, 'participantsList.length', 0) > 1;
- const displayNamesWithTooltips = _.map(
-
- // We only create tooltips for the first 10 users or so since some reports have hundreds of users causing
- // performance to degrade.
- (props.option.participantsList || []).slice(0, 10),
- ({displayName, firstName, login}) => {
- const displayNameTrimmed = Str.isSMSLogin(login) ? props.toLocalPhone(displayName) : displayName;
-
- return {
- displayName: (isMultipleParticipant ? firstName : displayNameTrimmed) || Str.removeSMSDomain(login),
- tooltip: Str.removeSMSDomain(login),
- };
- },
- );
+ // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
+ const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((props.option.participantsList || []).slice(0, 10), isMultipleParticipant);
const avatarTooltips = props.showTitleTooltip && !props.option.isChatRoom && !props.option.isArchivedRoom ? _.pluck(displayNamesWithTooltips, 'tooltip') : undefined;
return (
diff --git a/src/components/OptionsSelector.js b/src/components/OptionsSelector.js
index 1f6523c0b2b6..48c34b1fcb15 100755
--- a/src/components/OptionsSelector.js
+++ b/src/components/OptionsSelector.js
@@ -8,6 +8,7 @@ import styles from '../styles/styles';
import optionPropTypes from './optionPropTypes';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import TextInput from './TextInput';
+import FullScreenLoadingIndicator from './FullscreenLoadingIndicator';
const propTypes = {
/** Wether we should wait before focusing the TextInput, useful when using transitions */
@@ -70,6 +71,9 @@ const propTypes = {
/** Whether to autofocus the search input on mount */
autoFocus: PropTypes.bool,
+ /** Whether to show options list */
+ shouldShowOptions: PropTypes.bool,
+
...withLocalizePropTypes,
};
@@ -87,6 +91,7 @@ const defaultProps = {
showTitleTooltip: false,
shouldFocusOnSelectRow: false,
autoFocus: true,
+ shouldShowOptions: true,
};
class OptionsSelector extends Component {
@@ -131,6 +136,10 @@ class OptionsSelector extends Component {
* @param {SyntheticEvent} e
*/
handleKeyPress(e) {
+ if (!this.list) {
+ return;
+ }
+
// We are mapping over all the options to combine them into a single array and also saving the section index
// index within that section so we can navigate
const allOptions = _.reduce(this.props.sections, (options, section, sectionIndex) => (
@@ -238,21 +247,25 @@ class OptionsSelector extends Component {
selectTextOnFocus
/>
- this.list = el}
- optionHoveredStyle={styles.hoveredComponentBG}
- onSelectRow={this.selectRow}
- sections={this.props.sections}
- focusedIndex={this.state.focusedIndex}
- selectedOptions={this.props.selectedOptions}
- canSelectMultipleOptions={this.props.canSelectMultipleOptions}
- hideSectionHeaders={this.props.hideSectionHeaders}
- headerMessage={this.props.headerMessage}
- disableFocusOptions={this.props.disableArrowKeysActions}
- hideAdditionalOptionStates={this.props.hideAdditionalOptionStates}
- forceTextUnreadStyle={this.props.forceTextUnreadStyle}
- showTitleTooltip={this.props.showTitleTooltip}
- />
+ {this.props.shouldShowOptions
+ ? (
+ this.list = el}
+ optionHoveredStyle={styles.hoveredComponentBG}
+ onSelectRow={this.selectRow}
+ sections={this.props.sections}
+ focusedIndex={this.state.focusedIndex}
+ selectedOptions={this.props.selectedOptions}
+ canSelectMultipleOptions={this.props.canSelectMultipleOptions}
+ hideSectionHeaders={this.props.hideSectionHeaders}
+ headerMessage={this.props.headerMessage}
+ disableFocusOptions={this.props.disableArrowKeysActions}
+ hideAdditionalOptionStates={this.props.hideAdditionalOptionStates}
+ forceTextUnreadStyle={this.props.forceTextUnreadStyle}
+ showTitleTooltip={this.props.showTitleTooltip}
+ />
+ )
+ : }
);
}
diff --git a/src/components/Picker/BasePicker/basePickerPropTypes.js b/src/components/Picker/BasePicker/basePickerPropTypes.js
index 7af66b59bdf6..a3468ec71fea 100644
--- a/src/components/Picker/BasePicker/basePickerPropTypes.js
+++ b/src/components/Picker/BasePicker/basePickerPropTypes.js
@@ -11,8 +11,8 @@ const propTypes = {
/** Whether or not to show the disabled styles */
disabled: PropTypes.bool,
- /** Should the picker be styled for errors */
- hasError: PropTypes.bool,
+ /** Error text to display */
+ errorText: PropTypes.string,
/** Should the picker be styled for focus state */
focused: PropTypes.bool,
@@ -43,10 +43,19 @@ const propTypes = {
/** Size of a picker component */
size: PropTypes.oneOf(['normal', 'small']),
+
+ /** Callback called when Picker options menu is closed */
+ onClose: PropTypes.func.isRequired,
+
+ /** Callback called when Picker options menu is open */
+ onOpen: PropTypes.func.isRequired,
+
+ /** Callback called when click or tap out of Picker */
+ onBlur: PropTypes.func,
};
const defaultProps = {
disabled: false,
- hasError: false,
+ errorText: '',
focused: false,
placeholder: {},
value: null,
@@ -68,6 +77,7 @@ const defaultProps = {
>
),
size: 'normal',
+ onBlur: () => {},
};
export {
diff --git a/src/components/Picker/BasePicker/index.js b/src/components/Picker/BasePicker/index.js
index a56c7d9f0d29..bdd5def02ec3 100644
--- a/src/components/Picker/BasePicker/index.js
+++ b/src/components/Picker/BasePicker/index.js
@@ -11,10 +11,11 @@ class BasePicker extends React.Component {
super(props);
this.state = {
- selectedValue: this.props.value || this.props.defaultValue,
+ selectedValue: this.props.defaultValue,
};
this.updateSelectedValueAndExecuteOnChange = this.updateSelectedValueAndExecuteOnChange.bind(this);
+ this.executeOnCloseAndOnBlur = this.executeOnCloseAndOnBlur.bind(this);
}
updateSelectedValueAndExecuteOnChange(value) {
@@ -22,6 +23,12 @@ class BasePicker extends React.Component {
this.setState({selectedValue: value});
}
+ executeOnCloseAndOnBlur() {
+ // Picker's onClose is not executed on Web and Desktop, so props.onClose has to be called with onBlur callback.
+ this.props.onClose();
+ this.props.onBlur();
+ }
+
render() {
const hasError = !_.isEmpty(this.props.errorText);
return (
@@ -31,7 +38,7 @@ class BasePicker extends React.Component {
style={this.props.size === 'normal' ? basePickerStyles(this.props.disabled, hasError, this.props.focused) : styles.pickerSmall}
useNativeAndroidPickerStyle={false}
placeholder={this.props.placeholder}
- value={this.state.selectedValue}
+ value={this.props.value || this.state.selectedValue}
Icon={() => this.props.icon(this.props.size)}
disabled={this.props.disabled}
fixAndroidTouchableBug
@@ -39,7 +46,7 @@ class BasePicker extends React.Component {
onClose={this.props.onClose}
pickerProps={{
onFocus: this.props.onOpen,
- onBlur: this.props.onBlur,
+ onBlur: this.executeOnCloseAndOnBlur,
ref: this.props.innerRef,
}}
/>
diff --git a/src/components/Picker/index.js b/src/components/Picker/index.js
index 5f2f86374854..f8b9f68eef7a 100644
--- a/src/components/Picker/index.js
+++ b/src/components/Picker/index.js
@@ -6,7 +6,6 @@ import BasePicker from './BasePicker';
import Text from '../Text';
import styles from '../../styles/styles';
import InlineErrorText from '../InlineErrorText';
-import * as FormUtils from '../../libs/FormUtils';
const propTypes = {
/** Picker label */
@@ -24,16 +23,8 @@ const propTypes = {
/** Customize the Picker container */
containerStyles: PropTypes.arrayOf(PropTypes.object),
- /** Indicates that the input is being used with the Form component */
- isFormInput: PropTypes.bool,
-
- /**
- * The ID used to uniquely identify the input
- *
- * @param {Object} props - props passed to the input
- * @returns {Object} - returns an Error object if isFormInput is supplied but inputID is falsey or not a string
- */
- inputID: props => FormUtils.validateInputIDProps(props),
+ /** 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,
@@ -44,7 +35,6 @@ const defaultProps = {
isDisabled: false,
errorText: '',
containerStyles: [],
- isFormInput: false,
inputID: undefined,
shouldSaveDraft: false,
value: undefined,
diff --git a/src/components/Popover/index.js b/src/components/Popover/index.js
index 6b6f97b29bcb..9a2e20ed3da7 100644
--- a/src/components/Popover/index.js
+++ b/src/components/Popover/index.js
@@ -17,8 +17,6 @@ const Popover = (props) => {
popoverAnchorPosition={props.anchorPosition}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
- animationIn={props.isSmallScreenWidth ? undefined : props.animationIn}
- animationOut={props.isSmallScreenWidth ? undefined : props.animationOut}
animationInTiming={props.disableAnimation ? 1 : props.animationInTiming}
animationOutTiming={props.disableAnimation ? 1 : props.animationOutTiming}
shouldCloseOnOutsideClick
@@ -33,8 +31,6 @@ const Popover = (props) => {
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
fullscreen={props.isSmallScreenWidth ? true : props.fullscreen}
- animationIn={props.isSmallScreenWidth ? undefined : props.animationIn}
- animationOut={props.isSmallScreenWidth ? undefined : props.animationOut}
animationInTiming={props.disableAnimation && !props.isSmallScreenWidth ? 1 : props.animationInTiming}
animationOutTiming={props.disableAnimation && !props.isSmallScreenWidth ? 1 : props.animationOutTiming}
/>
diff --git a/src/components/Popover/popoverPropTypes.js b/src/components/Popover/popoverPropTypes.js
index 07a9c5b47193..6c959b025ac4 100644
--- a/src/components/Popover/popoverPropTypes.js
+++ b/src/components/Popover/popoverPropTypes.js
@@ -20,6 +20,9 @@ const propTypes = {
const defaultProps = {
...(_.omit(defaultModalProps, ['type', 'popoverAnchorPosition'])),
+ animationIn: 'fadeIn',
+ animationOut: 'fadeOut',
+
// Anchor position is optional only because it is not relevant on mobile
anchorPosition: {},
disableAnimation: true,
diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js
index b29216f34c45..6dd1495f9380 100644
--- a/src/components/PressableWithSecondaryInteraction/index.js
+++ b/src/components/PressableWithSecondaryInteraction/index.js
@@ -5,6 +5,7 @@ import {LongPressGestureHandler, State} from 'react-native-gesture-handler';
import SelectionScraper from '../../libs/SelectionScraper';
import * as pressableWithSecondaryInteractionPropTypes from './pressableWithSecondaryInteractionPropTypes';
import styles from '../../styles/styles';
+import hasHoverSupport from '../../libs/hasHoverSupport';
/**
* This is a special Pressable that calls onSecondaryInteraction when LongPressed, or right-clicked.
@@ -31,7 +32,7 @@ class PressableWithSecondaryInteraction extends Component {
* @param {Object} e
*/
callSecondaryInteractionWithMappedEvent(e) {
- if (e.nativeEvent.state !== State.ACTIVE) {
+ if ((e.nativeEvent.state !== State.ACTIVE) || hasHoverSupport()) {
return;
}
@@ -73,7 +74,7 @@ class PressableWithSecondaryInteraction extends Component {
onPressOut={this.props.onPressOut}
onPress={this.props.onPress}
ref={el => this.pressableRef = el}
- // eslint-disable-next-line react/jsx-props-no-spreading
+ // eslint-disable-next-line react/jsx-props-no-spreading
{...defaultPressableProps}
>
{this.props.children}
diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js
index 0d86d1071018..25fc18318c27 100644
--- a/src/components/ReportWelcomeText.js
+++ b/src/components/ReportWelcomeText.js
@@ -1,17 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import lodashGet from 'lodash/get';
-import Str from 'expensify-common/lib/str';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import styles from '../styles/styles';
import Text from './Text';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import compose from '../libs/compose';
-import * as ReportUtils from '../libs/reportUtils';
+import * as ReportUtils from '../libs/ReportUtils';
import * as OptionsListUtils from '../libs/OptionsListUtils';
import ONYXKEYS from '../ONYXKEYS';
-import CONST from '../CONST';
const personalDetailsPropTypes = PropTypes.shape({
/** The login of the person (either email or phone number) */
@@ -53,25 +51,9 @@ const ReportWelcomeText = (props) => {
const isDefault = !(isChatRoom || isPolicyExpenseChat);
const participants = lodashGet(props.report, 'participants', []);
const isMultipleParticipant = participants.length > 1;
- const displayNamesWithTooltips = _.map(
+ const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(
OptionsListUtils.getPersonalDetailsForLogins(participants, props.personalDetails),
- ({
- displayName, firstName, login, pronouns,
- }) => {
- const longName = displayName || Str.removeSMSDomain(login);
- const longNameLocalized = Str.isSMSLogin(longName) ? props.toLocalPhone(longName) : longName;
- const shortName = firstName || longNameLocalized;
- let finalPronouns = pronouns;
- if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) {
- const localeKey = pronouns.replace(CONST.PRONOUNS.PREFIX, '');
- finalPronouns = props.translate(`pronouns.${localeKey}`);
- }
- return {
- displayName: isMultipleParticipant ? shortName : longNameLocalized,
- tooltip: Str.removeSMSDomain(login),
- pronouns: finalPronouns,
- };
- },
+ isMultipleParticipant,
);
const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(props.report, props.policies);
return (
diff --git a/src/components/SignInPageForm/index.js b/src/components/SignInPageForm/index.js
index 163f1ee223b1..4ee95d1cb81e 100644
--- a/src/components/SignInPageForm/index.js
+++ b/src/components/SignInPageForm/index.js
@@ -1,5 +1,5 @@
import React from 'react';
-import BaseForm from './BaseForm';
+import FormElement from '../FormElement';
class Form extends React.Component {
componentDidMount() {
@@ -16,7 +16,7 @@ class Form extends React.Component {
render() {
return (
- this.form = el}
// eslint-disable-next-line react/jsx-props-no-spreading
{...this.props}
diff --git a/src/components/SignInPageForm/index.native.js b/src/components/SignInPageForm/index.native.js
index 21f10e7a428d..d09e60c1b98d 100644
--- a/src/components/SignInPageForm/index.native.js
+++ b/src/components/SignInPageForm/index.native.js
@@ -1,8 +1,8 @@
import React from 'react';
-import BaseForm from './BaseForm';
+import FormElement from '../FormElement';
// eslint-disable-next-line react/jsx-props-no-spreading
-const Form = props => ;
+const Form = props => ;
Form.displayName = 'Form';
export default Form;
diff --git a/src/components/StatePicker.js b/src/components/StatePicker.js
index 11d4cad09f55..0ae82af6baee 100644
--- a/src/components/StatePicker.js
+++ b/src/components/StatePicker.js
@@ -1,5 +1,5 @@
import _ from 'underscore';
-import React from 'react';
+import React, {forwardRef} from 'react';
import PropTypes from 'prop-types';
import {CONST} from 'expensify-common/lib/CONST';
import Picker from './Picker';
@@ -14,31 +14,54 @@ const propTypes = {
/** The label for the field */
label: PropTypes.string,
- /** A callback method that is called when the value changes and it received the selected value as an argument */
- onChange: PropTypes.func.isRequired,
+ /** 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,
+
+ /** The default value of the state picker */
+ defaultValue: PropTypes.string,
+
...withLocalizePropTypes,
};
const defaultProps = {
label: '',
- value: '',
+ value: undefined,
+ defaultValue: undefined,
+ errorText: '',
+ shouldSaveDraft: false,
+ inputID: undefined,
+ onBlur: () => {},
};
-const StatePicker = props => (
+const StatePicker = forwardRef((props, ref) => (
-);
+));
StatePicker.propTypes = propTypes;
StatePicker.defaultProps = defaultProps;
diff --git a/src/components/TestToolMenu.js b/src/components/TestToolMenu.js
new file mode 100644
index 000000000000..be7b7bc0f170
--- /dev/null
+++ b/src/components/TestToolMenu.js
@@ -0,0 +1,87 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import styles from '../styles/styles';
+import Switch from './Switch';
+import Text from './Text';
+import * as User from '../libs/actions/User';
+import * as Network from '../libs/actions/Network';
+import * as Session from '../libs/actions/Session';
+import ONYXKEYS from '../ONYXKEYS';
+import Button from './Button';
+import * as NetworkStore from '../libs/Network/NetworkStore';
+import TestToolRow from './TestToolRow';
+import networkPropTypes from './networkPropTypes';
+import compose from '../libs/compose';
+import {withNetwork} from './OnyxProvider';
+
+const propTypes = {
+ /** User object in Onyx */
+ user: PropTypes.shape({
+ /** Whether we should use the staging version of the secure API server */
+ shouldUseSecureStaging: PropTypes.bool,
+ }),
+
+ /** Network object in Onyx */
+ network: networkPropTypes.isRequired,
+};
+
+const defaultProps = {
+ user: {
+ shouldUseSecureStaging: false,
+ },
+};
+
+const TestToolMenu = props => (
+ <>
+
+ Test Preferences
+
+
+ {/* Option to switch from using the staging secure endpoint or the production secure endpoint.
+ This enables QA and internal testers to take advantage of sandbox environments for 3rd party services like Plaid and Onfido. */}
+
+ User.setShouldUseSecureStaging(!props.user.shouldUseSecureStaging)}
+ />
+
+
+ {/* When toggled all network requests will fail. */}
+
+ Network.setShouldFailAllRequests(!props.network.shouldFailAllRequests)}
+ />
+
+
+ {/* Instantly invalidates a user's local authToken. Useful for testing flows related to reauthentication. */}
+
+
+
+ {/* Invalidate stored user auto-generated credentials. Useful for manually testing sign out logic. */}
+
+
+ >
+);
+
+TestToolMenu.propTypes = propTypes;
+TestToolMenu.defaultProps = defaultProps;
+export default compose(
+ withNetwork(),
+ withOnyx({
+ user: {
+ key: ONYXKEYS.USER,
+ },
+ }),
+)(TestToolMenu);
diff --git a/src/components/TestToolRow.js b/src/components/TestToolRow.js
new file mode 100644
index 000000000000..80405a898bb0
--- /dev/null
+++ b/src/components/TestToolRow.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import styles from '../styles/styles';
+import Text from './Text';
+
+const propTypes = {
+ /** Title of control */
+ title: PropTypes.string.isRequired,
+
+ /** Control component jsx */
+ children: PropTypes.node.isRequired,
+};
+
+const TestToolRow = props => (
+
+
+
+ {props.title}
+
+
+
+ {props.children}
+
+
+);
+
+TestToolRow.propTypes = propTypes;
+export default TestToolRow;
diff --git a/src/components/TextInput/baseTextInputPropTypes.js b/src/components/TextInput/baseTextInputPropTypes.js
index 89ea048cd35d..a27ba0c5888e 100644
--- a/src/components/TextInput/baseTextInputPropTypes.js
+++ b/src/components/TextInput/baseTextInputPropTypes.js
@@ -1,5 +1,4 @@
import PropTypes from 'prop-types';
-import * as FormUtils from '../../libs/FormUtils';
const propTypes = {
/** Input label */
@@ -57,16 +56,8 @@ const propTypes = {
prefixCharacter: PropTypes.string,
/** Form props */
- /** Indicates that the input is being used with the Form component */
- isFormInput: PropTypes.bool,
-
- /**
- * The ID used to uniquely identify the input
- *
- * @param {Object} props - props passed to the input
- * @returns {Object} - returns an Error object if isFormInput is supplied but inputID is falsey or not a string
- */
- inputID: props => FormUtils.validateInputIDProps(props),
+ /** 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,
@@ -76,7 +67,6 @@ const propTypes = {
};
const defaultProps = {
- isFormInput: false,
label: '',
name: '',
errorText: '',
diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js
index 27ebb22b543f..fe2152b0d99a 100644
--- a/src/components/ThreeDotsMenu/index.js
+++ b/src/components/ThreeDotsMenu/index.js
@@ -94,8 +94,6 @@ class ThreeDotsMenu extends Component {
isVisible={this.state.isPopupMenuVisible}
anchorPosition={this.props.anchorPosition}
onItemSelected={() => this.togglePopupMenu()}
- animationIn="fadeInDown"
- animationOut="fadeOutUp"
menuItems={this.props.menuItems}
/>
>
diff --git a/src/components/Tooltip/TooltipRenderedOnPageBody.js b/src/components/Tooltip/TooltipRenderedOnPageBody.js
index 3ed7c472883b..644c9deae093 100644
--- a/src/components/Tooltip/TooltipRenderedOnPageBody.js
+++ b/src/components/Tooltip/TooltipRenderedOnPageBody.js
@@ -1,4 +1,4 @@
-import React, {memo} from 'react';
+import React from 'react';
import PropTypes from 'prop-types';
import {Animated, View} from 'react-native';
import ReactDOM from 'react-dom';
@@ -25,12 +25,6 @@ const propTypes = {
/** The Height of the tooltip wrapper */
wrapperHeight: PropTypes.number.isRequired,
- /** The width of the tooltip itself */
- tooltipWidth: PropTypes.number.isRequired,
-
- /** The Height of the tooltip itself */
- tooltipHeight: PropTypes.number.isRequired,
-
/** Any additional amount to manually adjust the horizontal position of the tooltip.
A positive value shifts the tooltip to the right, and a negative value shifts it to the left. */
shiftHorizontal: PropTypes.number.isRequired,
@@ -39,59 +33,92 @@ const propTypes = {
A positive value shifts the tooltip down, and a negative value shifts it up. */
shiftVertical: PropTypes.number.isRequired,
- /** Callback to set the Ref to the Tooltip */
- setTooltipRef: PropTypes.func.isRequired,
-
/** Text to be shown in the tooltip */
text: PropTypes.string.isRequired,
- /** Callback to be used to calulate the width and height of tooltip */
- measureTooltip: PropTypes.func.isRequired,
-};
+ /** Number of pixels to set max-width on tooltip */
+ maxWidth: PropTypes.number.isRequired,
-const defaultProps = {};
-
-const TooltipRenderedOnPageBody = (props) => {
- const {
- animationStyle,
- tooltipWrapperStyle,
- tooltipTextStyle,
- pointerWrapperStyle,
- pointerStyle,
- } = getTooltipStyles(
- props.animation,
- props.windowWidth,
- props.xOffset,
- props.yOffset,
- props.wrapperWidth,
- props.wrapperHeight,
- props.tooltipWidth,
- props.tooltipHeight,
- props.shiftHorizontal,
- props.shiftVertical,
- );
- return ReactDOM.createPortal(
-
- {props.text}
-
-
-
- ,
- document.querySelector('body'),
- );
+ /** Maximum number of lines to show in tooltip */
+ numberOfLines: PropTypes.number.isRequired,
};
-TooltipRenderedOnPageBody.propTypes = propTypes;
-TooltipRenderedOnPageBody.defaultProps = defaultProps;
-TooltipRenderedOnPageBody.displayName = 'TooltipRenderedOnPageBody';
-
// Props will change frequently.
// On every tooltip hover, we update the position in state which will result in re-rendering.
// We also update the state on layout changes which will be triggered often.
// There will be n number of tooltip components in the page.
-// Its good to memorize this one.
-export default memo(TooltipRenderedOnPageBody);
+// It's good to memorize this one.
+class TooltipRenderedOnPageBody extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ // The width of tooltip's inner text
+ tooltipTextWidth: 0,
+
+ // The width and height of the tooltip itself
+ tooltipWidth: 0,
+ tooltipHeight: 0,
+ };
+
+ this.measureTooltip = this.measureTooltip.bind(this);
+ }
+
+ componentDidMount() {
+ this.setState({
+ tooltipTextWidth: this.textRef.offsetWidth,
+ });
+ }
+
+ /**
+ * Measure the size of the tooltip itself.
+ *
+ * @param {Object} nativeEvent
+ */
+ measureTooltip({nativeEvent}) {
+ this.setState({
+ tooltipWidth: nativeEvent.layout.width,
+ tooltipHeight: nativeEvent.layout.height,
+ });
+ }
+
+ render() {
+ const {
+ animationStyle,
+ tooltipWrapperStyle,
+ tooltipTextStyle,
+ pointerWrapperStyle,
+ pointerStyle,
+ } = getTooltipStyles(
+ this.props.animation,
+ this.props.windowWidth,
+ this.props.xOffset,
+ this.props.yOffset,
+ this.props.wrapperWidth,
+ this.props.wrapperHeight,
+ this.props.maxWidth,
+ this.state.tooltipWidth,
+ this.state.tooltipHeight,
+ this.state.tooltipTextWidth,
+ this.props.shiftHorizontal,
+ this.props.shiftVertical,
+ );
+ return ReactDOM.createPortal(
+
+
+ this.textRef = ref}>{this.props.text}
+
+
+
+
+ ,
+ document.querySelector('body'),
+ );
+ }
+}
+
+TooltipRenderedOnPageBody.propTypes = propTypes;
+
+export default TooltipRenderedOnPageBody;
diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js
index 942029b4ab46..af46f8154d00 100644
--- a/src/components/Tooltip/index.js
+++ b/src/components/Tooltip/index.js
@@ -25,25 +25,14 @@ class Tooltip extends PureComponent {
// The width and height of the wrapper view
wrapperWidth: 0,
wrapperHeight: 0,
-
- // The width and height of the tooltip itself
- tooltipWidth: 0,
- tooltipHeight: 0,
};
- // The wrapper view containing the wrapped content along with the Tooltip itself.
- this.wrapperView = null;
-
- // The tooltip (popover) itself.
- this.tooltip = null;
-
// Whether the tooltip is first tooltip to activate the TooltipSense
this.isTooltipSenseInitiator = false;
this.shouldStartShowAnimation = false;
this.animation = new Animated.Value(0);
this.getWrapperPosition = this.getWrapperPosition.bind(this);
- this.measureTooltip = this.measureTooltip.bind(this);
this.showTooltip = this.showTooltip.bind(this);
this.hideTooltip = this.hideTooltip.bind(this);
}
@@ -86,18 +75,6 @@ class Tooltip extends PureComponent {
}));
}
- /**
- * Measure the size of the tooltip itself.
- *
- * @param {Object} nativeEvent
- */
- measureTooltip({nativeEvent}) {
- this.setState({
- tooltipWidth: nativeEvent.layout.width,
- tooltipHeight: nativeEvent.layout.height,
- });
- }
-
/**
* Display the tooltip in an animation.
*/
@@ -200,13 +177,11 @@ class Tooltip extends PureComponent {
yOffset={this.state.yOffset}
wrapperWidth={this.state.wrapperWidth}
wrapperHeight={this.state.wrapperHeight}
- tooltipWidth={this.state.tooltipWidth}
- tooltipHeight={this.state.tooltipHeight}
- setTooltipRef={el => this.tooltip = el}
shiftHorizontal={_.result(this.props, 'shiftHorizontal')}
shiftVertical={_.result(this.props, 'shiftVertical')}
- measureTooltip={this.measureTooltip}
text={this.props.text}
+ maxWidth={this.props.maxWidth}
+ numberOfLines={this.props.numberOfLines}
/>
)}
Linking.openURL(CONST.NEW_ZOOM_MEETING_URL),
+ onPress: () => {
+ this.toggleVideoChatMenu();
+ Linking.openURL(CONST.NEW_ZOOM_MEETING_URL);
+ },
},
{
icon: GoogleMeetIcon,
text: props.translate('videoChatButtonAndMenu.googleMeet'),
- onPress: () => Linking.openURL(CONST.NEW_GOOGLE_MEET_MEETING_URL),
+ onPress: () => {
+ this.toggleVideoChatMenu();
+ Linking.openURL(CONST.NEW_GOOGLE_MEET_MEETING_URL);
+ },
},
- ], item => ({
- ...item,
- onPress: () => {
- item.onPress();
- this.toggleVideoChatMenu();
- },
- }));
+ ];
this.state = {
isVideoChatMenuActive: false,
@@ -132,8 +132,6 @@ class VideoChatButtonAndMenu extends Component {
left: this.state.videoChatIconPosition.x - 150,
top: this.state.videoChatIconPosition.y + 40,
}}
- animationIn="fadeInDown"
- animationOut="fadeOutUp"
>
{_.map(this.menuItemData, ({icon, text, onPress}) => (
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index d0d30095d285..e6b171c03329 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -1,7 +1,6 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
-import Str from 'expensify-common/lib/str';
import _ from 'underscore';
import {View, ScrollView} from 'react-native';
import lodashGet from 'lodash/get';
@@ -15,7 +14,7 @@ import HeaderWithCloseButton from '../components/HeaderWithCloseButton';
import styles from '../styles/styles';
import DisplayNames from '../components/DisplayNames';
import * as OptionsListUtils from '../libs/OptionsListUtils';
-import * as ReportUtils from '../libs/reportUtils';
+import * as ReportUtils from '../libs/ReportUtils';
import participantPropTypes from '../components/participantPropTypes';
import * as Expensicons from '../components/Icon/Expensicons';
import ROUTES from '../ROUTES';
@@ -108,16 +107,9 @@ class ReportDetailsPage extends Component {
const chatRoomSubtitle = ReportUtils.getChatRoomSubtitle(this.props.report, this.props.policies);
const participants = lodashGet(this.props.report, 'participants', []);
const isMultipleParticipant = participants.length > 1;
- const displayNamesWithTooltips = _.map(
+ const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(
OptionsListUtils.getPersonalDetailsForLogins(participants, this.props.personalDetails),
- ({displayName, firstName, login}) => {
- const displayNameTrimmed = Str.isSMSLogin(login) ? this.props.toLocalPhone(displayName) : displayName;
-
- return {
- displayName: (isMultipleParticipant ? firstName : displayNameTrimmed) || Str.removeSMSDomain(login),
- tooltip: Str.removeSMSDomain(login),
- };
- },
+ isMultipleParticipant,
);
return (
diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js
index 160ece47e57f..2dae49e8cf45 100755
--- a/src/pages/ReportParticipantsPage.js
+++ b/src/pages/ReportParticipantsPage.js
@@ -16,7 +16,7 @@ import ROUTES from '../ROUTES';
import personalDetailsPropType from './personalDetailsPropType';
import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
import compose from '../libs/compose';
-import * as ReportUtils from '../libs/reportUtils';
+import * as ReportUtils from '../libs/ReportUtils';
const propTypes = {
/* Onyx Props */
diff --git a/src/pages/ReportSettingsPage.js b/src/pages/ReportSettingsPage.js
index 669b47b2d1c3..7cb8e62e72f5 100644
--- a/src/pages/ReportSettingsPage.js
+++ b/src/pages/ReportSettingsPage.js
@@ -9,7 +9,7 @@ import styles from '../styles/styles';
import compose from '../libs/compose';
import Navigation from '../libs/Navigation/Navigation';
import * as Report from '../libs/actions/Report';
-import * as ReportUtils from '../libs/reportUtils';
+import * as ReportUtils from '../libs/ReportUtils';
import HeaderWithCloseButton from '../components/HeaderWithCloseButton';
import ScreenWrapper from '../components/ScreenWrapper';
import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
diff --git a/src/pages/RequestCallPage.js b/src/pages/RequestCallPage.js
index 84504769a867..b42ecfc57332 100644
--- a/src/pages/RequestCallPage.js
+++ b/src/pages/RequestCallPage.js
@@ -31,6 +31,9 @@ import * as LoginUtils from '../libs/LoginUtils';
import * as ValidationUtils from '../libs/ValidationUtils';
import * as PersonalDetails from '../libs/actions/PersonalDetails';
import * as User from '../libs/actions/User';
+import FormElement from '../components/FormElement';
+import {withNetwork} from '../components/OnyxProvider';
+import networkPropTypes from '../components/networkPropTypes';
const propTypes = {
...withLocalizePropTypes,
@@ -78,6 +81,9 @@ const propTypes = {
// The date that the user will be unblocked
expiresAt: PropTypes.string,
}),
+
+ /** Information about the network from Onyx */
+ network: networkPropTypes.isRequired,
};
const defaultProps = {
@@ -116,14 +122,15 @@ class RequestCallPage extends Component {
}
componentDidMount() {
- // If it is the weekend don't check the wait time
- if (moment().day() === 0 || moment().day() === 6) {
- this.setState({
- onTheWeekend: true,
- });
+ this.fetchData();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.network.isOffline || this.props.network.isOffline) {
return;
}
- Inbox.getInboxCallWaitTime();
+
+ this.fetchData();
}
onSubmit() {
@@ -172,7 +179,7 @@ class RequestCallPage extends Component {
getPhoneNumberError() {
const phoneNumber = LoginUtils.getPhoneNumberWithoutSpecialChars(this.state.phoneNumber);
if (_.isEmpty(this.state.phoneNumber.trim()) || !Str.isValidPhone(phoneNumber)) {
- return this.props.translate('messages.errorMessageInvalidPhone');
+ return this.props.translate('common.error.phoneNumber');
}
return '';
}
@@ -218,6 +225,18 @@ class RequestCallPage extends Component {
return `${this.props.translate(waitTimeKey, {minutes: this.props.inboxCallUserWaitTime})} ${this.props.translate('requestCallPage.waitTime.guides')}`;
}
+ fetchData() {
+ // If it is the weekend don't check the wait time
+ if (moment().day() === 0 || moment().day() === 6) {
+ this.setState({
+ onTheWeekend: true,
+ });
+ return;
+ }
+
+ Inbox.getInboxCallWaitTime();
+ }
+
validatePhoneInput() {
this.setState({phoneNumberError: this.getPhoneNumberError()});
}
@@ -262,45 +281,47 @@ class RequestCallPage extends Component {
onCloseButtonPress={() => Navigation.dismissModal(true)}
/>
-
-
- {this.props.translate('requestCallPage.description')}
-
- this.setState({firstName})}
- onChangeLastName={lastName => this.setState({lastName})}
- style={[styles.mv4]}
- />
- this.setState({phoneNumber})}
- />
- this.setState({phoneExtension})}
- containerStyles={[styles.mt4]}
- />
- {this.getWaitTimeMessage()}
-
+
+
+
+ {this.props.translate('requestCallPage.description')}
+
+ this.setState({firstName})}
+ onChangeLastName={lastName => this.setState({lastName})}
+ style={[styles.mv4]}
+ />
+ this.setState({phoneNumber})}
+ />
+ this.setState({phoneExtension})}
+ containerStyles={[styles.mt4]}
+ />
+ {this.getWaitTimeMessage()}
+
+
{isBlockedFromConcierge && (
@@ -311,6 +332,7 @@ class RequestCallPage extends Component {
)}
diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js
index 7231bdccd31e..a8f6209d1190 100644
--- a/src/pages/home/HeaderView.js
+++ b/src/pages/home/HeaderView.js
@@ -4,7 +4,6 @@ import {View, Pressable} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
-import Str from 'expensify-common/lib/str';
import styles from '../../styles/styles';
import ONYXKEYS from '../../ONYXKEYS';
import themeColors from '../../styles/themes/default';
@@ -24,11 +23,14 @@ import VideoChatButtonAndMenu from '../../components/VideoChatButtonAndMenu';
import IOUBadge from '../../components/IOUBadge';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import CONST from '../../CONST';
-import * as ReportUtils from '../../libs/reportUtils';
+import * as ReportUtils from '../../libs/ReportUtils';
import Text from '../../components/Text';
import Tooltip from '../../components/Tooltip';
const propTypes = {
+ /** The ID of the report */
+ reportID: PropTypes.number.isRequired,
+
/** Toggles the navigationMenu open and closed */
onNavigationMenuButtonClicked: PropTypes.func.isRequired,
@@ -42,9 +44,6 @@ const propTypes = {
/** List of primarylogins of participants of the report */
participants: PropTypes.arrayOf(PropTypes.string),
- /** ID of the report */
- reportID: PropTypes.number,
-
/** Value indicating if the report is pinned or not */
isPinned: PropTypes.bool,
}),
@@ -74,23 +73,12 @@ const HeaderView = (props) => {
}
const participants = lodashGet(props.report, 'participants', []);
+ const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForLogins(participants, props.personalDetails);
const isMultipleParticipant = participants.length > 1;
- const displayNamesWithTooltips = _.map(
- OptionsListUtils.getPersonalDetailsForLogins(participants, props.personalDetails),
- ({displayName, firstName, login}) => {
- const displayNameTrimmed = Str.isSMSLogin(login) ? props.toLocalPhone(displayName) : displayName;
-
- return {
- displayName: (isMultipleParticipant ? firstName : displayNameTrimmed) || Str.removeSMSDomain(login),
- tooltip: Str.removeSMSDomain(login),
- };
- },
- );
+ const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant);
const isChatRoom = ReportUtils.isChatRoom(props.report);
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.report);
- const title = (isChatRoom || isPolicyExpenseChat)
- ? props.report.reportName
- : _.map(displayNamesWithTooltips, ({displayName}) => displayName).join(', ');
+ const title = ReportUtils.getReportName(props.report, participantPersonalDetails, props.policies);
const subtitle = ReportUtils.getChatRoomSubtitle(props.report, props.policies);
const isConcierge = participants.length === 1 && _.contains(participants, CONST.EMAIL.CONCIERGE);
@@ -100,7 +88,7 @@ const HeaderView = (props) => {
// these users via alternative means. It is possible to request a call with Concierge so we leave the option for them.
const shouldShowCallButton = isConcierge || !isAutomatedExpensifyAccount;
const avatarTooltip = isChatRoom ? undefined : _.pluck(displayNamesWithTooltips, 'tooltip');
- const shouldShowSubscript = isPolicyExpenseChat && !props.report.isOwnPolicyExpenseChat;
+ const shouldShowSubscript = isPolicyExpenseChat && !props.report.isOwnPolicyExpenseChat && !ReportUtils.isArchivedRoom(props.report);
const icons = ReportUtils.getIcons(props.report, props.personalDetails, props.policies);
return (
@@ -127,7 +115,7 @@ const HeaderView = (props) => {
{
if (isChatRoom || isPolicyExpenseChat) {
- return Navigation.navigate(ROUTES.getReportDetailsRoute(props.report.reportID));
+ return Navigation.navigate(ROUTES.getReportDetailsRoute(props.reportID));
}
if (participants.length === 1) {
return Navigation.navigate(ROUTES.getDetailsRoute(participants[0]));
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index a90ad90a9570..c1e7de21137f 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -12,7 +12,7 @@ import ROUTES from '../../ROUTES';
import * as Report from '../../libs/actions/Report';
import ONYXKEYS from '../../ONYXKEYS';
import Permissions from '../../libs/Permissions';
-import * as ReportUtils from '../../libs/reportUtils';
+import * as ReportUtils from '../../libs/ReportUtils';
import ReportActionsView from './report/ReportActionsView';
import ReportActionCompose from './report/ReportActionCompose';
import KeyboardSpacer from '../../components/KeyboardSpacer';
diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js
index 98bcc5dca80d..ea0b478aaf63 100755
--- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js
+++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js
@@ -10,12 +10,15 @@ import {
} from './genericReportActionContextMenuPropTypes';
import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
import ContextMenuActions, {CONTEXT_MENU_TYPES} from './ContextMenuActions';
+import compose from '../../../../libs/compose';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions';
const propTypes = {
/** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */
type: PropTypes.string,
...genericReportActionContextMenuPropTypes,
...withLocalizePropTypes,
+ ...windowDimensionsPropTypes,
};
const defaultProps = {
@@ -49,6 +52,7 @@ class BaseReportActionContextMenu extends React.Component {
draftMessage: this.props.draftMessage,
selection: this.props.selection,
})}
+ description={contextAction.getDescription(this.props.selection, this.props.isSmallScreenWidth)}
/>
))}
@@ -59,4 +63,7 @@ class BaseReportActionContextMenu extends React.Component {
BaseReportActionContextMenu.propTypes = propTypes;
BaseReportActionContextMenu.defaultProps = defaultProps;
-export default withLocalize(BaseReportActionContextMenu);
+export default compose(
+ withLocalize,
+ withWindowDimensions,
+)(BaseReportActionContextMenu);
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index b61ebe62f150..7196a4ce1b2e 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -4,10 +4,14 @@ import lodashGet from 'lodash/get';
import * as Expensicons from '../../../../components/Icon/Expensicons';
import * as Report from '../../../../libs/actions/Report';
import Clipboard from '../../../../libs/Clipboard';
-import * as ReportUtils from '../../../../libs/reportUtils';
+import * as ReportUtils from '../../../../libs/ReportUtils';
import ReportActionComposeFocusManager from '../../../../libs/ReportActionComposeFocusManager';
import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu';
import CONST from '../../../../CONST';
+import getAttachmentDetails from '../../../../libs/fileDownload/getAttachmentDetails';
+import fileDownload from '../../../../libs/fileDownload';
+import addEncryptedAuthTokenToURL from '../../../../libs/addEncryptedAuthTokenToURL';
+import * as ContextMenuUtils from './ContextMenuUtils';
/**
* Gets the HTML version of the message in an action.
@@ -27,6 +31,29 @@ const CONTEXT_MENU_TYPES = {
// A list of all the context actions in this menu.
export default [
+ {
+ textTranslateKey: 'common.download',
+ icon: Expensicons.Download,
+ successTextTranslateKey: 'common.download',
+ successIcon: Expensicons.Download,
+ shouldShow: (type, reportAction) => {
+ const message = _.last(lodashGet(reportAction, 'message', [{}]));
+ const isAttachment = _.has(reportAction, 'isAttachment')
+ ? reportAction.isAttachment
+ : ReportUtils.isReportMessageAttachment(message);
+ return isAttachment && reportAction.reportActionID;
+ },
+ onPress: (closePopover, {reportAction}) => {
+ const message = _.last(lodashGet(reportAction, 'message', [{}]));
+ const html = lodashGet(message, 'html', '');
+ const attachmentDetails = getAttachmentDetails(html);
+ const {originalFileName} = attachmentDetails;
+ let {sourceURL} = attachmentDetails;
+ sourceURL = addEncryptedAuthTokenToURL(sourceURL);
+ fileDownload(sourceURL, originalFileName);
+ },
+ getDescription: () => {},
+ },
{
textTranslateKey: 'reportActionContextMenu.copyURLToClipboard',
icon: Expensicons.Clipboard,
@@ -37,6 +64,7 @@ export default [
Clipboard.setString(selection);
hideContextMenu(true, ReportActionComposeFocusManager.focus);
},
+ getDescription: ContextMenuUtils.getPopoverDescription,
},
{
textTranslateKey: 'reportActionContextMenu.copyEmailToClipboard',
@@ -48,6 +76,7 @@ export default [
Clipboard.setString(selection.replace('mailto:', ''));
hideContextMenu(true, ReportActionComposeFocusManager.focus);
},
+ getDescription: () => {},
},
{
textTranslateKey: 'reportActionContextMenu.copyToClipboard',
@@ -82,6 +111,7 @@ export default [
hideContextMenu(true, ReportActionComposeFocusManager.focus);
}
},
+ getDescription: () => {},
},
{
@@ -89,6 +119,7 @@ export default [
icon: Expensicons.LinkCopy,
shouldShow: () => false,
onPress: () => {},
+ getDescription: () => {},
},
{
@@ -103,6 +134,7 @@ export default [
hideContextMenu(true, ReportActionComposeFocusManager.focus);
}
},
+ getDescription: () => {},
},
{
@@ -127,6 +159,7 @@ export default [
// No popover to hide, call editAction immediately
editAction();
},
+ getDescription: () => {},
},
{
textTranslateKey: 'reportActionContextMenu.deleteComment',
@@ -146,6 +179,7 @@ export default [
// No popover to hide, call showDeleteConfirmModal immediately
showDeleteModal(reportID, reportAction);
},
+ getDescription: () => {},
},
];
diff --git a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js
new file mode 100644
index 000000000000..f694f413e29a
--- /dev/null
+++ b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.js
@@ -0,0 +1,17 @@
+/* eslint-disable import/prefer-default-export */
+
+/**
+ * The popover description will be an empty string on anything but mobile web
+ * because we show link in tooltip instead of popover on web
+ *
+ * @param {String} selection
+ * @param {Boolean} isSmallScreenWidth
+ * @returns {String}
+ */
+function getPopoverDescription(selection, isSmallScreenWidth) {
+ return isSmallScreenWidth ? selection : '';
+}
+
+export {
+ getPopoverDescription,
+};
diff --git a/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js
new file mode 100644
index 000000000000..3dbf81926098
--- /dev/null
+++ b/src/pages/home/report/ContextMenu/ContextMenuUtils/index.native.js
@@ -0,0 +1,15 @@
+/* eslint-disable import/prefer-default-export */
+
+/**
+ * Always show popover description on native platforms
+ *
+ * @param {String} selection
+ * @returns {String}
+ */
+function getPopoverDescription(selection) {
+ return selection;
+}
+
+export {
+ getPopoverDescription,
+};
diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
index bb2668573d70..b77599ad9bea 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
@@ -285,7 +285,7 @@ class PopoverReportActionContextMenu extends React.Component {
animationOutTiming={1}
measureContent={this.measureContent}
shouldSetModalVisibility={false}
- fullscreen={false}
+ fullscreen
>
(
+
+
+ {props.children}
+
+
+);
+
+FloatingMessageCounterContainer.propTypes = floatingMessageCounterContainerPropTypes;
+FloatingMessageCounterContainer.displayName = 'FloatingMessageCounterContainer';
+
+export default FloatingMessageCounterContainer;
diff --git a/src/pages/home/report/FloatingMessageCounter/FloatingMessageCounterContainer/index.js b/src/pages/home/report/FloatingMessageCounter/FloatingMessageCounterContainer/index.js
new file mode 100644
index 000000000000..c5a9ed5078eb
--- /dev/null
+++ b/src/pages/home/report/FloatingMessageCounter/FloatingMessageCounterContainer/index.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import {Animated} from 'react-native';
+import styles from '../../../../../styles/styles';
+import floatingMessageCounterContainerPropTypes from './floatingMessageCounterContainerPropTypes';
+
+const FloatingMessageCounterContainer = props => (
+
+ {props.children}
+
+);
+
+FloatingMessageCounterContainer.propTypes = floatingMessageCounterContainerPropTypes;
+FloatingMessageCounterContainer.displayName = 'FloatingMessageCounterContainer';
+
+export default FloatingMessageCounterContainer;
diff --git a/src/pages/home/report/MarkerBadge/MarkerBadge.js b/src/pages/home/report/FloatingMessageCounter/index.js
similarity index 86%
rename from src/pages/home/report/MarkerBadge/MarkerBadge.js
rename to src/pages/home/report/FloatingMessageCounter/index.js
index 6cf4d32b070f..fb9dfba7c362 100644
--- a/src/pages/home/report/MarkerBadge/MarkerBadge.js
+++ b/src/pages/home/report/FloatingMessageCounter/index.js
@@ -8,7 +8,7 @@ import Icon from '../../../../components/Icon';
import * as Expensicons from '../../../../components/Icon/Expensicons';
import themeColors from '../../../../styles/themes/default';
import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
-import MarkerBadgeContainer from './MarkerBadgeContainer';
+import FloatingMessageCounterContainer from './FloatingMessageCounterContainer';
const propTypes = {
/** Count of new messages to show in the badge */
@@ -36,7 +36,7 @@ const defaultProps = {
const MARKER_INACTIVE_TRANSLATE_Y = -30;
const MARKER_ACTIVE_TRANSLATE_Y = 10;
-class MarkerBadge extends PureComponent {
+class FloatingMessageCounter extends PureComponent {
constructor(props) {
super(props);
this.translateY = new Animated.Value(MARKER_INACTIVE_TRANSLATE_Y);
@@ -70,8 +70,8 @@ class MarkerBadge extends PureComponent {
render() {
return (
-
-
+
+
{this.props.translate(
- 'reportActionsViewMarkerBadge.newMsg',
+ 'newMessageCount',
{count: this.props.count},
)}
@@ -114,12 +114,12 @@ class MarkerBadge extends PureComponent {
/>
-
+
);
}
}
-MarkerBadge.propTypes = propTypes;
-MarkerBadge.defaultProps = defaultProps;
+FloatingMessageCounter.propTypes = propTypes;
+FloatingMessageCounter.defaultProps = defaultProps;
-export default withLocalize(MarkerBadge);
+export default withLocalize(FloatingMessageCounter);
diff --git a/src/pages/home/report/MarkerBadge/MarkerBadgeContainer/index.android.js b/src/pages/home/report/MarkerBadge/MarkerBadgeContainer/index.android.js
deleted file mode 100644
index f975b698bf99..000000000000
--- a/src/pages/home/report/MarkerBadge/MarkerBadgeContainer/index.android.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-import {View, Animated} from 'react-native';
-import styles from '../../../../../styles/styles';
-import propTypes from './markerBadgeContainerPropTypes';
-
-const MarkerBadgeContainer = props => (
-
-
- {props.children}
-
-
-);
-
-MarkerBadgeContainer.propTypes = propTypes;
-MarkerBadgeContainer.displayName = 'MarkerBadgeContainer';
-
-export default MarkerBadgeContainer;
diff --git a/src/pages/home/report/MarkerBadge/MarkerBadgeContainer/index.js b/src/pages/home/report/MarkerBadge/MarkerBadgeContainer/index.js
deleted file mode 100644
index 12b690a5ef14..000000000000
--- a/src/pages/home/report/MarkerBadge/MarkerBadgeContainer/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import React from 'react';
-import {Animated} from 'react-native';
-import styles from '../../../../../styles/styles';
-import propTypes from './markerBadgeContainerPropTypes';
-
-const MarkerBadgeContainer = props => (
-
- {props.children}
-
-);
-
-MarkerBadgeContainer.propTypes = propTypes;
-MarkerBadgeContainer.displayName = 'MarkerBadgeContainer';
-
-export default MarkerBadgeContainer;
diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js
index 6517fef5d24a..a3f2c17ab409 100755
--- a/src/pages/home/report/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose.js
@@ -32,7 +32,7 @@ import Permissions from '../../../libs/Permissions';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
import reportActionPropTypes from './reportActionPropTypes';
-import * as ReportUtils from '../../../libs/reportUtils';
+import * as ReportUtils from '../../../libs/ReportUtils';
import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager';
import Text from '../../../components/Text';
import participantPropTypes from '../../../components/participantPropTypes';
@@ -44,6 +44,7 @@ import Tooltip from '../../../components/Tooltip';
import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton';
import VirtualKeyboard from '../../../libs/VirtualKeyboard';
import canUseTouchScreen from '../../../libs/canUseTouchscreen';
+import networkPropTypes from '../../../components/networkPropTypes';
const propTypes = {
/** Beta features list */
@@ -87,16 +88,20 @@ const propTypes = {
isFocused: PropTypes.bool.isRequired,
/** Information about the network */
- network: PropTypes.shape({
- /** Is the network currently offline or not */
- isOffline: PropTypes.bool,
- }),
+ network: networkPropTypes.isRequired,
// The NVP describing a user's block status
blockedFromConcierge: PropTypes.shape({
// The date that the user will be unblocked
expiresAt: PropTypes.string,
}),
+
+ /** The personal details of the person who is logged in */
+ myPersonalDetails: PropTypes.shape({
+ /** Primary login of the user */
+ login: PropTypes.string,
+ }),
+
...windowDimensionsPropTypes,
...withLocalizePropTypes,
};
@@ -106,9 +111,9 @@ const defaultProps = {
modal: {},
report: {},
reportActions: {},
- network: {isOffline: false},
blockedFromConcierge: {},
personalDetails: {},
+ myPersonalDetails: {},
};
class ReportActionCompose extends React.Component {
@@ -128,6 +133,7 @@ class ReportActionCompose extends React.Component {
this.onSelectionChange = this.onSelectionChange.bind(this);
this.setTextInputRef = this.setTextInputRef.bind(this);
this.getInputPlaceholder = this.getInputPlaceholder.bind(this);
+ this.getIOUOptions = this.getIOUOptions.bind(this);
this.state = {
isFocused: this.shouldFocusInputOnScreenFocus,
@@ -233,6 +239,45 @@ class ReportActionCompose extends React.Component {
return this.props.translate('reportActionCompose.writeSomething');
}
+ /**
+ * Returns the list of IOU Options
+ *
+ * @param {Array} reportParticipants
+ * @returns {Array
- {this.props.network.isOffline ? (
-
-
-
-
- {this.props.translate('reportActionCompose.youAppearToBeOffline')}
-
-
+
+
+ {this.props.network.isOffline ? (
+
+
+
+ {this.props.translate('reportActionCompose.youAppearToBeOffline')}
+
+
+ ) : }
- ) : }
+ {hasExceededMaxCommentLength && (
+
+ {`${this.comment.length}/${CONST.MAX_COMMENT_LENGTH}`}
+
+ )}
+
);
}
@@ -629,5 +642,8 @@ export default compose(
blockedFromConcierge: {
key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE,
},
+ myPersonalDetails: {
+ key: ONYXKEYS.MY_PERSONAL_DETAILS,
+ },
}),
)(ReportActionCompose);
diff --git a/src/pages/home/report/ReportActionItemCreated.js b/src/pages/home/report/ReportActionItemCreated.js
index 984fc13e1f50..00d3c0c62652 100644
--- a/src/pages/home/report/ReportActionItemCreated.js
+++ b/src/pages/home/report/ReportActionItemCreated.js
@@ -6,7 +6,7 @@ import ONYXKEYS from '../../../ONYXKEYS';
import RoomHeaderAvatars from '../../../components/RoomHeaderAvatars';
import ReportWelcomeText from '../../../components/ReportWelcomeText';
import participantPropTypes from '../../../components/participantPropTypes';
-import * as ReportUtils from '../../../libs/reportUtils';
+import * as ReportUtils from '../../../libs/ReportUtils';
import styles from '../../../styles/styles';
const propTypes = {
diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js
index fac432c74a23..e70becf96f45 100644
--- a/src/pages/home/report/ReportActionItemFragment.js
+++ b/src/pages/home/report/ReportActionItemFragment.js
@@ -87,6 +87,16 @@ const ReportActionItemFragment = (props) => {
);
}
+ // If the only difference between fragment.text and fragment.html is
tags
+ // we replace them with line breaks and render it as text, not as html.
+ // This is done to render emojis with line breaks between them as text.
+ const differByLineBreaksOnly = props.fragment.html.replaceAll('
', ' ') === props.fragment.text;
+ if (differByLineBreaksOnly) {
+ const textWithLineBreaks = props.fragment.html.replaceAll('
', '\n');
+ // eslint-disable-next-line no-param-reassign
+ props.fragment = {...props.fragment, text: textWithLineBreaks, html: textWithLineBreaks};
+ }
+
// Only render HTML if we have html in the fragment
return props.fragment.html !== props.fragment.text
? (
@@ -96,7 +106,7 @@ const ReportActionItemFragment = (props) => {
) : (
{Str.htmlDecode(props.fragment.text)}
{props.fragment.isEdited && (
diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js
index b207304b7e5e..c1eb02a8326d 100644
--- a/src/pages/home/report/ReportActionItemMessage.js
+++ b/src/pages/home/report/ReportActionItemMessage.js
@@ -8,25 +8,19 @@ import reportActionPropTypes from './reportActionPropTypes';
import {withNetwork} from '../../../components/OnyxProvider';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import compose from '../../../libs/compose';
+import networkPropTypes from '../../../components/networkPropTypes';
const propTypes = {
/** The report action */
action: PropTypes.shape(reportActionPropTypes).isRequired,
/** Information about the network */
- network: PropTypes.shape({
- /** Is the network currently offline or not */
- isOffline: PropTypes.bool,
- }),
+ network: networkPropTypes.isRequired,
/** localization props */
...withLocalizePropTypes,
};
-const defaultProps = {
- network: {isOffline: false},
-};
-
const ReportActionItemMessage = (props) => {
const isUnsent = props.network.isOffline && props.action.loading;
@@ -46,7 +40,6 @@ const ReportActionItemMessage = (props) => {
};
ReportActionItemMessage.propTypes = propTypes;
-ReportActionItemMessage.defaultProps = defaultProps;
ReportActionItemMessage.displayName = 'ReportActionItemMessage';
export default compose(
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index f9c40cf6ac6f..3a5db8e5bd1a 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -15,7 +15,7 @@ import Button from '../../../components/Button';
import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager';
import compose from '../../../libs/compose';
import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton';
-import * as ReportUtils from '../../../libs/reportUtils';
+import * as ReportUtils from '../../../libs/ReportUtils';
import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu';
import VirtualKeyboard from '../../../libs/VirtualKeyboard';
import * as User from '../../../libs/actions/User';
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
new file mode 100644
index 000000000000..4af69adc6672
--- /dev/null
+++ b/src/pages/home/report/ReportActionsList.js
@@ -0,0 +1,192 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {ActivityIndicator, View} from 'react-native';
+import InvertedFlatList from '../../../components/InvertedFlatList';
+import withDrawerState, {withDrawerPropTypes} from '../../../components/withDrawerState';
+import compose from '../../../libs/compose';
+import * as ReportScrollManager from '../../../libs/ReportScrollManager';
+import styles from '../../../styles/styles';
+import themeColors from '../../../styles/themes/default';
+import * as ReportUtils from '../../../libs/ReportUtils';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
+import {withPersonalDetails} from '../../../components/OnyxProvider';
+import ReportActionItem from './ReportActionItem';
+import variables from '../../../styles/variables';
+import participantPropTypes from '../../../components/participantPropTypes';
+import * as ReportActionsUtils from '../../../libs/ReportActionsUtils';
+import reportActionPropTypes from './reportActionPropTypes';
+
+const propTypes = {
+ /** Personal details of all the users */
+ personalDetails: PropTypes.objectOf(participantPropTypes),
+
+ /** The report currently being looked at */
+ report: PropTypes.shape({
+ /** Number of actions unread */
+ unreadActionCount: PropTypes.number,
+
+ /** The largest sequenceNumber on this report */
+ maxSequenceNumber: PropTypes.number,
+
+ /** The current position of the new marker */
+ newMarkerSequenceNumber: PropTypes.number,
+
+ /** Whether there is an outstanding amount in IOU */
+ hasOutstandingIOU: PropTypes.bool,
+ }).isRequired,
+
+ /** Sorted actions prepared for display */
+ sortedReportActions: PropTypes.arrayOf(PropTypes.shape({
+ /** Index of the action in the array */
+ index: PropTypes.number,
+
+ /** The action itself */
+ action: PropTypes.shape(reportActionPropTypes),
+ })).isRequired,
+
+ /** The sequence number of the most recent IOU report connected with the shown report */
+ mostRecentIOUReportSequenceNumber: PropTypes.number,
+
+ /** Are we loading more report actions? */
+ isLoadingReportActions: PropTypes.bool.isRequired,
+
+ /** Callback executed on list layout */
+ onLayout: PropTypes.func.isRequired,
+
+ /** Callback executed on scroll */
+ onScroll: PropTypes.func.isRequired,
+
+ /** Function to load more chats */
+ loadMoreChats: PropTypes.func.isRequired,
+
+ ...withDrawerPropTypes,
+ ...windowDimensionsPropTypes,
+};
+
+const defaultProps = {
+ personalDetails: {},
+ mostRecentIOUReportSequenceNumber: undefined,
+};
+
+class ReportActionsList extends React.Component {
+ constructor(props) {
+ super(props);
+ this.renderItem = this.renderItem.bind(this);
+ this.renderCell = this.renderCell.bind(this);
+ this.keyExtractor = this.keyExtractor.bind(this);
+ }
+
+ /**
+ * Calculates the ideal number of report actions to render in the first render, based on the screen height and on
+ * the height of the smallest report action possible.
+ * @return {Number}
+ */
+ calculateInitialNumToRender() {
+ const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom
+ + variables.fontSizeNormalHeight;
+ const availableHeight = this.props.windowHeight
+ - (styles.chatFooter.minHeight + variables.contentHeaderHeight);
+ return Math.ceil(availableHeight / minimumReportActionHeight);
+ }
+
+ /**
+ * Create a unique key for Each Action in the FlatList.
+ * We use a combination of sequenceNumber and clientID in case the clientID are the same - which
+ * shouldn't happen, but might be possible in some rare cases.
+ * @param {Object} item
+ * @return {String}
+ */
+ keyExtractor(item) {
+ return `${item.action.sequenceNumber}${item.action.clientID}`;
+ }
+
+ /**
+ * Do not move this or make it an anonymous function it is a method
+ * so it will not be recreated each time we render an item
+ *
+ * See: https://reactnative.dev/docs/optimizing-flatlist-configuration#avoid-anonymous-function-on-renderitem
+ *
+ * @param {Object} args
+ * @param {Object} args.item
+ * @param {Number} args.index
+ *
+ * @returns {React.Component}
+ */
+ renderItem({
+ item,
+ index,
+ }) {
+ const shouldDisplayNewIndicator = this.props.report.newMarkerSequenceNumber > 0
+ && item.action.sequenceNumber === this.props.report.newMarkerSequenceNumber;
+ return (
+
+ );
+ }
+
+ /**
+ * This function overrides the CellRendererComponent (defaults to a plain View), giving each ReportActionItem a
+ * higher z-index than the one below it. This prevents issues where the ReportActionContextMenu overlapping between
+ * rows is hidden beneath other rows.
+ *
+ * @param {Object} index - The ReportAction item in the FlatList.
+ * @param {Object|Array} style – The default styles of the CellRendererComponent provided by the CellRenderer.
+ * @param {Object} props – All the other Props provided to the CellRendererComponent by default.
+ * @returns {React.Component}
+ */
+ renderCell({item, style, ...props}) {
+ const cellStyle = [
+ style,
+ {zIndex: item.action.sequenceNumber},
+ ];
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ return ;
+ }
+
+ render() {
+ // Native mobile does not render updates flatlist the changes even though component did update called.
+ // To notify there something changes we can use extraData prop to flatlist
+ const extraData = (!this.props.isDrawerOpen && this.props.isSmallScreenWidth) ? this.props.report.newMarkerSequenceNumber : undefined;
+ const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(this.props.personalDetails, this.props.report);
+ return (
+
+ : null}
+ keyboardShouldPersistTaps="handled"
+ onLayout={this.props.onLayout}
+ onScroll={this.props.onScroll}
+ extraData={extraData}
+ />
+ );
+ }
+}
+
+ReportActionsList.propTypes = propTypes;
+ReportActionsList.defaultProps = defaultProps;
+
+export default compose(
+ withDrawerState,
+ withWindowDimensions,
+ withPersonalDetails(),
+)(ReportActionsList);
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index f425e97ede6e..ef0520b143f9 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -1,27 +1,18 @@
import React from 'react';
import {
- View,
Keyboard,
AppState,
- ActivityIndicator,
} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import _ from 'underscore';
import lodashGet from 'lodash/get';
-import Clipboard from '../../../libs/Clipboard';
import * as Report from '../../../libs/actions/Report';
-import KeyboardShortcut from '../../../libs/KeyboardShortcut';
-import SelectionScraper from '../../../libs/SelectionScraper';
-import ReportActionItem from './ReportActionItem';
-import styles from '../../../styles/styles';
import reportActionPropTypes from './reportActionPropTypes';
-import InvertedFlatList from '../../../components/InvertedFlatList';
import * as CollectionUtils from '../../../libs/CollectionUtils';
import Visibility from '../../../libs/Visibility';
import Timing from '../../../libs/actions/Timing';
import CONST from '../../../CONST';
-import themeColors from '../../../styles/themes/default';
import compose from '../../../libs/compose';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
import withDrawerState, {withDrawerPropTypes} from '../../../components/withDrawerState';
@@ -30,15 +21,16 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal
import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager';
import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu';
import PopoverReportActionContextMenu from './ContextMenu/PopoverReportActionContextMenu';
-import variables from '../../../styles/variables';
-import MarkerBadge from './MarkerBadge/MarkerBadge';
import Performance from '../../../libs/Performance';
-import * as ReportUtils from '../../../libs/reportUtils';
import ONYXKEYS from '../../../ONYXKEYS';
-import {withPersonalDetails} from '../../../components/OnyxProvider';
-import participantPropTypes from '../../../components/participantPropTypes';
-import EmojiPicker from '../../../components/EmojiPicker';
+import {withNetwork} from '../../../components/OnyxProvider';
import * as EmojiPickerAction from '../../../libs/actions/EmojiPickerAction';
+import FloatingMessageCounter from './FloatingMessageCounter';
+import networkPropTypes from '../../../components/networkPropTypes';
+import ReportActionsList from './ReportActionsList';
+import CopySelectionHelper from '../../../components/CopySelectionHelper';
+import EmojiPicker from '../../../components/EmojiPicker/EmojiPicker';
+import * as ReportActionsUtils from '../../../libs/ReportActionsUtils';
const propTypes = {
/** The ID of the report actions will be created for */
@@ -76,8 +68,8 @@ const propTypes = {
/** Are we waiting for more report data? */
isLoadingReportData: PropTypes.bool,
- /** Personal details of all the users */
- personalDetails: PropTypes.objectOf(participantPropTypes),
+ /** Information about the network */
+ network: networkPropTypes.isRequired,
...windowDimensionsPropTypes,
...withDrawerPropTypes,
@@ -94,44 +86,44 @@ const defaultProps = {
session: {},
isLoadingReportActions: false,
isLoadingReportData: false,
- personalDetails: {},
};
class ReportActionsView extends React.Component {
constructor(props) {
super(props);
- this.renderItem = this.renderItem.bind(this);
- this.renderCell = this.renderCell.bind(this);
- this.scrollToBottomAndUpdateLastRead = this.scrollToBottomAndUpdateLastRead.bind(this);
- this.onVisibilityChange = this.onVisibilityChange.bind(this);
- this.recordTimeToMeasureItemLayout = this.recordTimeToMeasureItemLayout.bind(this);
- this.loadMoreChats = this.loadMoreChats.bind(this);
- this.sortedReportActions = [];
this.appStateChangeListener = null;
this.didLayout = false;
this.state = {
- isMarkerActive: false,
- localUnreadActionCount: this.props.report.unreadActionCount,
+ isFloatingMessageCounterVisible: false,
+ messageCounterCount: this.props.report.unreadActionCount,
};
this.currentScrollOffset = 0;
- this.updateSortedReportActions(props.reportActions);
- this.updateMostRecentIOUReportActionNumber(props.reportActions);
- this.keyExtractor = this.keyExtractor.bind(this);
+ this.sortedReportActions = ReportActionsUtils.getSortedReportActions(props.reportActions);
+ this.mostRecentIOUReportSequenceNumber = ReportActionsUtils.getMostRecentIOUReportSequenceNumber(props.reportActions);
this.trackScroll = this.trackScroll.bind(this);
- this.showMarker = this.showMarker.bind(this);
- this.hideMarker = this.hideMarker.bind(this);
- this.toggleMarker = this.toggleMarker.bind(this);
- this.updateUnreadIndicatorPosition = this.updateUnreadIndicatorPosition.bind(this);
- this.updateLocalUnreadActionCount = this.updateLocalUnreadActionCount.bind(this);
+ this.showFloatingMessageCounter = this.showFloatingMessageCounter.bind(this);
+ this.hideFloatingMessageCounter = this.hideFloatingMessageCounter.bind(this);
+ this.toggleFloatingMessageCounter = this.toggleFloatingMessageCounter.bind(this);
+ this.updateNewMarkerPosition = this.updateNewMarkerPosition.bind(this);
+ this.updateMessageCounterCount = this.updateMessageCounterCount.bind(this);
+ this.loadMoreChats = this.loadMoreChats.bind(this);
+ this.recordTimeToMeasureItemLayout = this.recordTimeToMeasureItemLayout.bind(this);
+ this.scrollToBottomAndUpdateLastRead = this.scrollToBottomAndUpdateLastRead.bind(this);
this.updateNewMarkerAndMarkReadOnce = _.once(this.updateNewMarkerAndMarkRead.bind(this));
}
componentDidMount() {
- this.appStateChangeListener = AppState.addEventListener('change', this.onVisibilityChange);
+ this.appStateChangeListener = AppState.addEventListener('change', () => {
+ if (!Visibility.isVisible() || this.props.isDrawerOpen) {
+ return;
+ }
+
+ Report.updateLastReadActionID(this.props.reportID);
+ });
// If the reportID is not found then we have either not loaded this chat or the user is unable to access it.
// We will attempt to fetch it and redirect if still not accessible.
@@ -150,18 +142,13 @@ class ReportActionsView extends React.Component {
this.updateNewMarkerAndMarkReadOnce();
}
- Report.fetchActions(this.props.reportID);
-
- const copyShortcutConfig = CONST.KEYBOARD_SHORTCUTS.COPY;
- this.unsubscribeCopyShortcut = KeyboardShortcut.subscribe(copyShortcutConfig.shortcutKey, () => {
- this.copySelectionToClipboard();
- }, copyShortcutConfig.descriptionKey, copyShortcutConfig.modifiers, false);
+ this.fetchData();
}
shouldComponentUpdate(nextProps, nextState) {
if (!_.isEqual(nextProps.reportActions, this.props.reportActions)) {
- this.updateSortedReportActions(nextProps.reportActions);
- this.updateMostRecentIOUReportActionNumber(nextProps.reportActions);
+ this.sortedReportActions = ReportActionsUtils.getSortedReportActions(nextProps.reportActions);
+ this.mostRecentIOUReportSequenceNumber = ReportActionsUtils.getMostRecentIOUReportSequenceNumber(nextProps.reportActions);
return true;
}
@@ -170,6 +157,10 @@ class ReportActionsView extends React.Component {
return true;
}
+ if (nextProps.network.isOffline !== this.props.network.isOffline) {
+ return true;
+ }
+
if (nextProps.isLoadingReportActions !== this.props.isLoadingReportActions) {
return true;
}
@@ -178,11 +169,11 @@ class ReportActionsView extends React.Component {
return true;
}
- if (nextState.isMarkerActive !== this.state.isMarkerActive) {
+ if (nextState.isFloatingMessageCounterVisible !== this.state.isFloatingMessageCounterVisible) {
return true;
}
- if (nextState.localUnreadActionCount !== this.state.localUnreadActionCount) {
+ if (nextState.messageCounterCount !== this.state.messageCounterCount) {
return true;
}
@@ -202,6 +193,10 @@ class ReportActionsView extends React.Component {
}
componentDidUpdate(prevProps) {
+ if (prevProps.network.isOffline && !this.props.network.isOffline) {
+ this.fetchData();
+ }
+
// Update the last read action for the report currently in view when report data finishes loading.
// This report should now be up-to-date and since it is in view we mark it as read.
if (!this.props.isLoadingReportData && prevProps.isLoadingReportData) {
@@ -212,39 +207,41 @@ class ReportActionsView extends React.Component {
const previousLastSequenceNumber = lodashGet(CollectionUtils.lastItem(prevProps.reportActions), 'sequenceNumber');
const currentLastSequenceNumber = lodashGet(CollectionUtils.lastItem(this.props.reportActions), 'sequenceNumber');
- // Record the max action when window is visible except when Drawer is open on small screen
- const shouldRecordMaxAction = Visibility.isVisible()
- && (!this.props.isSmallScreenWidth || !this.props.isDrawerOpen);
+ // Record the max action when window is visible and the sidebar is not covering the report view on a small screen
+ const isSidebarCoveringReportView = this.props.isSmallScreenWidth && this.props.isDrawerOpen;
+ const shouldRecordMaxAction = Visibility.isVisible() && !isSidebarCoveringReportView;
+
+ const sidebarClosed = prevProps.isDrawerOpen && !this.props.isDrawerOpen;
+ const screenSizeIncreased = prevProps.isSmallScreenWidth && !this.props.isSmallScreenWidth;
+ const reportBecomeVisible = sidebarClosed || screenSizeIncreased;
if (previousLastSequenceNumber !== currentLastSequenceNumber) {
- // If a new comment is added and it's from the current user scroll to the bottom otherwise
- // leave the user positioned where they are now in the list.
const lastAction = CollectionUtils.lastItem(this.props.reportActions);
- if (lastAction && (lastAction.actorEmail === this.props.session.email)) {
+ const isLastActionFromCurrentUser = lodashGet(lastAction, 'actorEmail', '') === lodashGet(this.props.session, 'email', '');
+ if (isLastActionFromCurrentUser) {
+ // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where they are now in the list.
ReportScrollManager.scrollToBottom();
- }
-
- if (lodashGet(lastAction, 'actorEmail', '') !== lodashGet(this.props.session, 'email', '')) {
- // Only update the unread count when MarkerBadge is visible
- // Otherwise marker will be shown on scrolling up from the bottom even if user have read those messages
- if (this.state.isMarkerActive) {
- this.updateLocalUnreadActionCount(!shouldRecordMaxAction);
+ } else {
+ // Only update the unread count when the floating message counter is visible
+ // Otherwise counter will be shown on scrolling up from the bottom even if user have read those messages
+ if (this.state.isFloatingMessageCounterVisible) {
+ this.updateMessageCounterCount(!shouldRecordMaxAction);
}
- // show new MarkerBadge when there is a new message
- this.toggleMarker();
+ // Show new floating message counter when there is a new message
+ this.toggleFloatingMessageCounter();
}
// When the last action changes, record the max action
- // This will make the unread indicator go away if you receive comments in the same chat you're looking at
+ // This will make the NEW marker line go away if you receive comments in the same chat you're looking at
if (shouldRecordMaxAction) {
Report.updateLastReadActionID(this.props.reportID);
}
- } else if (shouldRecordMaxAction && (
- prevProps.isDrawerOpen !== this.props.isDrawerOpen
- || prevProps.isSmallScreenWidth !== this.props.isSmallScreenWidth
- )) {
- this.updateUnreadIndicatorPosition(this.props.report.unreadActionCount);
+ }
+
+ // Update the new marker position and last read action when we are closing the sidebar or moving from a small to large screen size
+ if (shouldRecordMaxAction && reportBecomeVisible) {
+ this.updateNewMarkerPosition(this.props.report.unreadActionCount);
Report.updateLastReadActionID(this.props.reportID);
}
}
@@ -259,38 +256,10 @@ class ReportActionsView extends React.Component {
}
Report.unsubscribeFromReportChannel(this.props.reportID);
-
- if (this.unsubscribeCopyShortcut) {
- this.unsubscribeCopyShortcut();
- }
- }
-
- /**
- * Records the max action on app visibility change event.
- */
- onVisibilityChange() {
- if (!Visibility.isVisible() || this.props.isDrawerOpen) {
- return;
- }
-
- Report.updateLastReadActionID(this.props.reportID);
- }
-
- copySelectionToClipboard = () => {
- const selectionMarkdown = SelectionScraper.getAsMarkdown();
-
- Clipboard.setString(selectionMarkdown);
}
- /**
- * Create a unique key for Each Action in the FlatList.
- * We use a combination of sequenceNumber and clientID in case the clientID are the same - which
- * shouldn't happen, but might be possible in some rare cases.
- * @param {Object} item
- * @return {String}
- */
- keyExtractor(item) {
- return `${item.action.sequenceNumber}${item.action.clientID}`;
+ fetchData() {
+ Report.fetchActions(this.props.reportID);
}
/**
@@ -318,84 +287,6 @@ class ReportActionsView extends React.Component {
Report.fetchActionsWithLoadingState(this.props.reportID, offset);
}
- /**
- * Calculates the ideal number of report actions to render in the first render, based on the screen height and on
- * the height of the smallest report action possible.
- * @return {Number}
- */
- calculateInitialNumToRender() {
- const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom
- + variables.fontSizeNormalHeight;
- const availableHeight = this.props.windowHeight
- - (styles.chatFooter.minHeight + variables.contentHeaderHeight);
- return Math.ceil(availableHeight / minimumReportActionHeight);
- }
-
- /**
- * Updates and sorts the report actions by sequence number
- *
- * @param {Array<{sequenceNumber, actionName}>} reportActions
- */
- updateSortedReportActions(reportActions) {
- this.sortedReportActions = _.chain(reportActions)
- .sortBy('sequenceNumber')
- .filter(action => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU
- || (action.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isDeletedAction(action))
- || action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED
- || action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED)
- .map((item, index) => ({action: item, index}))
- .value()
- .reverse();
- }
-
- /**
- * Returns true when the report action immediately before the
- * specified index is a comment made by the same actor who who
- * is leaving a comment in the action at the specified index.
- * Also checks to ensure that the comment is not too old to
- * be considered part of the same comment
- *
- * @param {Number} actionIndex - index of the comment item in state to check
- *
- * @return {Boolean}
- */
- isConsecutiveActionMadeByPreviousActor(actionIndex) {
- const previousAction = this.sortedReportActions[actionIndex + 1];
- const currentAction = this.sortedReportActions[actionIndex];
-
- // It's OK for there to be no previous action, and in that case, false will be returned
- // so that the comment isn't grouped
- if (!currentAction || !previousAction) {
- return false;
- }
-
- // Comments are only grouped if they happen within 5 minutes of each other
- if (currentAction.action.timestamp - previousAction.action.timestamp > 300) {
- return false;
- }
-
- // Do not group if previous or current action was a renamed action
- if (previousAction.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED
- || currentAction.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
- return false;
- }
-
- return currentAction.action.actorEmail === previousAction.action.actorEmail;
- }
-
- /**
- * Finds and updates most recent IOU report action number
- *
- * @param {Array<{sequenceNumber, actionName}>} reportActions
- */
- updateMostRecentIOUReportActionNumber(reportActions) {
- this.mostRecentIOUReportSequenceNumber = _.chain(reportActions)
- .sortBy('sequenceNumber')
- .filter(action => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU)
- .max(action => action.sequenceNumber)
- .value().sequenceNumber;
- }
-
/**
* This function is triggered from the ref callback for the scrollview. That way it can be scrolled once all the
* items have been rendered. If the number of actions has changed since it was last rendered, then
@@ -410,7 +301,7 @@ class ReportActionsView extends React.Component {
* Updates NEW marker position
* @param {Number} unreadActionCount
*/
- updateUnreadIndicatorPosition(unreadActionCount) {
+ updateNewMarkerPosition(unreadActionCount) {
// Since we want the New marker to remain in place even if newer messages come in, we set it once on mount.
// We determine the last read action by deducting the number of unread actions from the total number.
// Then, we add 1 because we want the New marker displayed over the oldest unread sequence.
@@ -419,31 +310,31 @@ class ReportActionsView extends React.Component {
}
/**
- * Show/hide the new MarkerBadge when user is scrolling back/forth in the history of messages.
+ * Show/hide the new floating message counter when user is scrolling back/forth in the history of messages.
*/
- toggleMarker() {
- // Update the unread message count before MarkerBadge is about to show
- if (this.currentScrollOffset < -200 && !this.state.isMarkerActive) {
- this.updateLocalUnreadActionCount();
- this.showMarker();
+ toggleFloatingMessageCounter() {
+ // Update the message counter count before counter is about to show
+ if (this.currentScrollOffset < -200 && !this.state.isFloatingMessageCounterVisible) {
+ this.updateMessageCounterCount();
+ this.showFloatingMessageCounter();
}
- if (this.currentScrollOffset > -200 && this.state.isMarkerActive) {
- this.hideMarker();
+ if (this.currentScrollOffset > -200 && this.state.isFloatingMessageCounterVisible) {
+ this.hideFloatingMessageCounter();
}
}
/**
- * Update the unread messages count to show in the MarkerBadge
+ * Update the message counter count to show in the floating message counter
* @param {Boolean} [shouldResetLocalCount=false] Whether count should increment or reset
*/
- updateLocalUnreadActionCount(shouldResetLocalCount = false) {
+ updateMessageCounterCount(shouldResetLocalCount = false) {
this.setState((prevState) => {
- const localUnreadActionCount = shouldResetLocalCount
+ const messageCounterCount = shouldResetLocalCount
? this.props.report.unreadActionCount
- : prevState.localUnreadActionCount + this.props.report.unreadActionCount;
- this.updateUnreadIndicatorPosition(localUnreadActionCount);
- return {localUnreadActionCount};
+ : prevState.messageCounterCount + this.props.report.unreadActionCount;
+ this.updateNewMarkerPosition(messageCounterCount);
+ return {messageCounterCount};
});
}
@@ -451,7 +342,7 @@ class ReportActionsView extends React.Component {
* Update NEW marker and mark report as read
*/
updateNewMarkerAndMarkRead() {
- this.updateUnreadIndicatorPosition(this.props.report.unreadActionCount);
+ this.updateNewMarkerPosition(this.props.report.unreadActionCount);
// Only mark as read if the report is open
if (!this.props.isDrawerOpen) {
@@ -460,18 +351,19 @@ class ReportActionsView extends React.Component {
}
/**
- * Show the new MarkerBadge
+ * Show the new floating message counter
*/
- showMarker() {
- this.setState({isMarkerActive: true});
+ showFloatingMessageCounter() {
+ this.setState({isFloatingMessageCounterVisible: true});
}
/**
- * Hide the new MarkerBadge
+ * Hide the new floating message counter
*/
- hideMarker() {
- this.setState({isMarkerActive: false}, () => {
- this.setState({localUnreadActionCount: 0});
+ hideFloatingMessageCounter() {
+ this.setState({
+ isFloatingMessageCounterVisible: false,
+ messageCounterCount: 0,
});
}
@@ -482,7 +374,7 @@ class ReportActionsView extends React.Component {
*/
trackScroll({nativeEvent}) {
this.currentScrollOffset = -nativeEvent.contentOffset.y;
- this.toggleMarker();
+ this.toggleFloatingMessageCounter();
}
/**
@@ -505,99 +397,32 @@ class ReportActionsView extends React.Component {
}
}
- /**
- * This function overrides the CellRendererComponent (defaults to a plain View), giving each ReportActionItem a
- * higher z-index than the one below it. This prevents issues where the ReportActionContextMenu overlapping between
- * rows is hidden beneath other rows.
- *
- * @param {Object} index - The ReportAction item in the FlatList.
- * @param {Object|Array} style – The default styles of the CellRendererComponent provided by the CellRenderer.
- * @param {Object} props – All the other Props provided to the CellRendererComponent by default.
- * @returns {React.Component}
- */
- renderCell({item, style, ...props}) {
- const cellStyle = [
- style,
- {zIndex: item.action.sequenceNumber},
- ];
- // eslint-disable-next-line react/jsx-props-no-spreading
- return ;
- }
-
- /**
- * Do not move this or make it an anonymous function it is a method
- * so it will not be recreated each time we render an item
- *
- * See: https://reactnative.dev/docs/optimizing-flatlist-configuration#avoid-anonymous-function-on-renderitem
- *
- * @param {Object} args
- * @param {Object} args.item
- * @param {Number} args.index
- *
- * @returns {React.Component}
- */
- renderItem({
- item,
- index,
- }) {
- const shouldDisplayNewIndicator = this.props.report.newMarkerSequenceNumber > 0
- && item.action.sequenceNumber === this.props.report.newMarkerSequenceNumber;
- return (
-
- );
- }
-
render() {
// Comments have not loaded at all yet do nothing
if (!_.size(this.props.reportActions)) {
return null;
}
- // Native mobile does not render updates flatlist the changes even though component did update called.
- // To notify there something changes we can use extraData prop to flatlist
- const extraData = (!this.props.isDrawerOpen && this.props.isSmallScreenWidth) ? this.props.report.newMarkerSequenceNumber : undefined;
- const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(this.props.personalDetails, this.props.report);
-
return (
<>
-
-
- : null}
- keyboardShouldPersistTaps="handled"
- onLayout={this.recordTimeToMeasureItemLayout}
+
+
>
);
}
@@ -611,7 +436,7 @@ export default compose(
withWindowDimensions,
withDrawerState,
withLocalize,
- withPersonalDetails(),
+ withNetwork(),
withOnyx({
isLoadingReportData: {
key: ONYXKEYS.IS_LOADING_REPORT_DATA,
diff --git a/src/pages/home/report/ReportTypingIndicator.js b/src/pages/home/report/ReportTypingIndicator.js
index aea493cadeb3..ee43a3a004ff 100755
--- a/src/pages/home/report/ReportTypingIndicator.js
+++ b/src/pages/home/report/ReportTypingIndicator.js
@@ -1,5 +1,4 @@
import React from 'react';
-import {View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
import {withOnyx} from 'react-native-onyx';
@@ -50,7 +49,7 @@ class ReportTypingIndicator extends React.Component {
// Decide on the Text element that will hold the display based on the number of users that are typing.
switch (numUsersTyping) {
case 0:
- return ;
+ return <>>;
case 1:
return (
-
- {this.props.translate('reportTypingIndicator.multipleUsers')}
- {` ${this.props.translate('reportTypingIndicator.areTyping')}`}
-
-
+
+ {this.props.translate('reportTypingIndicator.multipleUsers')}
+ {` ${this.props.translate('reportTypingIndicator.areTyping')}`}
+
);
}
}
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index 3a77109ca9ad..9021bbc6674e 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -24,7 +24,9 @@ import participantPropTypes from '../../../components/participantPropTypes';
import themeColors from '../../../styles/themes/default';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import * as App from '../../../libs/actions/App';
-import * as ReportUtils from '../../../libs/reportUtils';
+import * as ReportUtils from '../../../libs/ReportUtils';
+import networkPropTypes from '../../../components/networkPropTypes';
+import {withNetwork} from '../../../components/OnyxProvider';
const propTypes = {
/** Toggles the navigation menu open and closed */
@@ -39,8 +41,13 @@ const propTypes = {
/* Onyx Props */
/** List of reports */
reports: PropTypes.objectOf(PropTypes.shape({
+ /** ID of the report */
reportID: PropTypes.number,
+
+ /** Name of the report */
reportName: PropTypes.string,
+
+ /** Number of unread actions on the report */
unreadActionCount: PropTypes.number,
})),
@@ -60,10 +67,7 @@ const propTypes = {
}),
/** Information about the network */
- network: PropTypes.shape({
- /** Is the network currently offline or not */
- isOffline: PropTypes.bool,
- }),
+ network: networkPropTypes.isRequired,
/** Currently viewed reportID */
currentlyViewedReportID: PropTypes.string,
@@ -90,7 +94,6 @@ const defaultProps = {
myPersonalDetails: {
avatar: ReportUtils.getDefaultAvatar(),
},
- network: null,
currentlyViewedReportID: '',
priorityMode: CONST.PRIORITY_MODE.DEFAULT,
initialReportDataLoaded: false,
@@ -322,6 +325,7 @@ SidebarLinks.defaultProps = defaultProps;
export default compose(
withLocalize,
+ withNetwork(),
withOnyx({
reports: {
key: ONYXKEYS.COLLECTION.REPORT,
@@ -332,9 +336,6 @@ export default compose(
myPersonalDetails: {
key: ONYXKEYS.MY_PERSONAL_DETAILS,
},
- network: {
- key: ONYXKEYS.NETWORK,
- },
currentlyViewedReportID: {
key: ONYXKEYS.CURRENTLY_VIEWED_REPORTID,
},
diff --git a/src/pages/home/sidebar/SidebarScreen.js b/src/pages/home/sidebar/SidebarScreen.js
index 3d3d06ee5115..d44c40fc75b4 100755
--- a/src/pages/home/sidebar/SidebarScreen.js
+++ b/src/pages/home/sidebar/SidebarScreen.js
@@ -22,7 +22,7 @@ import Permissions from '../../../libs/Permissions';
import ONYXKEYS from '../../../ONYXKEYS';
import * as Policy from '../../../libs/actions/Policy';
import Performance from '../../../libs/Performance';
-import * as WelcomeAction from '../../../libs/actions/WelcomeActions';
+import * as Welcome from '../../../libs/actions/Welcome';
const propTypes = {
/* Beta features list */
@@ -43,10 +43,10 @@ class SidebarScreen extends Component {
constructor(props) {
super(props);
- this.onCreateMenuItemSelected = this.onCreateMenuItemSelected.bind(this);
- this.toggleCreateMenu = this.toggleCreateMenu.bind(this);
+ this.hideCreateMenu = this.hideCreateMenu.bind(this);
this.startTimer = this.startTimer.bind(this);
this.navigateToSettings = this.navigateToSettings.bind(this);
+ this.showCreateMenu = this.showCreateMenu.bind(this);
this.state = {
isCreateMenuActive: false,
@@ -58,14 +58,16 @@ class SidebarScreen extends Component {
Timing.start(CONST.TIMING.SIDEBAR_LOADED, true);
const routes = lodashGet(this.props.navigation.getState(), 'routes', []);
- WelcomeAction.show({routes, toggleCreateMenu: this.toggleCreateMenu});
+ Welcome.show({routes, showCreateMenu: this.showCreateMenu});
}
/**
- * Method called when a Create Menu item is selected.
+ * Method called when we click the floating action button
*/
- onCreateMenuItemSelected() {
- this.toggleCreateMenu();
+ showCreateMenu() {
+ this.setState({
+ isCreateMenuActive: true,
+ });
}
/**
@@ -76,16 +78,14 @@ class SidebarScreen extends Component {
}
/**
- * Method called when we click the floating action button
- * will trigger the animation
* Method called either when:
* Pressing the floating action button to open the CreateMenu modal
* Selecting an item on CreateMenu or closing it by clicking outside of the modal component
*/
- toggleCreateMenu() {
- this.setState(state => ({
- isCreateMenuActive: !state.isCreateMenuActive,
- }));
+ hideCreateMenu() {
+ this.setState({
+ isCreateMenuActive: false,
+ });
}
/**
@@ -117,14 +117,14 @@ class SidebarScreen extends Component {
accessibilityLabel={this.props.translate('sidebarScreen.fabNewChat')}
accessibilityRole="button"
isActive={this.state.isCreateMenuActive}
- onPress={this.toggleCreateMenu}
+ onPress={this.showCreateMenu}
/>
Navigation.navigate(ROUTES.IOU_BILL),
},
] : []),
- ...(!this.props.isCreatingWorkspace && Permissions.canUseFreePlan(this.props.betas) && !Policy.isAdminOfFreePolicy(this.props.allPolicies) ? [
+ ...(!this.props.isCreatingWorkspace && !Policy.isAdminOfFreePolicy(this.props.allPolicies) ? [
{
icon: Expensicons.NewWorkspace,
iconWidth: 46,
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index 09ad555d5c04..53b45dce1a6b 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -20,6 +20,8 @@ import KeyboardAvoidingView from '../../components/KeyboardAvoidingView';
import Button from '../../components/Button';
import FixedFooter from '../../components/FixedFooter';
import * as IOU from '../../libs/actions/IOU';
+import {withNetwork} from '../../components/OnyxProvider';
+import networkPropTypes from '../../components/networkPropTypes';
/**
* IOU Currency selection for selecting currency
@@ -51,7 +53,11 @@ const propTypes = {
// ISO4217 Code for the currency
ISO4217: PropTypes.string,
})),
+
+ /** Information about the network from Onyx */
+ network: networkPropTypes.isRequired,
...withLocalizePropTypes,
+
};
const defaultProps = {
@@ -84,7 +90,15 @@ class IOUCurrencySelection extends Component {
}
componentDidMount() {
- PersonalDetails.getCurrencyList();
+ this.fetchData();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.network.isOffline || this.props.network.isOffline) {
+ return;
+ }
+
+ this.fetchData();
}
/**
@@ -120,6 +134,10 @@ class IOUCurrencySelection extends Component {
return currencyOptions;
}
+ fetchData() {
+ PersonalDetails.getCurrencyList();
+ }
+
/**
* Function which toggles a currency in the list
*
@@ -244,4 +262,5 @@ export default compose(
myPersonalDetails: {key: ONYXKEYS.MY_PERSONAL_DETAILS},
iou: {key: ONYXKEYS.IOU},
}),
+ withNetwork(),
)(IOUCurrencySelection);
diff --git a/src/pages/iou/IOUDetailsModal.js b/src/pages/iou/IOUDetailsModal.js
index 23dd98d3e934..bd406810dc73 100644
--- a/src/pages/iou/IOUDetailsModal.js
+++ b/src/pages/iou/IOUDetailsModal.js
@@ -6,6 +6,7 @@ import lodashGet from 'lodash/get';
import _ from 'underscore';
import styles from '../../styles/styles';
import ONYXKEYS from '../../ONYXKEYS';
+import {withNetwork} from '../../components/OnyxProvider';
import themeColors from '../../styles/themes/default';
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
import Navigation from '../../libs/Navigation/Navigation';
@@ -20,6 +21,7 @@ import CONST from '../../CONST';
import SettlementButton from '../../components/SettlementButton';
import ROUTES from '../../ROUTES';
import FixedFooter from '../../components/FixedFooter';
+import networkPropTypes from '../../components/networkPropTypes';
const propTypes = {
/** URL Route params */
@@ -65,6 +67,9 @@ const propTypes = {
email: PropTypes.string,
}).isRequired,
+ /** Information about the network */
+ network: networkPropTypes.isRequired,
+
...withLocalizePropTypes,
};
@@ -75,7 +80,15 @@ const defaultProps = {
class IOUDetailsModal extends Component {
componentDidMount() {
- Report.fetchIOUReportByID(this.props.route.params.iouReportID, this.props.route.params.chatReportID, true);
+ this.fetchData();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.network.isOffline || this.props.network.isOffline) {
+ return;
+ }
+
+ this.fetchData();
}
/**
@@ -85,6 +98,10 @@ class IOUDetailsModal extends Component {
return _.first(lodashGet(this.props, 'iouReport.submitterPhoneNumbers', [])) || '';
}
+ fetchData() {
+ Report.fetchIOUReportByID(this.props.route.params.iouReportID, this.props.route.params.chatReportID, true);
+ }
+
/**
* @param {String} paymentMethodType
*/
@@ -152,6 +169,7 @@ IOUDetailsModal.defaultProps = defaultProps;
export default compose(
withLocalize,
+ withNetwork(),
withOnyx({
iou: {
key: ONYXKEYS.IOU,
diff --git a/src/pages/iou/IOUModal.js b/src/pages/iou/IOUModal.js
index a75b7f990cfe..c0d8cf70449f 100755
--- a/src/pages/iou/IOUModal.js
+++ b/src/pages/iou/IOUModal.js
@@ -429,7 +429,7 @@ class IOUModal extends Component {
onConfirm={this.createTransaction}
onSendMoney={this.sendMoney}
hasMultipleParticipants={this.props.hasMultipleParticipants}
- participants={this.state.participants}
+ participants={_.filter(this.state.participants, email => this.props.myPersonalDetails.login !== email.login)}
iouAmount={this.state.amount}
comment={this.state.comment}
onUpdateComment={this.updateComment}
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index cba96fa15504..2bb4544fabb2 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -21,6 +21,8 @@ import compose from '../../libs/compose';
import CONST from '../../CONST';
import DateUtils from '../../libs/DateUtils';
import Permissions from '../../libs/Permissions';
+import networkPropTypes from '../../components/networkPropTypes';
+import {withNetwork} from '../../components/OnyxProvider';
const propTypes = {
/* Onyx Props */
@@ -35,10 +37,7 @@ const propTypes = {
}),
/** Information about the network */
- network: PropTypes.shape({
- /** Is the network currently offline or not */
- isOffline: PropTypes.bool,
- }),
+ network: networkPropTypes.isRequired,
/** The session of the logged in person */
session: PropTypes.shape({
@@ -75,7 +74,6 @@ const propTypes = {
const defaultProps = {
myPersonalDetails: {},
- network: {},
session: {},
policies: {},
userWallet: {
@@ -212,13 +210,11 @@ InitialSettingsPage.displayName = 'InitialSettingsPage';
export default compose(
withLocalize,
+ withNetwork(),
withOnyx({
myPersonalDetails: {
key: ONYXKEYS.MY_PERSONAL_DETAILS,
},
- network: {
- key: ONYXKEYS.NETWORK,
- },
session: {
key: ONYXKEYS.SESSION,
},
diff --git a/src/pages/settings/PasswordConfirmationScreen.js b/src/pages/settings/PasswordConfirmationScreen.js
new file mode 100644
index 000000000000..fbb2a5a0b008
--- /dev/null
+++ b/src/pages/settings/PasswordConfirmationScreen.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import {Image, View} from 'react-native';
+import Text from '../../components/Text';
+import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
+import styles from '../../styles/styles';
+import confettiPop from '../../../assets/images/confetti-pop.gif';
+import Button from '../../components/Button';
+import FixedFooter from '../../components/FixedFooter';
+import Navigation from '../../libs/Navigation/Navigation';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+};
+
+const PasswordConfirmationScreen = props => (
+ <>
+
+
+
+ {props.translate('passwordConfirmationScreen.passwordUpdated')}
+
+
+ {props.translate('passwordConfirmationScreen.allSet')}
+
+
+
+
+ >
+);
+
+PasswordConfirmationScreen.propTypes = propTypes;
+PasswordConfirmationScreen.displayName = 'PasswordConfirmationScreen';
+
+export default withLocalize(PasswordConfirmationScreen);
diff --git a/src/pages/settings/PasswordPage.js b/src/pages/settings/PasswordPage.js
index c6779c14e421..8e68e734ec29 100755
--- a/src/pages/settings/PasswordPage.js
+++ b/src/pages/settings/PasswordPage.js
@@ -3,10 +3,8 @@ import {View, ScrollView} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import _ from 'underscore';
-
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
import Navigation from '../../libs/Navigation/Navigation';
-import ROUTES from '../../ROUTES';
import ScreenWrapper from '../../components/ScreenWrapper';
import Text from '../../components/Text';
import styles from '../../styles/styles';
@@ -20,6 +18,7 @@ import KeyboardAvoidingView from '../../components/KeyboardAvoidingView';
import FixedFooter from '../../components/FixedFooter';
import TextInput from '../../components/TextInput';
import * as Session from '../../libs/actions/Session';
+import PasswordConfirmationScreen from './PasswordConfirmationScreen';
const propTypes = {
/* Onyx Props */
@@ -152,77 +151,81 @@ class PasswordPage extends Component {
Navigation.navigate(ROUTES.SETTINGS_SECURITY)}
+ onBackButtonPress={() => Navigation.goBack()}
onCloseButtonPress={() => Navigation.dismissModal(true)}
/>
-
-
- {this.props.translate('passwordPage.changingYourPasswordPrompt')}
-
-
- this.currentPasswordInputRef = el}
- secureTextEntry
- autoCompleteType="password"
- textContentType="password"
- value={this.state.currentPassword}
- onChangeText={text => this.clearErrorAndSetValue('currentPassword', text)}
- returnKeyType="done"
- hasError={this.state.errors.currentPassword}
- errorText={this.getErrorText('currentPassword')}
- onSubmitEditing={this.submit}
- />
-
-
- this.clearErrorAndSetValue('newPassword', text, ['newPasswordSameAsOld'])}
- onSubmitEditing={this.submit}
- />
- {
-
- shouldShowNewPasswordPrompt && (
-
+ ) : (
+ <>
+
- {this.props.translate('passwordPage.newPasswordPrompt')}
-
- )
- }
-
- {_.every(this.state.errors, error => !error) && !_.isEmpty(this.props.account.error) && (
-
- {this.props.account.error}
-
+
+ {this.props.translate('passwordPage.changingYourPasswordPrompt')}
+
+
+ this.currentPasswordInputRef = el}
+ secureTextEntry
+ autoCompleteType="password"
+ textContentType="password"
+ value={this.state.currentPassword}
+ onChangeText={text => this.clearErrorAndSetValue('currentPassword', text)}
+ returnKeyType="done"
+ hasError={this.state.errors.currentPassword}
+ errorText={this.getErrorText('currentPassword')}
+ onSubmitEditing={this.submit}
+ />
+
+
+ this.clearErrorAndSetValue('newPassword', text, ['newPasswordSameAsOld'])}
+ onSubmitEditing={this.submit}
+ />
+ {shouldShowNewPasswordPrompt && (
+
+ {this.props.translate('passwordPage.newPasswordPrompt')}
+
+ )}
+
+ {_.every(this.state.errors, error => !error) && !_.isEmpty(this.props.account.error) && (
+
+ {this.props.account.error}
+
+ )}
+
+
+
+
+ >
)}
-
-
-
-
);
diff --git a/src/pages/settings/Payments/AddDebitCardPage.js b/src/pages/settings/Payments/AddDebitCardPage.js
index e6a8406c1cea..7ce2c06adf32 100644
--- a/src/pages/settings/Payments/AddDebitCardPage.js
+++ b/src/pages/settings/Payments/AddDebitCardPage.js
@@ -249,7 +249,6 @@ class DebitCardPage extends Component {
label={this.props.translate('addDebitCardPage.expiration')}
placeholder={this.props.translate('addDebitCardPage.expirationDate')}
value={this.state.expirationDate}
- maxLength={7}
errorText={this.getErrorText('expirationDate')}
keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
onChangeText={this.addOrRemoveSlashToExpiryDate}
@@ -299,9 +298,9 @@ class DebitCardPage extends Component {
this.clearErrorAndSetValue('addressState', value)}
+ onInputChange={value => this.clearErrorAndSetValue('addressState', value)}
value={this.state.addressState}
- hasError={lodashGet(this.state.errors, 'addressState', false)}
+ errorText={this.getErrorText('addressState')}
/>
diff --git a/src/pages/settings/Payments/PaymentMethodList.js b/src/pages/settings/Payments/PaymentMethodList.js
index 47145e22ac92..9f1ddd25ea33 100644
--- a/src/pages/settings/Payments/PaymentMethodList.js
+++ b/src/pages/settings/Payments/PaymentMethodList.js
@@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx';
import styles from '../../../styles/styles';
import * as StyleUtils from '../../../styles/StyleUtils';
import MenuItem from '../../../components/MenuItem';
+import Button from '../../../components/Button';
import Text from '../../../components/Text';
import compose from '../../../libs/compose';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
@@ -16,14 +17,12 @@ import bankAccountPropTypes from '../../../components/bankAccountPropTypes';
import * as PaymentUtils from '../../../libs/PaymentUtils';
const MENU_ITEM = 'menuItem';
+const BUTTON = 'button';
const propTypes = {
/** What to do when a menu item is pressed */
onPress: PropTypes.func.isRequired,
- /** Is the payment options menu open / active? */
- isAddPaymentMenuActive: PropTypes.bool,
-
/** User's paypal.me username if they have one */
payPalMeUsername: PropTypes.string,
@@ -77,7 +76,6 @@ const defaultProps = {
walletLinkedAccountID: 0,
walletLinkedAccountType: '',
},
- isAddPaymentMenuActive: false,
shouldShowAddPaymentMethodButton: true,
filterType: '',
actionPaymentMethodType: '',
@@ -117,7 +115,10 @@ class PaymentMethodList extends Component {
* @returns {Array}
*/
getFilteredPaymentMethods() {
- let combinedPaymentMethods = PaymentUtils.formatPaymentMethods(this.props.bankAccountList, this.props.cardList, this.props.payPalMeUsername, this.props.userWallet);
+ // Hide the billing card from the payments menu for now because you can't make it your default method, or delete it
+ const filteredCardList = _.filter(this.props.cardList, card => !card.additionalData.isBillingCard);
+
+ let combinedPaymentMethods = PaymentUtils.formatPaymentMethods(this.props.bankAccountList, filteredCardList, this.props.payPalMeUsername, this.props.userWallet);
if (!_.isEmpty(this.props.filterType)) {
combinedPaymentMethods = _.filter(combinedPaymentMethods, paymentMethod => paymentMethod.accountType === this.props.filterType);
@@ -155,13 +156,16 @@ class PaymentMethodList extends Component {
}
combinedPaymentMethods.push({
- type: MENU_ITEM,
- title: this.props.translate('paymentMethodList.addPaymentMethod'),
- icon: Expensicons.Plus,
+ type: BUTTON,
+ text: this.props.translate('paymentMethodList.addPaymentMethod'),
+ icon: Expensicons.CreditCard,
+ style: [styles.mh4],
+ iconStyles: [styles.mr4],
onPress: e => this.props.onPress(e),
+ isDisabled: this.props.isLoadingPayments,
+ shouldShowRightIcon: true,
+ success: true,
key: 'addPaymentMethodButton',
- iconFill: this.props.isAddPaymentMenuActive ? StyleUtils.getIconFillColor(CONST.BUTTON_STATES.PRESSED) : null,
- wrapperStyle: this.props.isAddPaymentMenuActive ? [StyleUtils.getButtonBackgroundColorStyle(CONST.BUTTON_STATES.PRESSED)] : [],
});
return combinedPaymentMethods;
@@ -204,6 +208,21 @@ class PaymentMethodList extends Component {
/>
);
}
+ if (item.type === BUTTON) {
+ return (
+
+ );
+ }
return (
{
- {/* If we are in the staging environment then we have the option to switch from using the staging secure endpoint or the production secure endpoint. This enables QA */}
- {/* and internal testers to take advantage of sandbox environments for 3rd party services like Plaid and Onfido */}
- {props.environment === CONST.ENVIRONMENT.STAGING && (
- <>
-
- Test Preferences
-
-
-
-
- Use Secure Staging Server
-
-
-
-
-
-
- >
- )}
+ {/* If we are in the staging environment then we enable additional test features */}
+ {props.environment === CONST.ENVIRONMENT.STAGING && }
diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js
index 4daf14ca1076..1aaf232db0bf 100755
--- a/src/pages/settings/Profile/ProfilePage.js
+++ b/src/pages/settings/Profile/ProfilePage.js
@@ -28,7 +28,7 @@ import CheckboxWithLabel from '../../../components/CheckboxWithLabel';
import AvatarWithImagePicker from '../../../components/AvatarWithImagePicker';
import currentUserPersonalDetailsPropsTypes from './currentUserPersonalDetailsPropsTypes';
import * as ValidationUtils from '../../../libs/ValidationUtils';
-import * as ReportUtils from '../../../libs/reportUtils';
+import * as ReportUtils from '../../../libs/ReportUtils';
const propTypes = {
/* Onyx Props */
@@ -56,10 +56,13 @@ const defaultProps = {
loginList: [],
};
-const timezones = _.map(moment.tz.names(), timezone => ({
- value: timezone,
- label: timezone,
-}));
+const timezones = _.chain(moment.tz.names())
+ .filter(timezone => !timezone.startsWith('Etc/GMT'))
+ .map(timezone => ({
+ value: timezone,
+ label: timezone,
+ }))
+ .value();
class ProfilePage extends Component {
constructor(props) {
@@ -292,7 +295,6 @@ class ProfilePage extends Component {
items={timezones}
isDisabled={this.state.isAutomaticTimezone}
value={this.state.selectedTimezone}
- key={`timezonePicker-${this.state.isAutomaticTimezone}`}
/>
this.input = el}
+ ref={el => this.inputPassword = el}
label={this.props.translate('common.password')}
secureTextEntry
autoCompleteType={ComponentUtils.PASSWORD_AUTOCOMPLETE_TYPE}
@@ -124,7 +141,7 @@ class PasswordForm extends React.Component {
@@ -137,6 +154,7 @@ class PasswordForm extends React.Component {
{this.props.account.requiresTwoFactorAuth && (
this.input2FA = el}
label={this.props.translate('passwordForm.twoFactorCode')}
value={this.state.twoFactorAuthCode}
placeholder={this.props.translate('passwordForm.requiredWhen2FAEnabled')}
diff --git a/src/pages/signin/ResendValidationForm.js b/src/pages/signin/ResendValidationForm.js
index 79e425dae5bd..3a19e901cc15 100755
--- a/src/pages/signin/ResendValidationForm.js
+++ b/src/pages/signin/ResendValidationForm.js
@@ -13,7 +13,7 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize
import compose from '../../libs/compose';
import redirectToSignIn from '../../libs/actions/SignInRedirect';
import Avatar from '../../components/Avatar';
-import * as ReportUtils from '../../libs/reportUtils';
+import * as ReportUtils from '../../libs/ReportUtils';
const propTypes = {
/* Onyx Props */
diff --git a/src/pages/workspace/WorkspaceBankAccountPage.js b/src/pages/workspace/WorkspaceBankAccountPage.js
index 3acaa0df7c85..04a89ca53995 100644
--- a/src/pages/workspace/WorkspaceBankAccountPage.js
+++ b/src/pages/workspace/WorkspaceBankAccountPage.js
@@ -21,6 +21,8 @@ import WorkspaceResetBankAccountModal from './WorkspaceResetBankAccountModal';
import styles from '../../styles/styles';
import CONST from '../../CONST';
import withFullPolicy from './withFullPolicy';
+import Button from '../../components/Button';
+import MenuItem from '../../components/MenuItem';
const propTypes = {
/** ACH data for the withdrawal account actively being set up */
@@ -110,25 +112,27 @@ class WorkspaceBankAccountPage extends React.Component {
{this.props.translate('workspace.bankAccount.youreAlmostDone')}
+
+
diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js
index 0d5e069b9fdc..a4ae23252d78 100644
--- a/src/pages/workspace/WorkspaceMembersPage.js
+++ b/src/pages/workspace/WorkspaceMembersPage.js
@@ -7,7 +7,6 @@ import {
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import Str from 'expensify-common/lib/str';
-import Log from '../../libs/Log';
import styles from '../../styles/styles';
import ONYXKEYS from '../../ONYXKEYS';
import HeaderWithCloseButton from '../../components/HeaderWithCloseButton';
@@ -22,7 +21,6 @@ import Text from '../../components/Text';
import ROUTES from '../../ROUTES';
import ConfirmModal from '../../components/ConfirmModal';
import personalDetailsPropType from '../personalDetailsPropType';
-import Permissions from '../../libs/Permissions';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
import OptionRow from '../../components/OptionRow';
import CheckboxWithTooltip from '../../components/CheckboxWithTooltip';
@@ -31,9 +29,6 @@ import withFullPolicy, {fullPolicyPropTypes, fullPolicyDefaultProps} from './wit
import CONST from '../../CONST';
const propTypes = {
- /** List of betas */
- betas: PropTypes.arrayOf(PropTypes.string).isRequired,
-
/** The personal details of the person who is logged in */
personalDetails: personalDetailsPropType.isRequired,
@@ -214,8 +209,9 @@ class WorkspaceMembersPage extends React.Component {
/>
this.toggleUser(item.login)}
forceTextUnreadStyle
- isDisabled
+ isDisabled={!canBeRemoved}
option={{
text: Str.removeSMSDomain(item.displayName),
alternateText: Str.removeSMSDomain(item.login),
@@ -240,10 +236,6 @@ class WorkspaceMembersPage extends React.Component {
}
render() {
- if (!Permissions.canUseFreePlan(this.props.betas)) {
- Log.info('Not showing workspace people page because user is not on free plan beta');
- return ;
- }
const policyEmployeeList = lodashGet(this.props, 'policy.employeeList', []);
const removableMembers = _.without(this.props.policy.employeeList, this.props.session.email, this.props.policy.owner);
const data = _.chain(policyEmployeeList)
@@ -333,8 +325,5 @@ export default compose(
session: {
key: ONYXKEYS.SESSION,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
}),
)(WorkspaceMembersPage);
diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js
index 3b2ce0417fe3..6aec1b883eb3 100644
--- a/src/pages/workspace/WorkspaceNewRoomPage.js
+++ b/src/pages/workspace/WorkspaceNewRoomPage.js
@@ -171,7 +171,6 @@ class WorkspaceNewRoomPage extends React.Component {
placeholder={{value: '', label: this.props.translate('newRoomPage.selectAWorkspace')}}
items={this.state.workspaceOptions}
errorText={this.state.errors.policyID}
- hasError={Boolean(this.state.errors.policyID)}
onInputChange={policyID => this.clearErrorAndSetValue('policyID', policyID)}
/>
diff --git a/src/pages/workspace/WorkspacePageWithSections.js b/src/pages/workspace/WorkspacePageWithSections.js
index f5fa19fc6700..6d84ed2484a8 100644
--- a/src/pages/workspace/WorkspacePageWithSections.js
+++ b/src/pages/workspace/WorkspacePageWithSections.js
@@ -17,8 +17,13 @@ import reimbursementAccountPropTypes from '../ReimbursementAccount/reimbursement
import userPropTypes from '../settings/userPropTypes';
import KeyboardAvoidingView from '../../components/KeyboardAvoidingView';
import withFullPolicy from './withFullPolicy';
+import {withNetwork} from '../../components/OnyxProvider';
+import networkPropTypes from '../../components/networkPropTypes';
const propTypes = {
+ /** Information about the network from Onyx */
+ network: networkPropTypes.isRequired,
+
/** The text to display in the header */
headerText: PropTypes.string.isRequired,
@@ -65,6 +70,18 @@ const defaultProps = {
class WorkspacePageWithSections extends React.Component {
componentDidMount() {
+ this.fetchData();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.network.isOffline || this.props.network.isOffline) {
+ return;
+ }
+
+ this.fetchData();
+ }
+
+ fetchData() {
const achState = lodashGet(this.props.reimbursementAccount, 'achData.state', '');
BankAccounts.fetchFreePlanVerifiedBankAccount('', achState);
}
@@ -118,4 +135,5 @@ export default compose(
},
}),
withFullPolicy,
+ withNetwork(),
)(WorkspacePageWithSections);
diff --git a/src/pages/workspace/WorkspaceResetBankAccountModal.js b/src/pages/workspace/WorkspaceResetBankAccountModal.js
index 6a922386cb5a..02e380744c20 100644
--- a/src/pages/workspace/WorkspaceResetBankAccountModal.js
+++ b/src/pages/workspace/WorkspaceResetBankAccountModal.js
@@ -36,7 +36,7 @@ const WorkspaceResetBankAccountModal = (props) => {
const bankShortName = account ? `${account.addressName} ${account.accountNumber.slice(-4)}` : '';
return (
;
- }
-
if (_.isEmpty(this.props.policy)) {
return ;
}
@@ -190,10 +194,8 @@ WorkspaceSettingsPage.defaultProps = defaultProps;
export default compose(
withFullPolicy,
withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
currencyList: {key: ONYXKEYS.CURRENCY_LIST},
}),
withLocalize,
+ withNetwork(),
)(WorkspaceSettingsPage);
diff --git a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js b/src/pages/workspace/bills/WorkspaceBillsFirstSection.js
index b40afa67751c..85f1ecac7565 100644
--- a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js
+++ b/src/pages/workspace/bills/WorkspaceBillsFirstSection.js
@@ -1,5 +1,5 @@
import React from 'react';
-import {View, TouchableOpacity} from 'react-native';
+import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import Str from 'expensify-common/lib/str';
@@ -14,6 +14,7 @@ import * as Link from '../../../libs/actions/Link';
import compose from '../../../libs/compose';
import ONYXKEYS from '../../../ONYXKEYS';
import userPropTypes from '../../settings/userPropTypes';
+import TextLink from '../../../components/TextLink';
const propTypes = {
/** The policy ID currently being configured */
@@ -54,11 +55,11 @@ const WorkspaceBillsFirstSection = (props) => {
{props.translate('workspace.bills.askYourVendorsBeforeEmail')}
{props.user.isFromPublicDomain ? (
- Link.openExternalLink('https://community.expensify.com/discussion/7500/how-to-pay-your-company-bills-in-expensify/')}
>
- example.com@expensify.cash
-
+ example.com@expensify.cash
+
) : (
(
Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(props.policyID)),
- icon: Expensicons.Bank,
- shouldShowRightIcon: true,
- iconRight: Expensicons.ArrowRight,
- },
- ]}
>
{props.translate('workspace.bills.unlockNoVBACopy')}
+
>
);
diff --git a/src/pages/workspace/card/WorkspaceCardNoVBAView.js b/src/pages/workspace/card/WorkspaceCardNoVBAView.js
index 2b7f0a46973d..1ad5447df218 100644
--- a/src/pages/workspace/card/WorkspaceCardNoVBAView.js
+++ b/src/pages/workspace/card/WorkspaceCardNoVBAView.js
@@ -10,6 +10,7 @@ import * as Expensicons from '../../../components/Icon/Expensicons';
import * as Illustrations from '../../../components/Icon/Illustrations';
import UnorderedList from '../../../components/UnorderedList';
import Section from '../../../components/Section';
+import Button from '../../../components/Button';
const propTypes = {
/** The policy ID currently being configured */
@@ -22,14 +23,6 @@ const WorkspaceCardNoVBAView = props => (
Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(props.policyID)),
- icon: Expensicons.Bank,
- shouldShowRightIcon: true,
- },
- ]}
>
{props.translate('workspace.card.noVBACopy')}
@@ -43,6 +36,16 @@ const WorkspaceCardNoVBAView = props => (
props.translate('workspace.card.benefit4'),
]}
/>
+
);
diff --git a/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js
index 0cab490c164e..ca6ab2578ba4 100644
--- a/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js
+++ b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js
@@ -8,7 +8,6 @@ import * as Illustrations from '../../../components/Icon/Illustrations';
import UnorderedList from '../../../components/UnorderedList';
import * as Link from '../../../libs/actions/Link';
import Section from '../../../components/Section';
-import Permissions from '../../../libs/Permissions';
const propTypes = {
...withLocalizePropTypes,
@@ -30,16 +29,14 @@ const WorkspaceCardVBAWithECardView = (props) => {
shouldShowRightIcon: true,
iconRight: Expensicons.NewWindow,
},
- ];
- if (Permissions.canUseMonthlySettlements(props.betas)) {
- menuItems.push({
+ {
title: props.translate('workspace.common.settlementFrequency'),
onPress: () => Link.openOldDotLink(encodeURI('domain_companycards?param={"section":"configureSettings"}')),
icon: Expensicons.Gear,
shouldShowRightIcon: true,
iconRight: Expensicons.NewWindow,
- });
- }
+ },
+ ];
return (
(
Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(props.policyID)),
- icon: Expensicons.Bank,
- shouldShowRightIcon: true,
- iconRight: Expensicons.ArrowRight,
- },
- ]}
>
{props.translate('workspace.invoices.unlockNoVBACopy')}
+
>
);
diff --git a/src/pages/workspace/reimburse/WorkspaceReimbursePage.js b/src/pages/workspace/reimburse/WorkspaceReimbursePage.js
index 840dda1fcdab..2f0e8a9e3816 100644
--- a/src/pages/workspace/reimburse/WorkspaceReimbursePage.js
+++ b/src/pages/workspace/reimburse/WorkspaceReimbursePage.js
@@ -1,8 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
-import WorkspaceReimburseNoVBAView from './WorkspaceReimburseNoVBAView';
-import WorkspaceReimburseVBAView from './WorkspaceReimburseVBAView';
+import WorkspaceReimburseView from './WorkspaceReimburseView';
import WorkspacePageWithSections from '../WorkspacePageWithSections';
import CONST from '../../../CONST';
@@ -26,13 +25,7 @@ const WorkspaceReimbursePage = props => (
guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_REIMBURSE}
>
{(hasVBA, policyID) => (
- <>
- {!hasVBA ? (
-
- ) : (
-
- )}
- >
+
)}
);
diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseVBAView.js b/src/pages/workspace/reimburse/WorkspaceReimburseVBAView.js
deleted file mode 100644
index 919c340b4079..000000000000
--- a/src/pages/workspace/reimburse/WorkspaceReimburseVBAView.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import PropTypes from 'prop-types';
-import Text from '../../../components/Text';
-import styles from '../../../styles/styles';
-import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
-import * as Expensicons from '../../../components/Icon/Expensicons';
-import * as Illustrations from '../../../components/Icon/Illustrations';
-import Section from '../../../components/Section';
-import CopyTextToClipboard from '../../../components/CopyTextToClipboard';
-import * as Link from '../../../libs/actions/Link';
-
-const propTypes = {
- /** The policy ID currently being configured */
- policyID: PropTypes.string.isRequired,
-
- ...withLocalizePropTypes,
-};
-
-const WorkspaceReimburseVBAView = props => (
- <>
- Link.openOldDotLink(`expenses?policyIDList=${props.policyID}&billableReimbursable=reimbursable&submitterEmail=%2B%2B`),
- icon: Expensicons.Receipt,
- shouldShowRightIcon: true,
- iconRight: Expensicons.NewWindow,
- },
- ]}
- >
-
-
- {props.translate('workspace.reimburse.captureNoVBACopyBeforeEmail')}
-
- {props.translate('workspace.reimburse.captureNoVBACopyAfterEmail')}
-
-
-
-
- Link.openOldDotLink(`reports?policyID=${props.policyID}&from=all&type=expense&showStates=Archived&isAdvancedFilterMode=true`),
- icon: Expensicons.Bank,
- shouldShowRightIcon: true,
- iconRight: Expensicons.NewWindow,
- },
- ]}
- >
-
- {props.translate('workspace.reimburse.fastReimbursementsVBACopy')}
-
-
- >
-);
-
-WorkspaceReimburseVBAView.propTypes = propTypes;
-WorkspaceReimburseVBAView.displayName = 'WorkspaceReimburseVBAView';
-
-export default withLocalize(WorkspaceReimburseVBAView);
diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js b/src/pages/workspace/reimburse/WorkspaceReimburseView.js
similarity index 75%
rename from src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js
rename to src/pages/workspace/reimburse/WorkspaceReimburseView.js
index e068a8666eeb..927401c46522 100644
--- a/src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js
+++ b/src/pages/workspace/reimburse/WorkspaceReimburseView.js
@@ -21,11 +21,15 @@ import ONYXKEYS from '../../../ONYXKEYS';
import * as Policy from '../../../libs/actions/Policy';
import withFullPolicy from '../withFullPolicy';
import CONST from '../../../CONST';
+import Button from '../../../components/Button';
const propTypes = {
/** The policy ID currently being configured */
policyID: PropTypes.string.isRequired,
+ /** Does the user has a VBA into its account? */
+ hasVBA: PropTypes.bool.isRequired,
+
/** Policy values needed in the component */
policy: PropTypes.shape({
customUnit: PropTypes.shape({
@@ -44,7 +48,7 @@ const propTypes = {
...withLocalizePropTypes,
};
-class WorkspaceReimburseNoVBAView extends React.Component {
+class WorkspaceReimburseView extends React.Component {
constructor(props) {
super(props);
this.state = {
@@ -187,29 +191,52 @@ class WorkspaceReimburseNoVBAView extends React.Component {
- Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(this.props.policyID)),
- icon: Expensicons.Bank,
- shouldShowRightIcon: true,
- },
- ]}
- >
-
- {this.props.translate('workspace.reimburse.unlockNoVBACopy')}
-
-
+ {!this.props.hasVBA && (
+
+
+ {this.props.translate('workspace.reimburse.unlockNoVBACopy')}
+
+
+ )}
+ {Boolean(this.props.hasVBA) && (
+ Link.openOldDotLink(`reports?policyID=${this.props.policyID}&from=all&type=expense&showStates=Archived&isAdvancedFilterMode=true`),
+ icon: Expensicons.Bank,
+ shouldShowRightIcon: true,
+ iconRight: Expensicons.NewWindow,
+ },
+ ]}
+ >
+
+ {this.props.translate('workspace.reimburse.fastReimbursementsVBACopy')}
+
+
+ )}
>
);
}
}
-WorkspaceReimburseNoVBAView.propTypes = propTypes;
-WorkspaceReimburseNoVBAView.displayName = 'WorkspaceReimburseNoVBAView';
+WorkspaceReimburseView.propTypes = propTypes;
+WorkspaceReimburseView.displayName = 'WorkspaceReimburseView';
export default compose(
withFullPolicy,
@@ -219,4 +246,4 @@ export default compose(
key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
},
}),
-)(WorkspaceReimburseNoVBAView);
+)(WorkspaceReimburseView);
diff --git a/src/pages/workspace/travel/WorkspaceTravelNoVBAView.js b/src/pages/workspace/travel/WorkspaceTravelNoVBAView.js
index f50d7c49d00c..6afc2837aa05 100644
--- a/src/pages/workspace/travel/WorkspaceTravelNoVBAView.js
+++ b/src/pages/workspace/travel/WorkspaceTravelNoVBAView.js
@@ -9,6 +9,7 @@ import * as Illustrations from '../../../components/Icon/Illustrations';
import Section from '../../../components/Section';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
+import Button from '../../../components/Button';
const propTypes = {
/** The policy ID currently being configured */
@@ -22,19 +23,20 @@ const WorkspaceTravelNoVBAView = props => (
Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(props.policyID)),
- icon: Expensicons.Bank,
- shouldShowRightIcon: true,
- iconRight: Expensicons.ArrowRight,
- },
- ]}
>
{props.translate('workspace.travel.noVBACopy')}
+
>
);
diff --git a/src/setup/index.js b/src/setup/index.js
index 81c49c3adbe1..8c2ae8e2eb99 100644
--- a/src/setup/index.js
+++ b/src/setup/index.js
@@ -22,6 +22,7 @@ export default function () {
Onyx.init({
keys: ONYXKEYS,
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
+ keysToDisableSyncEvents: [ONYXKEYS.CURRENTLY_VIEWED_REPORTID],
captureMetrics: Metrics.canCaptureOnyxMetrics(),
initialKeyStates: {
diff --git a/src/stories/Button.stories.js b/src/stories/Button.stories.js
index c951ba89c0bf..9b02db7951a9 100644
--- a/src/stories/Button.stories.js
+++ b/src/stories/Button.stories.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {useCallback, useState} from 'react';
import Button from '../components/Button';
/**
@@ -18,6 +18,23 @@ const Template = args => ;
// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Default = Template.bind({});
const Loading = Template.bind({});
+const PressOnEnter = (props) => {
+ const [text, setText] = useState('');
+ const onPress = useCallback(() => {
+ setText('Button Pressed!');
+ setTimeout(() => setText(''), 500);
+ });
+ return (
+
+ );
+};
+
Default.args = {
text: 'Save & Continue',
success: true,
@@ -28,8 +45,15 @@ Loading.args = {
success: true,
};
+PressOnEnter.args = {
+ text: 'Press Enter',
+ pressOnEnter: true,
+ success: true,
+};
+
export default story;
export {
Default,
Loading,
+ PressOnEnter,
};
diff --git a/src/stories/Datepicker.stories.js b/src/stories/Datepicker.stories.js
index 0fb3cd31c2b6..08f39b104c7e 100644
--- a/src/stories/Datepicker.stories.js
+++ b/src/stories/Datepicker.stories.js
@@ -13,7 +13,7 @@ export default {
onChange: {action: 'date changed'},
},
args: {
- value: '',
+ defaultValue: '',
label: 'Select Date',
placeholder: 'Date Placeholder',
errorText: '',
@@ -34,7 +34,7 @@ Default.args = {
PreFilled.args = {
label: 'Select Date',
- value: new Date(2018, 7, 21),
+ defaultValue: new Date(2018, 7, 21),
};
export {
diff --git a/src/stories/Form.stories.js b/src/stories/Form.stories.js
index 262a9f3a1c90..6473750dac6d 100644
--- a/src/stories/Form.stories.js
+++ b/src/stories/Form.stories.js
@@ -2,7 +2,9 @@ import React, {useState} from 'react';
import {View} from 'react-native';
import TextInput from '../components/TextInput';
import Picker from '../components/Picker';
+import StatePicker from '../components/StatePicker';
import AddressSearch from '../components/AddressSearch';
+import DatePicker from '../components/DatePicker';
import Form from '../components/Form';
import * as FormActions from '../libs/actions/FormActions';
import styles from '../styles/styles';
@@ -22,6 +24,8 @@ const story = {
AddressSearch,
CheckboxWithLabel,
Picker,
+ StatePicker,
+ DatePicker,
},
};
@@ -38,7 +42,6 @@ const Template = (args) => {
@@ -46,13 +49,17 @@ const Template = (args) => {
label="Account number"
inputID="accountNumber"
containerStyles={[styles.mt4]}
- isFormInput
/>
+
{
value: 'apple',
},
]}
- isFormInput
/>
{
value: 'apple',
},
]}
- isFormInput
/>
+
+
+
(
I accept the Expensify Terms of Service
@@ -130,7 +140,6 @@ const WithNativeEventHandler = (args) => {
label="Routing number"
inputID="routingNumber"
onChangeText={setLog}
- isFormInput
shouldSaveDraft
/>
@@ -158,12 +167,21 @@ const defaultArgs = {
if (!values.accountNumber) {
errors.accountNumber = 'Please enter an account number';
}
+ if (!values.street) {
+ errors.street = 'Please enter an address';
+ }
+ if (!values.dob) {
+ errors.dob = 'Please enter your date of birth';
+ }
if (!values.pickFruit) {
errors.pickFruit = 'Please select a fruit';
}
if (!values.pickAnotherFruit) {
errors.pickAnotherFruit = 'Please select a fruit';
}
+ if (!values.pickState) {
+ errors.pickState = 'Please select a state';
+ }
if (!values.checkbox) {
errors.checkbox = 'You must accept the Terms of Service to continue';
}
@@ -182,8 +200,11 @@ const defaultArgs = {
draftValues: {
routingNumber: '00001',
accountNumber: '1111222233331111',
+ street: '123 Happy St, Happyland HP 12345',
+ dob: '1990-01-01',
pickFruit: 'orange',
pickAnotherFruit: 'apple',
+ pickState: 'AL',
checkbox: false,
},
};
@@ -196,8 +217,11 @@ InputError.args = {
draftValues: {
routingNumber: '',
accountNumber: '',
+ street: '',
pickFruit: '',
+ dob: '',
pickAnotherFruit: '',
+ pickState: '',
checkbox: false,
},
};
diff --git a/src/styles/cardStyles/index.js b/src/styles/cardStyles/index.js
new file mode 100644
index 000000000000..4759700c6f92
--- /dev/null
+++ b/src/styles/cardStyles/index.js
@@ -0,0 +1,16 @@
+import variables from '../variables';
+
+/**
+ * Get card style for cardStyleInterpolator
+ * @param {Boolean} isSmallScreenWidth
+ * @param {Number} screenWidth
+ * @returns {Object}
+ */
+export default function getCardStyles(isSmallScreenWidth, screenWidth) {
+ return {
+ position: 'fixed',
+ width: isSmallScreenWidth ? screenWidth : variables.sideBarWidth,
+ right: 0,
+ height: '100%',
+ };
+}
diff --git a/src/styles/cardStyles/index.native.js b/src/styles/cardStyles/index.native.js
new file mode 100644
index 000000000000..fef33f500708
--- /dev/null
+++ b/src/styles/cardStyles/index.native.js
@@ -0,0 +1,3 @@
+export default function getCardStyles() {
+ return {};
+}
diff --git a/src/styles/getTooltipStyles.js b/src/styles/getTooltipStyles.js
index f39fe2c42311..086a1069d74d 100644
--- a/src/styles/getTooltipStyles.js
+++ b/src/styles/getTooltipStyles.js
@@ -60,8 +60,10 @@ function computeHorizontalShift(windowWidth, xOffset, componentWidth, tooltipWid
* and the top edge of the wrapped component.
* @param {Number} componentWidth - The width of the wrapped component.
* @param {Number} componentHeight - The height of the wrapped component.
+ * @param {Number} maxWidth - The tooltip's max width.
* @param {Number} tooltipWidth - The width of the tooltip itself.
* @param {Number} tooltipHeight - The height of the tooltip itself.
+ * @param {Number} tooltipTextWidth - The tooltip's inner text width.
* @param {Number} [manualShiftHorizontal] - Any additional amount to manually shift the tooltip to the left or right.
* A positive value shifts it to the right,
* and a negative value shifts it to the left.
@@ -76,8 +78,10 @@ export default function getTooltipStyles(
yOffset,
componentWidth,
componentHeight,
+ maxWidth,
tooltipWidth,
tooltipHeight,
+ tooltipTextWidth,
manualShiftHorizontal = 0,
manualShiftVertical = 0,
) {
@@ -93,6 +97,13 @@ export default function getTooltipStyles(
const tooltipVerticalPadding = spacing.pv1;
const tooltipFontSize = variables.fontSizeSmall;
+ // We get wrapper width based on the tooltip's inner text width so the wrapper is just big enough to fit text and prevent white space.
+ // If the text width is less than the maximum available width, add horizontal padding.
+ // Note: tooltipTextWidth ignores the fractions (OffsetWidth) so add 1px to fit the text properly.
+ const wrapperWidth = tooltipTextWidth && tooltipTextWidth < maxWidth
+ ? tooltipTextWidth + (spacing.ph2.paddingHorizontal * 2) + 1
+ : maxWidth;
+
return {
animationStyle: {
// remember Transform causes a new Local cordinate system
@@ -109,6 +120,7 @@ export default function getTooltipStyles(
...tooltipVerticalPadding,
...spacing.ph2,
zIndex: variables.tooltipzIndex,
+ width: wrapperWidth,
// Because it uses fixed positioning, the top-left corner of the tooltip is aligned
// with the top-left corner of the window by default.
@@ -144,6 +156,8 @@ export default function getTooltipStyles(
color: themeColors.textReversed,
fontFamily: fontFamily.GTA,
fontSize: tooltipFontSize,
+ overflowWrap: 'normal',
+ overflow: 'hidden',
},
pointerWrapperStyle: {
position: 'fixed',
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 730a98495aee..4859c0470350 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -100,8 +100,8 @@ const webViewStyles = {
pre: {
...baseCodeTagStyles,
- paddingTop: 4,
- paddingBottom: 5,
+ paddingTop: 12,
+ paddingBottom: 12,
paddingRight: 8,
paddingLeft: 8,
fontFamily: fontFamily.MONOSPACE,
@@ -381,6 +381,16 @@ const styles = {
backgroundColor: themeColors.buttonDefaultBG,
},
+ buttonExtraLarge: {
+ borderRadius: variables.componentBorderRadius,
+ height: variables.componentSizeExtraLarge,
+ paddingTop: 12,
+ paddingRight: 18,
+ paddingBottom: 12,
+ paddingLeft: 18,
+ backgroundColor: themeColors.buttonDefaultBG,
+ },
+
buttonSmallText: {
fontSize: variables.fontSizeSmall,
fontFamily: fontFamily.GTA_BOLD,
@@ -402,6 +412,13 @@ const styles = {
textAlign: 'center',
},
+ buttonExtraLargeText: {
+ fontSize: variables.fontSizeMedium,
+ fontFamily: fontFamily.GTA_BOLD,
+ fontWeight: fontWeightBold,
+ textAlign: 'center',
+ },
+
buttonSuccess: {
backgroundColor: themeColors.buttonSuccessBG,
borderWidth: 0,
@@ -1025,9 +1042,9 @@ const styles = {
textDecorationLine: 'none',
},
- singleEmojiText: {
- fontSize: variables.fontSizeSingleEmoji,
- lineHeight: variables.fontSizeSingleEmojiHeight,
+ onlyEmojisText: {
+ fontSize: variables.fontSizeOnlyEmojis,
+ lineHeight: variables.fontSizeOnlyEmojisHeight,
},
createMenuPositionSidebar: {
@@ -2359,7 +2376,7 @@ const styles = {
height: 20,
},
- reportMarkerBadgeWrapper: {
+ floatingMessageCounterWrapper: {
position: 'absolute',
left: '50%',
top: 0,
@@ -2367,7 +2384,7 @@ const styles = {
...visibility('hidden'),
},
- reportMarkerBadgeWrapperAndroid: {
+ floatingMessageCounterWrapperAndroid: {
left: 0,
width: '100%',
alignItems: 'center',
@@ -2377,17 +2394,17 @@ const styles = {
...visibility('hidden'),
},
- reportMarkerBadgeSubWrapperAndroid: {
+ floatingMessageCounterSubWrapperAndroid: {
left: '50%',
width: 'auto',
},
- reportMarkerBadge: {
+ floatingMessageCounter: {
left: '-50%',
...visibility('visible'),
},
- reportMarkerBadgeTransformation: translateY => ({
+ floatingMessageCounterTransformation: translateY => ({
transform: [
{translateY},
],
@@ -2491,6 +2508,17 @@ const styles = {
closeAccountMessageInput: {
height: 153,
},
+
+ userSelectText: {
+ userSelect: 'text',
+ },
+
+ screenCenteredContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ marginBottom: 40,
+ padding: 16,
+ },
};
export default styles;
diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js
index e5ddcee0c3f0..6ba2830de7bf 100644
--- a/src/styles/utilities/spacing.js
+++ b/src/styles/utilities/spacing.js
@@ -37,6 +37,10 @@ export default {
marginHorizontal: 12,
},
+ mh4: {
+ marginHorizontal: 16,
+ },
+
mh5: {
marginHorizontal: 20,
},
diff --git a/src/styles/variables.js b/src/styles/variables.js
index 5ffd6bf29131..506755712154 100644
--- a/src/styles/variables.js
+++ b/src/styles/variables.js
@@ -4,6 +4,7 @@ export default {
componentSizeNormal: 40,
inputComponentSizeNormal: 42,
componentSizeLarge: 52,
+ componentSizeExtraLarge: 64,
componentBorderRadius: 8,
componentBorderRadiusSmall: 4,
componentBorderRadiusNormal: 8,
@@ -13,12 +14,13 @@ export default {
avatarSizeSmall: 28,
avatarSizeSubscript: 20,
avatarSizeSmallSubscript: 14,
- fontSizeSingleEmoji: 30,
- fontSizeSingleEmojiHeight: 35,
+ fontSizeOnlyEmojis: 30,
+ fontSizeOnlyEmojisHeight: 35,
fontSizeSmall: 11,
fontSizeExtraSmall: 9,
fontSizeLabel: 13,
fontSizeNormal: 15,
+ fontSizeMedium: 16,
fontSizeLarge: 17,
fontSizeHero: 36,
fontSizeh1: 19,
diff --git a/tests/actions/ReimbursementAccountTest.js b/tests/actions/ReimbursementAccountTest.js
index 10e01d5d9d8d..0c0b53c25f90 100644
--- a/tests/actions/ReimbursementAccountTest.js
+++ b/tests/actions/ReimbursementAccountTest.js
@@ -6,7 +6,6 @@ import HttpUtils from '../../src/libs/HttpUtils';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
import CONST from '../../src/CONST';
import BankAccount from '../../src/libs/models/BankAccount';
-import * as NetworkStore from '../../src/libs/Network/NetworkStore';
const TEST_BANK_ACCOUNT_ID = 1;
const TEST_BANK_ACCOUNT_CITY = 'Opa-locka';
@@ -37,7 +36,6 @@ beforeAll(() => Onyx.init());
beforeEach(() => Onyx.clear()
.then(() => {
- NetworkStore.setHasReadRequiredDataFromStorage(true);
TestHelper.signInWithTestUser();
return waitForPromisesToResolve();
}));
diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js
index 27a1c10134fe..7d1c569603ed 100644
--- a/tests/actions/ReportTest.js
+++ b/tests/actions/ReportTest.js
@@ -13,6 +13,7 @@ import * as Report from '../../src/libs/actions/Report';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
import * as TestHelper from '../utils/TestHelper';
import Log from '../../src/libs/Log';
+import * as PersistedRequests from '../../src/libs/actions/PersistedRequests';
describe('actions/Report', () => {
beforeAll(() => {
@@ -42,7 +43,7 @@ describe('actions/Report', () => {
afterEach(() => {
// Unsubscribe from account channel after each test since we subscribe in the function
// subscribeToUserEvents and we don't want duplicate event subscriptions.
- Pusher.unsubscribe('private-user-accountID-1');
+ Pusher.unsubscribe(`private-encrypted-user-accountID-1${CONFIG.PUSHER.SUFFIX}`);
});
it('should store a new report action in Onyx when reportComment event is handled via Pusher', () => {
@@ -103,7 +104,7 @@ describe('actions/Report', () => {
.then(() => {
// We subscribed to the Pusher channel above and now we need to simulate a reportComment action
// Pusher event so we can verify that action was handled correctly and merged into the reportActions.
- const channel = Pusher.getChannel('private-user-accountID-1');
+ const channel = Pusher.getChannel(`private-encrypted-user-accountID-1${CONFIG.PUSHER.SUFFIX}`);
channel.emit(Pusher.TYPE.REPORT_COMMENT, {
reportID: REPORT_ID,
reportAction: {...REPORT_ACTION, clientID},
@@ -157,7 +158,7 @@ describe('actions/Report', () => {
.then(() => {
// We subscribed to the Pusher channel above and now we need to simulate a reportTogglePinned
// Pusher event so we can verify that pinning was handled correctly and merged into the report.
- const channel = Pusher.getChannel('private-user-accountID-1');
+ const channel = Pusher.getChannel(`private-encrypted-user-accountID-1${CONFIG.PUSHER.SUFFIX}`);
channel.emit(Pusher.TYPE.REPORT_TOGGLE_PINNED, {
reportID: REPORT_ID,
isPinned: true,
@@ -191,20 +192,20 @@ describe('actions/Report', () => {
},
}))
.then(() => {
- global.fetch = jest.fn()
- .mockImplementation(() => Promise.resolve({
- json: () => Promise.resolve({
- jsonCode: 200,
- }),
- }));
-
- // WHEN we add 1 less than the max before a log packet is sent
- for (let i = 0; i < LOGGER_MAX_LOG_LINES; i++) {
+ global.fetch = TestHelper.getGlobalFetchMock();
+
+ // WHEN we add enough logs to send a packet
+ for (let i = 0; i <= LOGGER_MAX_LOG_LINES; i++) {
Log.info('Test log info');
}
- // And leave a comment on a report which will trigger the log packet to be sent in the same call
+ // And leave a comment on a report
Report.addAction(REPORT_ID, 'Testing a comment');
+
+ // Then we should expect that there is on persisted request
+ expect(PersistedRequests.getAll().length).toBe(1);
+
+ // When we wait for the queue to run
return waitForPromisesToResolve();
})
.then(() => {
diff --git a/tests/unit/NetworkTest.js b/tests/unit/NetworkTest.js
index cad4be39d573..d8eade984048 100644
--- a/tests/unit/NetworkTest.js
+++ b/tests/unit/NetworkTest.js
@@ -15,6 +15,9 @@ import * as NetworkStore from '../../src/libs/Network/NetworkStore';
import * as Session from '../../src/libs/actions/Session';
import * as PersistedRequests from '../../src/libs/actions/PersistedRequests';
import Log from '../../src/libs/Log';
+import * as SequentialQueue from '../../src/libs/Network/SequentialQueue';
+import * as MainQueue from '../../src/libs/Network/MainQueue';
+import * as Request from '../../src/libs/Request';
// Set up manual mocks for methods used in the actions so our test does not fail.
jest.mock('../../src/libs/Notification/PushNotification', () => ({
@@ -32,14 +35,20 @@ Onyx.init({
const originalXHR = HttpUtils.xhr;
beforeEach(() => {
+ global.fetch = TestHelper.getGlobalFetchMock();
HttpUtils.xhr = originalXHR;
PersistedRequests.clear();
- Network.clearRequestQueue();
- return Onyx.clear().then(waitForPromisesToResolve);
+ MainQueue.clear();
+
+ // Wait for any Log command to finish and Onyx to fully clear
+ jest.advanceTimersByTime(CONST.NETWORK.PROCESS_REQUEST_DELAY_MS);
+ return waitForPromisesToResolve()
+ .then(Onyx.clear)
+ .then(waitForPromisesToResolve);
});
afterEach(() => {
- NetworkStore.setHasReadRequiredDataFromStorage(false);
+ NetworkStore.resetHasReadRequiredDataFromStorage();
Onyx.addDelayToConnectCallback(0);
jest.clearAllMocks();
});
@@ -64,7 +73,7 @@ test('failing to reauthenticate while offline should not log out user', () => {
expect(isOffline).toBe(null);
// Mock fetch() so that it throws a TypeError to simulate a bad network connection
- global.fetch = jest.fn(() => new Promise((_resolve, reject) => reject(new TypeError('Failed to fetch'))));
+ global.fetch = jest.fn().mockRejectedValue(new TypeError(CONST.ERROR.FAILED_TO_FETCH));
const actualXhr = HttpUtils.xhr;
HttpUtils.xhr = jest.fn();
@@ -92,7 +101,7 @@ test('failing to reauthenticate while offline should not log out user', () => {
jsonCode: CONST.JSON_CODE.SUCCESS,
}));
- // This should first trigger re-authentication and then an API is offline error
+ // This should first trigger re-authentication and then a Failed to fetch
API.Get({returnValueList: 'chatList'});
return waitForPromisesToResolve()
.then(() => Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}))
@@ -232,9 +241,9 @@ test('consecutive API calls eventually succeed when authToken is expired', () =>
});
});
-test('retry network request if auth and credentials are not read from Onyx yet', () => {
- // In order to test an scenario where the auth token and credentials hasn't been read from storage we set
- // NetworkStore.setHasReadRequiredDataFromStorage(false) and set the session and credentials to "ready" the Network
+test('Request will not run until credentials are read from Onyx', () => {
+ // In order to test an scenario where the auth token and credentials hasn't been read from storage we reset hasReadRequiredDataFromStorage
+ // and set the session and credentials to "ready" the Network
// Given a test user login and account ID
const TEST_USER_LOGIN = 'test@testguy.com';
@@ -244,37 +253,24 @@ test('retry network request if auth and credentials are not read from Onyx yet',
Onyx.addDelayToConnectCallback(ONYX_DELAY_MS);
// Given initial state to Network
- NetworkStore.setHasReadRequiredDataFromStorage(false);
-
- // Given an initial value to trigger an update
- Onyx.merge(ONYXKEYS.CREDENTIALS, {login: 'test-login'});
- Onyx.merge(ONYXKEYS.SESSION, {authToken: 'test-auth-token'});
+ NetworkStore.resetHasReadRequiredDataFromStorage();
// Given some mock functions to track the isReady
// flag in Network and the http requests made
const spyHttpUtilsXhr = jest.spyOn(HttpUtils, 'xhr').mockImplementation(() => Promise.resolve({}));
- // When we make an arbitrary request that can be retried
- // And we wait for the Onyx.callbacks to be set
+ // When we make a request
Session.fetchAccountDetails(TEST_USER_LOGIN);
- return waitForPromisesToResolve().then(() => {
- // Then we expect not having the Network ready and not making an http request
- expect(NetworkStore.hasReadRequiredDataFromStorage()).toBe(false);
- expect(spyHttpUtilsXhr).not.toHaveBeenCalled();
- // When we resolve Onyx.connect callbacks
- jest.advanceTimersByTime(ONYX_DELAY_MS);
+ // Then we should expect that no requests have been made yet
+ expect(spyHttpUtilsXhr).not.toHaveBeenCalled();
- // Then we should expect call Network.setIsReady(true)
- // And We should expect not making an http request yet
- expect(NetworkStore.hasReadRequiredDataFromStorage()).toBe(true);
+ // Once credentials are set and we wait for promises to resolve
+ Onyx.merge(ONYXKEYS.CREDENTIALS, {login: 'test-login'});
+ Onyx.merge(ONYXKEYS.SESSION, {authToken: 'test-auth-token'});
+ return waitForPromisesToResolve().then(() => {
+ // Then we should expect the request to have been made since the network is now ready
expect(spyHttpUtilsXhr).not.toHaveBeenCalled();
-
- // When we run processNetworkRequestQueue in the setInterval of Network.js
- jest.advanceTimersByTime(CONST.NETWORK.PROCESS_REQUEST_DELAY_MS);
-
- // Then we should expect a retry of the network request
- expect(spyHttpUtilsXhr).toHaveBeenCalled();
});
});
@@ -347,6 +343,10 @@ test('requests should resume when we are online', () => {
Network.post('mock command', {param2: 'value2', persist: true});
return waitForPromisesToResolve();
})
+ .then(() => {
+ const persisted = PersistedRequests.getAll();
+ expect(persisted).toHaveLength(2);
+ })
// When we resume connectivity
.then(() => Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}))
@@ -466,25 +466,25 @@ test('test bad response will log alert', () => {
});
});
-test('test Failed to fetch error for requests not flagged with shouldRetry will throw API OFFLINE error', () => {
+test('test Failed to fetch error for requests not flagged with shouldRetry will resolve with an offline jsonCode', () => {
// Setup xhr handler that rejects once with a 502 Bad Gateway
- global.fetch = jest.fn(() => new Promise((_resolve, reject) => reject(new Error(CONST.ERROR.FAILED_TO_FETCH))));
+ global.fetch = jest.fn().mockRejectedValue(new Error(CONST.ERROR.FAILED_TO_FETCH));
- const onRejected = jest.fn();
+ const onResolved = jest.fn();
// Given we have a request made while online
return Onyx.set(ONYXKEYS.NETWORK, {isOffline: false})
.then(() => {
// When network calls with are made
- Network.post('mock command', {param1: 'value1', shouldRetry: false})
- .catch(onRejected);
+ Network.post('MockCommand', {param1: 'value1', shouldRetry: false})
+ .then(onResolved);
return waitForPromisesToResolve();
})
.then(() => {
- const error = onRejected.mock.calls[0][0];
- expect(onRejected).toHaveBeenCalled();
- expect(error.message).toBe(CONST.ERROR.API_OFFLINE);
+ const response = onResolved.mock.calls[0][0];
+ expect(onResolved).toHaveBeenCalled();
+ expect(response.jsonCode).toBe(CONST.JSON_CODE.UNABLE_TO_RETRY);
});
});
@@ -528,7 +528,11 @@ test('several actions made while offline will get added in the order they are cr
const xhr = jest.spyOn(HttpUtils, 'xhr')
.mockResolvedValue({jsonCode: CONST.JSON_CODE.SUCCESS});
- return Onyx.set(ONYXKEYS.NETWORK, {isOffline: true})
+ return Onyx.multiSet({
+ [ONYXKEYS.SESSION]: {authToken: 'anyToken'},
+ [ONYXKEYS.NETWORK]: {isOffline: true},
+ [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin: 'test_user', autoGeneratedPassword: 'psswd'},
+ })
.then(() => {
// When we queue 6 persistable commands
Network.post('MockCommand', {content: 'value1', persist: true});
@@ -556,6 +560,208 @@ test('several actions made while offline will get added in the order they are cr
// Move main queue forward so it processes the "read" request
jest.advanceTimersByTime(CONST.NETWORK.PROCESS_REQUEST_DELAY_MS);
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
expect(xhr.mock.calls[6][1].content).toBe('not-persisted');
});
});
+
+test('several actions made while offline will get added in the order they are created when we need to reauthenticate', () => {
+ // Given offline state where all requests will eventualy succeed without issue and assumed to be valid credentials
+ const xhr = jest.spyOn(HttpUtils, 'xhr')
+ .mockResolvedValueOnce({jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED})
+ .mockResolvedValue({jsonCode: CONST.JSON_CODE.SUCCESS});
+
+ return Onyx.multiSet({
+ [ONYXKEYS.NETWORK]: {isOffline: true},
+ [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin: 'caca', autoGeneratedPassword: 'caca'},
+ })
+ .then(() => {
+ // When we queue 6 persistable commands
+ Network.post('MockCommand', {content: 'value1', persist: true});
+ Network.post('MockCommand', {content: 'value2', persist: true});
+ Network.post('MockCommand', {content: 'value3', persist: true});
+ Network.post('MockCommand', {content: 'value4', persist: true});
+ Network.post('MockCommand', {content: 'value5', persist: true});
+ Network.post('MockCommand', {content: 'value6', persist: true});
+ return waitForPromisesToResolve();
+ })
+ .then(() => Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}))
+ .then(waitForPromisesToResolve)
+ .then(() => {
+ // Then expect only 8 calls to have been made total and for them to be made in the order that we made them despite requiring reauthentication
+ expect(xhr.mock.calls.length).toBe(8);
+ expect(xhr.mock.calls[0][1].content).toBe('value1');
+
+ // Our call to Authenticate will not have a "content" field
+ expect(xhr.mock.calls[1][1].content).not.toBeDefined();
+
+ // Rest of the calls have the expected params and are called in sequence
+ expect(xhr.mock.calls[2][1].content).toBe('value1');
+ expect(xhr.mock.calls[3][1].content).toBe('value2');
+ expect(xhr.mock.calls[4][1].content).toBe('value3');
+ expect(xhr.mock.calls[5][1].content).toBe('value4');
+ expect(xhr.mock.calls[6][1].content).toBe('value5');
+ expect(xhr.mock.calls[7][1].content).toBe('value6');
+ });
+});
+
+test('Sequential queue will succeed if triggered while reauthentication via main queue is in progress', () => {
+ // Given offline state where all requests will eventualy succeed without issue and assumed to be valid credentials
+ const xhr = jest.spyOn(HttpUtils, 'xhr')
+ .mockResolvedValueOnce({jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED})
+ .mockResolvedValueOnce({jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED})
+ .mockResolvedValue({jsonCode: CONST.JSON_CODE.SUCCESS, authToken: 'newToken'});
+
+ return Onyx.multiSet({
+ [ONYXKEYS.SESSION]: {authToken: 'oldToken'},
+ [ONYXKEYS.NETWORK]: {isOffline: false},
+ [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin: 'test_user', autoGeneratedPassword: 'psswd'},
+ })
+ .then(() => {
+ // When we queue both non-persistable and persistable commands that will trigger reauthentication and go offline at the same time
+ Network.post('Push_Authenticate', {content: 'value1'});
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+ expect(NetworkStore.isOffline()).toBe(false);
+ expect(NetworkStore.isAuthenticating()).toBe(false);
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ Network.post('MockCommand', {persist: true});
+ expect(PersistedRequests.getAll().length).toBe(1);
+ expect(NetworkStore.isOffline()).toBe(true);
+ expect(SequentialQueue.isRunning()).toBe(false);
+ expect(NetworkStore.isAuthenticating()).toBe(false);
+
+ // We should only have a single call at this point as the main queue is stopped since we've gone offline
+ expect(xhr.mock.calls.length).toBe(1);
+
+ // Come back from offline to trigger the sequential queue flush
+ return Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ })
+ .then(() => {
+ // When we wait for the sequential queue to finish
+ expect(SequentialQueue.isRunning()).toBe(true);
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // Then we should expect to see that...
+ // The sequential queue has stopped
+ expect(SequentialQueue.isRunning()).toBe(false);
+
+ // All persisted requests have run
+ expect(PersistedRequests.getAll().length).toBe(0);
+
+ // We are not offline anymore
+ expect(NetworkStore.isOffline()).toBe(false);
+
+ // First call to xhr is the Push_Authenticate request that could not call Authenticate because we went offline
+ const [firstCommand] = xhr.mock.calls[0];
+ expect(firstCommand).toBe('Push_Authenticate');
+
+ // Second call to xhr is the MockCommand that also failed with a 407
+ const [secondCommand] = xhr.mock.calls[1];
+ expect(secondCommand).toBe('MockCommand');
+
+ // Third command should be the call to Authenticate
+ const [thirdCommand] = xhr.mock.calls[2];
+ expect(thirdCommand).toBe('Authenticate');
+
+ const [fourthCommand] = xhr.mock.calls[3];
+ expect(fourthCommand).toBe('MockCommand');
+
+ // We are using the new authToken
+ expect(NetworkStore.getAuthToken()).toBe('newToken');
+
+ // We are no longer authenticating
+ expect(NetworkStore.isAuthenticating()).toBe(false);
+ });
+});
+
+test('Sequential queue will not run until credentials are read', () => {
+ const xhr = jest.spyOn(HttpUtils, 'xhr');
+ const processWithMiddleware = jest.spyOn(Request, 'processWithMiddleware');
+
+ // Given a simulated a condition where the credentials have not yet been read from storage and we are offline
+ return Onyx.multiSet({
+ [ONYXKEYS.NETWORK]: {isOffline: true},
+ [ONYXKEYS.CREDENTIALS]: null,
+ [ONYXKEYS.SESSION]: null,
+ })
+ .then(() => {
+ expect(NetworkStore.isOffline()).toBe(true);
+
+ NetworkStore.resetHasReadRequiredDataFromStorage();
+
+ // And queue a request while offline
+ Network.post('MockCommand', {content: 'value1', persist: true});
+
+ // Then we should expect the request to get persisted
+ expect(PersistedRequests.getAll().length).toBe(1);
+
+ // When we go online and wait for promises to resolve
+ return Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ })
+ .then(() => {
+ expect(processWithMiddleware).toHaveBeenCalled();
+
+ // Then we should not expect XHR to run
+ expect(xhr).not.toHaveBeenCalled();
+
+ // When we set our credentials and authToken
+ return Onyx.multiSet({
+ [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin: 'test_user', autoGeneratedPassword: 'psswd'},
+ [ONYXKEYS.SESSION]: {authToken: 'oldToken'},
+ });
+ })
+ .then(waitForPromisesToResolve)
+ .then(() => {
+ // Then we should expect XHR to run
+ expect(xhr).toHaveBeenCalled();
+ });
+});
+
+test('persistable request will move directly to the SequentialQueue when we are online and block non-persistable requests', () => {
+ const xhr = jest.spyOn(HttpUtils, 'xhr');
+ return Onyx.set(ONYXKEYS.NETWORK, {isOffline: false})
+ .then(() => {
+ // GIVEN that we are online
+ expect(NetworkStore.isOffline()).toBe(false);
+
+ // WHEN we make a request that should be retried, one that should not, and another that should
+ Network.post('MockCommandOne', {persist: true});
+ Network.post('MockCommandTwo');
+ Network.post('MockCommandThree', {persist: true});
+
+ // THEN the retryable requests should immediately be added to the persisted requests
+ expect(PersistedRequests.getAll().length).toBe(2);
+
+ // WHEN we wait for the queue to run and finish processing
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // THEN the queue should be stopped and there should be no more requests to run
+ expect(SequentialQueue.isRunning()).toBe(false);
+ expect(PersistedRequests.getAll().length).toBe(0);
+
+ // And our persistable request should run before our non persistable one in a blocking way
+ const firstRequest = xhr.mock.calls[0];
+ const [firstRequestCommandName] = firstRequest;
+ expect(firstRequestCommandName).toBe('MockCommandOne');
+
+ const secondRequest = xhr.mock.calls[1];
+ const [secondRequestCommandName] = secondRequest;
+ expect(secondRequestCommandName).toBe('MockCommandThree');
+
+ // WHEN we advance the main queue timer and wait for promises
+ jest.advanceTimersByTime(CONST.NETWORK.PROCESS_REQUEST_DELAY_MS);
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // THEN we should see that our third (non-persistable) request has run last
+ const thirdRequest = xhr.mock.calls[2];
+ const [thirdRequestCommandName] = thirdRequest;
+ expect(thirdRequestCommandName).toBe('MockCommandTwo');
+ });
+});
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 3fb6441c6602..1ea4fe9fe02e 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -100,6 +100,18 @@ describe('OptionsListUtils', () => {
iouReportID: 100,
hasOutstandingIOU: true,
},
+
+ // This report is an archived room – it does not have a name and instead falls back on oldPolicyName
+ 10: {
+ lastVisitedTimestamp: 1610666739200,
+ lastMessageTimestamp: 1,
+ reportID: 10,
+ isPinned: false,
+ participants: ['captain_britain@expensify.com', 'captain_america@expensify.com'],
+ reportName: '',
+ oldPolicyName: "SHIELD's workspace",
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ },
};
// And a set of personalDetails some with existing reports and some without
@@ -152,11 +164,11 @@ describe('OptionsListUtils', () => {
const REPORTS_WITH_CONCIERGE = {
...REPORTS,
- 10: {
+ 11: {
lastVisitedTimestamp: 1610666739302,
lastMessageTimestamp: 1,
isPinned: false,
- reportID: 10,
+ reportID: 11,
participants: ['concierge@expensify.com'],
reportName: 'Concierge',
unreadActionCount: 1,
@@ -165,11 +177,11 @@ describe('OptionsListUtils', () => {
const REPORTS_WITH_CHRONOS = {
...REPORTS,
- 11: {
+ 12: {
lastVisitedTimestamp: 1610666739302,
lastMessageTimestamp: 1,
isPinned: false,
- reportID: 10,
+ reportID: 12,
participants: ['chronos@expensify.com'],
reportName: 'Chronos',
unreadActionCount: 1,
@@ -178,11 +190,11 @@ describe('OptionsListUtils', () => {
const REPORTS_WITH_RECEIPTS = {
...REPORTS,
- 12: {
+ 13: {
lastVisitedTimestamp: 1610666739302,
lastMessageTimestamp: 1,
isPinned: false,
- reportID: 10,
+ reportID: 13,
participants: ['receipts@expensify.com'],
reportName: 'Receipts',
unreadActionCount: 1,
@@ -191,20 +203,20 @@ describe('OptionsListUtils', () => {
const REPORTS_WITH_MORE_PINS = {
...REPORTS,
- 13: {
+ 14: {
lastVisitedTimestamp: 1610666739302,
lastMessageTimestamp: 1,
isPinned: true,
- reportID: 13,
+ reportID: 14,
participants: ['d_email@email.com'],
reportName: 'D report name',
unreadActionCount: 0,
},
- 14: {
+ 15: {
lastVisitedTimestamp: 1610666732302,
lastMessageTimestamp: 1,
isPinned: true,
- reportID: 14,
+ reportID: 15,
participants: ['z_email@email.com'],
reportName: 'Z Report Name',
unreadActionCount: 0,
@@ -592,11 +604,11 @@ describe('OptionsListUtils', () => {
...REPORTS,
// Note: This report has no lastMessageTimestamp but is also pinned
- 10: {
+ 16: {
lastVisitedTimestamp: 1610666739300,
lastMessageTimestamp: 0,
isPinned: true,
- reportID: 10,
+ reportID: 16,
participants: ['captain_britain@expensify.com'],
reportName: 'Captain Britain',
},
@@ -623,16 +635,20 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails.length).toBe(0);
// And the most recent pinned report is first in the list of reports
- expect(results.recentReports[0].login).toBe('captain_britain@expensify.com');
+ let index = 0;
+ expect(results.recentReports[index].login).toBe('captain_britain@expensify.com');
// And the third report is the report with an IOU debt
- expect(results.recentReports[2].login).toBe('mistersinister@marauders.com');
+ index += 2;
+ expect(results.recentReports[index].login).toBe('mistersinister@marauders.com');
// And the fourth report is the report with a draft comment
- expect(results.recentReports[3].text).toBe('tonystark@expensify.com, reedrichards@expensify.com');
+ expect(results.recentReports[++index].text).toBe('tonystark@expensify.com, reedrichards@expensify.com');
// And the fifth report is the report with the lastMessage timestamp
- expect(results.recentReports[4].login).toBe('steverogers@expensify.com');
+ expect(results.recentReports[++index].login).toBe('steverogers@expensify.com');
+
+ expect(_.last(results.recentReports).text).toBe("SHIELD's workspace");
});
});
@@ -651,29 +667,33 @@ describe('OptionsListUtils', () => {
// Pinned reports are always on the top in alphabetical order regardless of whether they are unread or have IOU debt.
// D report name (Alphabetically first among pinned reports)
- expect(results.recentReports[0].login).toBe('d_email@email.com');
+ let index = 0;
+ expect(results.recentReports[index].login).toBe('d_email@email.com');
// Mister Fantastic report name (Alphabetically second among pinned reports)
- expect(results.recentReports[1].login).toBe('reedrichards@expensify.com');
+ expect(results.recentReports[++index].login).toBe('reedrichards@expensify.com');
// Z report name (Alphabetically third among pinned reports)
- expect(results.recentReports[2].login).toBe('z_email@email.com');
+ expect(results.recentReports[++index].login).toBe('z_email@email.com');
// Unpinned report name ordered alphabetically after pinned reports
// Black Panther report name has unread message
- expect(results.recentReports[3].login).toBe('tchalla@expensify.com');
+ expect(results.recentReports[++index].text).toBe("SHIELD's workspace");
+
+ expect(results.recentReports[++index].login).toBe('tchalla@expensify.com');
// Captain America report name has unread message
- expect(results.recentReports[4].login).toBe('steverogers@expensify.com');
+ expect(results.recentReports[++index].login).toBe('steverogers@expensify.com');
// Invisible woman report name has unread message
- expect(results.recentReports[5].login).toBe('suestorm@expensify.com');
+ expect(results.recentReports[++index].login).toBe('suestorm@expensify.com');
// Mister Sinister report name has IOU debt
- expect(results.recentReports[7].login).toBe('mistersinister@marauders.com');
+ index += 2;
+ expect(results.recentReports[index].login).toBe('mistersinister@marauders.com');
// Spider-Man report name is last report and has unread message
- expect(results.recentReports[8].login).toBe('peterparker@expensify.com');
+ expect(results.recentReports[++index].login).toBe('peterparker@expensify.com');
}));
it('getSidebarOptions() with empty policyExpenseChats and defaultRooms', () => {
diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.js
new file mode 100644
index 000000000000..e2f9fefbda52
--- /dev/null
+++ b/tests/unit/ReportUtilsTest.js
@@ -0,0 +1,240 @@
+import _ from 'underscore';
+import Onyx from 'react-native-onyx';
+import CONST from '../../src/CONST';
+import ONYXKEYS from '../../src/ONYXKEYS';
+import * as ReportUtils from '../../src/libs/ReportUtils';
+import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
+
+const REPORT_TYPE_CHAT = {
+ reportNameValuePairs: {
+ type: CONST.REPORT.TYPE.CHAT,
+ },
+};
+
+const currentUserEmail = 'bjorn@vikings.net';
+const participantsPersonalDetails = {
+ 'ragnar@vikings.net': {
+ displayName: 'Ragnar Lothbrok',
+ firstName: 'Ragnar',
+ login: 'ragnar@vikings.net',
+ },
+ 'floki@vikings.net': {
+ login: 'floki@vikings.net',
+ },
+ 'lagertha@vikings.net': {
+ displayName: 'Lagertha Lothbrok',
+ firstName: 'Lagertha',
+ login: 'lagertha@vikings.net',
+ pronouns: 'She/her',
+ },
+ '+12223334444@expensify.sms': {
+ login: '+12223334444@expensify.sms',
+ },
+};
+const policy = {
+ policyID: 1,
+ name: 'Vikings Policy',
+};
+const policies = {
+ [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
+};
+
+Onyx.init({keys: ONYXKEYS});
+
+beforeAll(() => Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail}));
+beforeEach(() => Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.DEFAULT_LOCALE).then(waitForPromisesToResolve));
+
+describe('ReportUtils', () => {
+ describe('getDisplayNamesWithTooltips', () => {
+ test('withSingleParticipantReport', () => {
+ expect(ReportUtils.getDisplayNamesWithTooltips(participantsPersonalDetails, false)).toStrictEqual([
+ {
+ displayName: 'Ragnar Lothbrok',
+ tooltip: 'ragnar@vikings.net',
+ pronouns: undefined,
+ },
+ {
+ displayName: 'floki@vikings.net',
+ tooltip: 'floki@vikings.net',
+ pronouns: undefined,
+ },
+ {
+ displayName: 'Lagertha Lothbrok',
+ tooltip: 'lagertha@vikings.net',
+ pronouns: 'She/her',
+ },
+ {
+ displayName: '2223334444',
+ tooltip: '+12223334444',
+ pronouns: undefined,
+ },
+ ]);
+ });
+
+ test('withMultiParticipantReport', () => {
+ expect(ReportUtils.getDisplayNamesWithTooltips(participantsPersonalDetails, true)).toStrictEqual([
+ {
+ displayName: 'Ragnar',
+ tooltip: 'ragnar@vikings.net',
+ pronouns: undefined,
+ },
+ {
+ displayName: 'floki@vikings.net',
+ tooltip: 'floki@vikings.net',
+ pronouns: undefined,
+ },
+ {
+ displayName: 'Lagertha',
+ tooltip: 'lagertha@vikings.net',
+ pronouns: 'She/her',
+ },
+ {
+ displayName: '2223334444',
+ tooltip: '+12223334444',
+ pronouns: undefined,
+ },
+ ]);
+ });
+ });
+
+ describe('getReportName', () => {
+ describe('1:1 DM', () => {
+ test('with displayName', () => {
+ expect(ReportUtils.getReportName({
+ ...REPORT_TYPE_CHAT,
+ participants: [currentUserEmail, 'ragnar@vikings.net'],
+ }, _.pick(participantsPersonalDetails, 'ragnar@vikings.net'))).toBe('Ragnar Lothbrok');
+ });
+
+ test('no displayName', () => {
+ expect(ReportUtils.getReportName({
+ ...REPORT_TYPE_CHAT,
+ participants: [currentUserEmail, 'floki@vikings.net'],
+ }, _.pick(participantsPersonalDetails, 'floki@vikings.net'))).toBe('floki@vikings.net');
+ });
+
+ test('SMS', () => {
+ expect(ReportUtils.getReportName({
+ ...REPORT_TYPE_CHAT,
+ participants: [currentUserEmail, '+12223334444@expensify.sms'],
+ }, _.pick(participantsPersonalDetails, '+12223334444@expensify.sms'))).toBe('2223334444');
+ });
+ });
+
+ test('Group DM', () => {
+ expect(ReportUtils.getReportName({
+ ...REPORT_TYPE_CHAT,
+ participants: [currentUserEmail, 'ragnar@vikings.net', 'floki@vikings.net', 'lagertha@vikings.net', '+12223334444@expensify.sms'],
+ }, participantsPersonalDetails)).toBe('Ragnar, floki@vikings.net, Lagertha, 2223334444');
+ });
+
+ describe('Default Policy Room', () => {
+ const baseAdminsRoom = {
+ ...REPORT_TYPE_CHAT,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
+ reportName: 'admins',
+ };
+
+ test('Active', () => {
+ expect(ReportUtils.getReportName(baseAdminsRoom)).toBe('#admins');
+ });
+
+ test('Archived', () => {
+ const archivedAdminsRoom = {
+ ...baseAdminsRoom,
+ statusNum: CONST.REPORT.STATUS.CLOSED,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ };
+
+ expect(ReportUtils.getReportName(archivedAdminsRoom)).toBe('#admins (archived)');
+
+ return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, 'es')
+ .then(() => expect(ReportUtils.getReportName(archivedAdminsRoom)).toBe('#admins (archivado)'));
+ });
+ });
+
+ describe('User-Created Policy Room', () => {
+ const baseUserCreatedRoom = {
+ ...REPORT_TYPE_CHAT,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
+ reportName: 'VikingsChat',
+ };
+
+ test('Active', () => {
+ expect(ReportUtils.getReportName(baseUserCreatedRoom)).toBe('#VikingsChat');
+ });
+
+ test('Archived', () => {
+ const archivedPolicyRoom = {
+ ...baseUserCreatedRoom,
+ statusNum: CONST.REPORT.STATUS.CLOSED,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ };
+
+ expect(ReportUtils.getReportName(archivedPolicyRoom)).toBe('#VikingsChat (archived)');
+
+ return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, 'es')
+ .then(() => expect(ReportUtils.getReportName(archivedPolicyRoom)).toBe('#VikingsChat (archivado)'));
+ });
+ });
+
+ describe('PolicyExpenseChat', () => {
+ describe('Active', () => {
+ test('as member', () => {
+ expect(ReportUtils.getReportName({
+ ...REPORT_TYPE_CHAT,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ policyID: policy.policyID,
+ isOwnPolicyExpenseChat: true,
+ }, {}, policies)).toBe(policy.name);
+ });
+
+ test('as admin', () => {
+ expect(ReportUtils.getReportName({
+ ...REPORT_TYPE_CHAT,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ policyID: policy.policyID,
+ isOwnPolicyExpenseChat: false,
+ ownerEmail: 'ragnar@vikings.net',
+ }, participantsPersonalDetails, policies)).toBe('Ragnar Lothbrok');
+ });
+ });
+
+ describe('Archived', () => {
+ const baseArchivedPolicyExpenseChat = {
+ ...REPORT_TYPE_CHAT,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ ownerEmail: 'ragnar@vikings.net',
+ policyID: policy.policyID,
+ oldPolicyName: policy.name,
+ statusNum: CONST.REPORT.STATUS.CLOSED,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ };
+
+ test('as member', () => {
+ const memberArchivedPolicyExpenseChat = {
+ ...baseArchivedPolicyExpenseChat,
+ isOwnPolicyExpenseChat: true,
+ };
+
+ expect(ReportUtils.getReportName(memberArchivedPolicyExpenseChat)).toBe('Vikings Policy (archived)');
+
+ return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, 'es')
+ .then(() => expect(ReportUtils.getReportName(memberArchivedPolicyExpenseChat)).toBe('Vikings Policy (archivado)'));
+ });
+
+ test('as admin', () => {
+ const adminArchivedPolicyExpenseChat = {
+ ...baseArchivedPolicyExpenseChat,
+ isOwnPolicyExpenseChat: false,
+ };
+
+ expect(ReportUtils.getReportName(adminArchivedPolicyExpenseChat, participantsPersonalDetails)).toBe('Ragnar Lothbrok (archived)');
+
+ return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, 'es')
+ .then(() => expect(ReportUtils.getReportName(adminArchivedPolicyExpenseChat, participantsPersonalDetails)).toBe('Ragnar Lothbrok (archivado)'));
+ });
+ });
+ });
+ });
+});
diff --git a/tests/unit/RequestTest.js b/tests/unit/RequestTest.js
new file mode 100644
index 000000000000..2efd2c448711
--- /dev/null
+++ b/tests/unit/RequestTest.js
@@ -0,0 +1,62 @@
+import * as Request from '../../src/libs/Request';
+import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
+import * as TestHelper from '../utils/TestHelper';
+
+beforeAll(() => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+});
+
+beforeEach(() => {
+ Request.clearMiddlewares();
+});
+
+test('Request.use() can register a middleware and it will run', () => {
+ const testMiddleware = jest.fn();
+ Request.use(testMiddleware);
+ const request = {
+ command: 'MockCommand',
+ data: {authToken: 'testToken', persist: true},
+ };
+
+ Request.processWithMiddleware(request, true);
+ return waitForPromisesToResolve()
+ .then(() => {
+ const [promise, returnedRequest, isFromSequentialQueue] = testMiddleware.mock.calls[0];
+ expect(testMiddleware).toHaveBeenCalled();
+ expect(returnedRequest).toEqual(request);
+ expect(isFromSequentialQueue).toBe(true);
+ expect(promise).toBeInstanceOf(Promise);
+ });
+});
+
+test('Request.use() can register two middlewares. They can pass a response to the next and throw errors', () => {
+ // Given an initial middleware that returns a promise with a resolved value
+ const testMiddleware = jest.fn().mockResolvedValue({
+ jsonCode: 404,
+ });
+
+ // And another middleware that will throw when it sees this jsonCode
+ const errorThrowingMiddleware = promise => promise.then(response => new Promise((resolve, reject) => {
+ if (response.jsonCode !== 404) {
+ return;
+ }
+
+ reject(new Error('Oops'));
+ }));
+
+ Request.use(testMiddleware);
+ Request.use(errorThrowingMiddleware);
+
+ const request = {
+ command: 'MockCommand',
+ data: {authToken: 'testToken'},
+ };
+
+ const catchHandler = jest.fn();
+ Request.processWithMiddleware(request).catch(catchHandler);
+ return waitForPromisesToResolve()
+ .then(() => {
+ expect(catchHandler).toHaveBeenCalled();
+ expect(catchHandler).toHaveBeenCalledWith(new Error('Oops'));
+ });
+});
diff --git a/tests/unit/createOnReadyTaskTest.js b/tests/unit/createOnReadyTaskTest.js
new file mode 100644
index 000000000000..e1280e4d9f1c
--- /dev/null
+++ b/tests/unit/createOnReadyTaskTest.js
@@ -0,0 +1,38 @@
+import createOnReadyTask from '../../src/libs/createOnReadyTask';
+import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
+
+test('createOnReadyTask', () => {
+ // Given a generic onReady task and a mock callback executed when we are ready
+ const readyTask = createOnReadyTask();
+ const mock = jest.fn();
+ readyTask.isReady().then(mock);
+ return waitForPromisesToResolve()
+ .then(() => {
+ expect(mock).toHaveBeenCalledTimes(0);
+
+ // When we set ready
+ readyTask.setIsReady();
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // Then we should expect mock to be called
+ expect(mock).toHaveBeenCalledTimes(1);
+
+ // When we reset the task and wait for it again
+ readyTask.reset();
+ readyTask.isReady().then(mock);
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // Then we should not expect mock to be called again
+ expect(mock).toHaveBeenCalledTimes(1);
+
+ // When we set it to ready again
+ readyTask.setIsReady();
+ return waitForPromisesToResolve();
+ })
+ .then(() => {
+ // Then we should expect the mock to get called twice
+ expect(mock).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/tests/unit/enhanceParametersTest.js b/tests/unit/enhanceParametersTest.js
index a4fc9f70a9cf..50d9fdbee2ed 100644
--- a/tests/unit/enhanceParametersTest.js
+++ b/tests/unit/enhanceParametersTest.js
@@ -19,6 +19,7 @@ test('Enhance parameters adds correct parameters for Log command with no authTok
testParameter: 'test',
api_setCookie: false,
email,
+ platform: 'ios',
referer: CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER,
});
});
@@ -37,6 +38,7 @@ test('Enhance parameters adds correct parameters for a command that requires aut
testParameter: 'test',
api_setCookie: false,
email,
+ platform: 'ios',
authToken,
referer: CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER,
});
diff --git a/tests/utils/TestHelper.js b/tests/utils/TestHelper.js
index e4efe68d1b99..9cee105e64b7 100644
--- a/tests/utils/TestHelper.js
+++ b/tests/utils/TestHelper.js
@@ -77,7 +77,29 @@ function fetchPersonalDetailsForTestUser(accountID, email, personalDetailsList)
});
}
+/**
+ * Use for situations where fetch() is required.
+ *
+ * @example
+ *
+ * beforeAll(() => {
+ * global.fetch = TestHelper.getGlobalFetchMock();
+ * });
+ *
+ * @returns {Function}
+ */
+function getGlobalFetchMock() {
+ return jest.fn()
+ .mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({
+ jsonCode: 200,
+ }),
+ });
+}
+
export {
+ getGlobalFetchMock,
signInWithTestUser,
fetchPersonalDetailsForTestUser,
};