diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index cec1d11c7456..d66a59bfbc27 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -126,16 +126,24 @@ jobs: - name: Download baseline APK uses: actions/download-artifact@e9ef242655d12993efdcda9058dee2db83a2cb9b + id: downloadBaselineAPK with: name: baseline-apk-${{ needs.buildBaseline.outputs.VERSION }} path: zip + # The downloaded artifact will be a file named "app-e2eRelease.apk" so we have to rename it + - name: Rename baseline APK + run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-baseline.apk" + - name: Download delta APK uses: actions/download-artifact@e9ef242655d12993efdcda9058dee2db83a2cb9b with: name: delta-apk-${{ needs.buildDelta.outputs.DELTA_REF }} path: zip + - name: Rename delta APK + run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-compare.apk" + - name: Copy e2e code into zip folder run: cp -r tests/e2e zip @@ -163,19 +171,19 @@ jobs: test_spec_file: tests/e2e/TestSpec.yml test_spec_type: APPIUM_NODE_TEST_SPEC remote_src: false - file_artifacts: CustomerArtifacts.zip + file_artifacts: Customer Artifacts.zip cleanup: true - name: Unzip AWS Device Farm results if: ${{ always() }} - run: unzip CustomerArtifacts.zip + run: unzip "Customer Artifacts.zip" - name: Print AWS Device Farm run results if: ${{ always() }} run: cat "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md" - name: Print AWS Device Farm verbose run results - if: ${{ always() && fromJSON(runner.debug) }} + if: ${{ always() && runner.debug != null && fromJSON(runner.debug) }} run: cat "./Host_Machine_Files/\$WORKING_DIRECTORY/debug.log" - name: Check if test failed, if so post the results and add the DeployBlocker label diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a6562bbef58c..795271cab60a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,3 +22,12 @@ jobs: - name: Lint shell scripts with ShellCheck run: npm run shellcheck + + - name: Verify there's no Prettier diff + run: | + npm run prettier -- --loglevel silent + if ! git diff --name-only --exit-code; then + # shellcheck disable=SC2016 + echo 'Error: Prettier diff detected! Please run `npm run prettier` and commit the changes.' + exit 1 + fi diff --git a/android/app/build.gradle b/android/app/build.gradle index 19abad513987..60039a852cce 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -106,8 +106,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001031401 - versionName "1.3.14-1" + versionCode 1001031500 + versionName "1.3.15-0" } splits { diff --git a/android/app/src/main/java/com/expensify/chat/MainApplication.java b/android/app/src/main/java/com/expensify/chat/MainApplication.java index b24e8eb49eb1..a4f2bc97416d 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.java +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.java @@ -37,6 +37,7 @@ protected List getPackages() { // packages.add(new MyReactNativePackage()); packages.add(new BootSplashPackage()); packages.add(new ExpensifyAppPackage()); + packages.add(new RNTextInputResetPackage()); return packages; } diff --git a/android/app/src/main/java/com/expensify/chat/RNTextInputResetModule.java b/android/app/src/main/java/com/expensify/chat/RNTextInputResetModule.java new file mode 100644 index 000000000000..37ec8f5c58f2 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/RNTextInputResetModule.java @@ -0,0 +1,46 @@ +package com.expensify.chat; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.Callback; +import com.facebook.react.uimanager.UIManagerModule; +import com.facebook.react.uimanager.UIBlock; +import com.facebook.react.uimanager.NativeViewHierarchyManager; +import android.content.Context; +import android.view.View; +import android.widget.TextView; +import android.view.inputmethod.InputMethodManager; +import android.util.Log; + +public class RNTextInputResetModule extends ReactContextBaseJavaModule { + + private final ReactApplicationContext reactContext; + + public RNTextInputResetModule(ReactApplicationContext reactContext) { + super(reactContext); + this.reactContext = reactContext; + } + + @Override + public String getName() { + return "RNTextInputReset"; + } + + // Props to https://github.com/MattFoley for this temporary hack + // https://github.com/facebook/react-native/pull/12462#issuecomment-298812731 + @ReactMethod + public void resetKeyboardInput(final int reactTagToReset) { + UIManagerModule uiManager = getReactApplicationContext().getNativeModule(UIManagerModule.class); + uiManager.addUIBlock(new UIBlock() { + @Override + public void execute(NativeViewHierarchyManager nativeViewHierarchyManager) { + InputMethodManager imm = (InputMethodManager) getReactApplicationContext().getBaseContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + View viewToReset = nativeViewHierarchyManager.resolveView(reactTagToReset); + imm.restartInput(viewToReset); + } + } + }); + } +} diff --git a/android/app/src/main/java/com/expensify/chat/RNTextInputResetPackage.java b/android/app/src/main/java/com/expensify/chat/RNTextInputResetPackage.java new file mode 100644 index 000000000000..8e5d9994fd4b --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/RNTextInputResetPackage.java @@ -0,0 +1,28 @@ +package com.expensify.chat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; +import com.facebook.react.bridge.JavaScriptModule; + +public class RNTextInputResetPackage implements ReactPackage { + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + return Arrays.asList(new RNTextInputResetModule(reactContext)); + } + + // Deprecated from RN 0.47 + public List> createJSModules() { + return Collections.emptyList(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} diff --git a/assets/images/expensify-logo--dev.svg b/assets/images/expensify-logo--dev.svg index 42a0f1d8e952..f68fafbad806 100644 --- a/assets/images/expensify-logo--dev.svg +++ b/assets/images/expensify-logo--dev.svg @@ -4,7 +4,6 @@ viewBox="0 0 162 34" style="enable-background:new 0 0 162 34;" xml:space="preserve"> @@ -16,22 +15,22 @@ - - - + + - - - - - + + - diff --git a/assets/images/expensify-logo--staging.svg b/assets/images/expensify-logo--staging.svg index 335c41a294e3..12b0b9bf6e79 100644 --- a/assets/images/expensify-logo--staging.svg +++ b/assets/images/expensify-logo--staging.svg @@ -4,7 +4,6 @@ viewBox="0 0 162 34" style="enable-background:new 0 0 162 34;" xml:space="preserve"> @@ -16,22 +15,22 @@ - - - + + - - - - - + + - diff --git a/assets/images/qrcode.svg b/assets/images/qrcode.svg index ead1d765b46a..d6a5201512cc 100644 --- a/assets/images/qrcode.svg +++ b/assets/images/qrcode.svg @@ -1,3 +1,5 @@ - - + + diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md index 9d94623f0e33..8cc9ff7dc4e4 100644 --- a/contributingGuides/STYLE.md +++ b/contributingGuides/STYLE.md @@ -4,94 +4,11 @@ For almost all of our code style rules, refer to the [Airbnb JavaScript Style Gu When writing ES6 or React code, please also refer to the [Airbnb React/JSX Style Guide](https://github.com/airbnb/javascript/tree/master/react). -There are a few things that we have customized for our tastes which will take precedence over Airbnb's guide. - -## Functions - - Always wrap the function expression for immediately-invoked function expressions (IIFE) in parens: - - ```javascript - // Bad - (function () { - console.log('Welcome to the Internet. Please follow me.'); - }()); - - // Good - (function () { - console.log('Welcome to the Internet. Please follow me.'); - })(); - ``` - -## Whitespace - - Use soft tabs set to 4 spaces. - - ```javascript - // Bad - function () { - ∙∙const name; - } - - // Bad - function () { - ∙const name; - } - - // Good - function () { - ∙∙∙∙const name; - } - ``` - - - Place 1 space before the function keyword and the opening parent for anonymous functions. This does not count for named functions. - - ```javascript - // Bad - function() { - ... - } +We use Prettier to automatically style our code. +- You can run Prettier to fix the style on all files with `npm run prettier` +- You can run Prettier in watch mode to fix the styles when they are saved with `npm run prettier-watch` - // Bad - function getValue (element) { - ... - } - - // Good - function∙() { - ... - } - - // Good - function getValue(element) { - ... - } - ``` - - - Do not add spaces inside curly braces. - - ```javascript - // Bad - const foo = { clark: 'kent' }; - - // Good - const foo = {clark: 'kent'}; - ``` - - Aligning tokens should be avoided as it rarely aids in readability and often - produces inconsistencies and larger diffs when updating the code. - - ```javascript - // Good - const foo = { - foo: 'bar', - foobar: 'foobar', - foobarbaz: 'foobarbaz', - }; - - // Bad - const foo = { - foo : 'bar', - foobar : 'foobar', - foobarbaz: 'foobarbaz', - }; - ``` +There are a few things that we have customized for our tastes which will take precedence over Airbnb's guide. ## Naming Conventions diff --git a/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md b/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md index c818ce5dcfaf..d26d85e90cd3 100644 --- a/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md +++ b/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md @@ -51,7 +51,6 @@ You do this by synchronizing Expensify and your accounting package as follows: 1. Click *Settings > Policies* 2. Navigate to the *Connections* tab 3. Select your accounting system - - If you don’t see your accounting solution in the list of integrations we support, you can review an alternative solution in the Feature Deep Dives section below. 4. Follow the prompts to connect your accounting package Check out the links below for more information on how to connect to your accounting solution: @@ -59,6 +58,7 @@ Check out the links below for more information on how to connect to your account - *[Xero](https://community.expensify.com/discussion/5282/how-to-connect-your-policy-to-xero)* - *[NetSuite](https://community.expensify.com/discussion/5212/how-to-connect-your-policy-to-netsuite-token-based-authentication)* - *[Sage Intacct](https://community.expensify.com/discussion/4777/how-to-connect-to-sage-intacct-user-based-permissions-expense-reports)* +- *[Other Accounting System](https://community.expensify.com/discussion/5271/how-to-set-up-an-indirect-accounting-integration) *“Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical.”* diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 782a20041893..31984a8b4aba 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.14 + 1.3.15 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.3.14.1 + 1.3.15.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 6d36daac9abf..3dfd9dda34d1 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.14 + 1.3.15 CFBundleSignature ???? CFBundleVersion - 1.3.14.1 + 1.3.15.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 926e790ad79d..f0d6720080e8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -516,7 +516,7 @@ PODS: - React-Core - react-native-key-command (1.0.0): - React-Core - - react-native-netinfo (9.3.9): + - react-native-netinfo (8.3.1): - React-Core - react-native-pdf (6.6.2): - React-Core @@ -1085,7 +1085,7 @@ SPEC CHECKSUMS: react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b react-native-key-command: 0b3aa7c9f5c052116413e81dce33a3b2153a6c5d - react-native-netinfo: 22c082970cbd99071a4e5aa7a612ac20d66b08f0 + react-native-netinfo: 1a6035d3b9780221d407c277ebfb5722ace00658 react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 react-native-plaid-link-sdk: 9eb0f71dad94b3bdde649c7a384cba93024af46c diff --git a/package-lock.json b/package-lock.json index 152679fbc826..8332da2a9b51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.14-1", + "version": "1.3.15-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.14-1", + "version": "1.3.15-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -23,7 +23,7 @@ "@react-native-camera-roll/camera-roll": "5.4.0", "@react-native-community/clipboard": "^1.5.1", "@react-native-community/datetimepicker": "^3.5.2", - "@react-native-community/netinfo": "^9.3.9", + "@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", @@ -40,7 +40,7 @@ "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#5f4672c84e19122fc77b4bf1ac092d4919472112", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", @@ -7086,9 +7086,8 @@ "license": "MIT" }, "node_modules/@react-native-community/netinfo": { - "version": "9.3.9", - "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-9.3.9.tgz", - "integrity": "sha512-L9f8OjX5Fwh5CdP4ygDPa6iQCJJ4tAtXiFuQp6EG4/sdSXDqOXaehAhJrZAN8P8Lztnf8YN8p836GmZuBCrY+A==", + "version": "8.3.1", + "license": "MIT", "peerDependencies": { "react-native": ">=0.59" } @@ -23492,8 +23491,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#5f4672c84e19122fc77b4bf1ac092d4919472112", - "integrity": "sha512-mi1DVlq5Z4AnDVXrLefmWUTApGe0IlICrfZtE0M18BEWpFhRVvibBLlyuxp9N8uJTv75ozg9xG72DO5Veky2sg==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", + "integrity": "sha512-0XZtJOzpH5cwaWMeW/25ZEazELbFd65Q/SPqoOyEhSWx/rarhZZNGYyQlcfHfj+c1wk1eSUEVL1nEIT9VOAUNg==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -46457,9 +46456,7 @@ "dev": true }, "@react-native-community/netinfo": { - "version": "9.3.9", - "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-9.3.9.tgz", - "integrity": "sha512-L9f8OjX5Fwh5CdP4ygDPa6iQCJJ4tAtXiFuQp6EG4/sdSXDqOXaehAhJrZAN8P8Lztnf8YN8p836GmZuBCrY+A==", + "version": "8.3.1", "requires": {} }, "@react-native-community/progress-bar-android": { @@ -57366,9 +57363,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#5f4672c84e19122fc77b4bf1ac092d4919472112", - "integrity": "sha512-mi1DVlq5Z4AnDVXrLefmWUTApGe0IlICrfZtE0M18BEWpFhRVvibBLlyuxp9N8uJTv75ozg9xG72DO5Veky2sg==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#5f4672c84e19122fc77b4bf1ac092d4919472112", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", + "integrity": "sha512-0XZtJOzpH5cwaWMeW/25ZEazELbFd65Q/SPqoOyEhSWx/rarhZZNGYyQlcfHfj+c1wk1eSUEVL1nEIT9VOAUNg==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", diff --git a/package.json b/package.json index e2b9a7595f72..ded3d982f1b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.14-1", + "version": "1.3.15-0", "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,18 +9,18 @@ "scripts": { "postinstall": "npx patch-package && cd desktop && npm install", "clean": "npx react-native clean-project-auto", - "android": "scripts/set-pusher-suffix.sh && concurrently \"npx react-native run-android --port=8083\" npm:prettier-watch", - "ios": "scripts/set-pusher-suffix.sh && concurrently \"npx react-native run-ios --port=8082\" npm:prettier-watch", - "ipad": " concurrently \"npx react-native run-ios --port=8082 --simulator=\"iPad Pro (12.9-inch) (4th generation)\"\" npm:prettier-watch", - "ipad-sm": " concurrently \"npx react-native run-ios --port=8082 --simulator=\"iPad Pro (9.7-inch)\"\" npm:prettier-watch", + "android": "scripts/set-pusher-suffix.sh && npx react-native run-android --port=8083", + "ios": "scripts/set-pusher-suffix.sh && npx react-native run-ios --port=8082", + "ipad": "concurrently \"npx react-native run-ios --port=8082 --simulator=\"iPad Pro (12.9-inch) (4th generation)\"\"", + "ipad-sm": "concurrently \"npx react-native run-ios --port=8082 --simulator=\"iPad Pro (9.7-inch)\"\"", "start": "npx react-native start", - "web": "scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server npm:prettier-watch", + "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", "build-adhoc": "webpack --config config/webpack/webpack.common.js --env envFile=.env.adhoc", - "desktop": "scripts/set-pusher-suffix.sh && concurrently \"node desktop/start.js\" npm:prettier-watch", + "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", "createDocsRoutes": "node .github/scripts/createDocsRoutes.js", @@ -58,7 +58,7 @@ "@react-native-camera-roll/camera-roll": "5.4.0", "@react-native-community/clipboard": "^1.5.1", "@react-native-community/datetimepicker": "^3.5.2", - "@react-native-community/netinfo": "^9.3.9", + "@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", @@ -75,7 +75,7 @@ "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#5f4672c84e19122fc77b4bf1ac092d4919472112", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", diff --git a/src/CONST.js b/src/CONST.js index b561cdb28b05..dce50bafec4f 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -35,7 +35,62 @@ const CONST = { API_ATTACHMENT_VALIDATIONS: { // Same as the PHP layer allows - ALLOWED_EXTENSIONS: ['webp', 'jpg', 'jpeg', 'png', 'gif', 'pdf', 'html', 'txt', 'rtf', 'doc', 'docx', 'htm', 'tiff', 'tif', 'xml', 'mp3', 'mp4', 'mov'], + /* eslint-disable-next-line max-len */ + UNALLOWED_EXTENSIONS: [ + 'ade', + 'adp', + 'apk', + 'appx', + 'appxbundle', + 'bat', + 'cab', + 'chm', + 'cmd', + 'com', + 'cpl', + 'diagcab', + 'diagcfg', + 'diagpack', + 'dll', + 'dmg', + 'ex', + 'ex_', + 'exe', + 'hta', + 'img', + 'ins', + 'iso', + 'isp', + 'jar', + 'jnlp', + 'js', + 'jse', + 'lib', + 'lnk', + 'mde', + 'msc', + 'msi', + 'msix', + 'msixbundle', + 'msp', + 'mst', + 'nsh', + 'pif', + 'ps1', + 'scr', + 'sct', + 'shb', + 'sys', + 'vb', + 'vbe', + 'vbs', + 'vhd', + 'vxd', + 'wsc', + 'wsf', + 'wsh', + 'xll', + ], // 24 megabytes in bytes, this is limit set on servers, do not update without wider internal discussion MAX_SIZE: 25165824, @@ -410,9 +465,12 @@ const CONST = { CLOSED: 'CLOSED', CREATED: 'CREATED', TASKEDITED: 'TASKEDITED', + TASKCANCELED: 'TASKCANCELED', IOU: 'IOU', RENAMED: 'RENAMED', CHRONOSOOOLIST: 'CHRONOSOOOLIST', + TASKCOMPLETED: 'TASKCOMPLETED', + TASKREOPENED: 'TASKREOPENED', POLICYCHANGELOG: { ADD_APPROVER_RULE: 'POLICYCHANGELOG_ADD_APPROVER_RULE', ADD_CATEGORY: 'POLICYCHANGELOG_ADD_CATEGORY', @@ -1074,6 +1132,8 @@ const CONST = { // Furthermore, applying markup is very resource-consuming, so let's set a slightly lower limit for that MAX_MARKUP_LENGTH: 10000, + MAX_THREAD_REPLIES_PREVIEW: 99, + FORM_CHARACTER_LIMIT: 50, LEGAL_NAMES_CHARACTER_LIMIT: 150, WORKSPACE_NAME_CHARACTER_LIMIT: 80, diff --git a/src/components/ArrowKeyFocusManager.js b/src/components/ArrowKeyFocusManager.js index 5ee9968dd97f..10dfdf3d1111 100644 --- a/src/components/ArrowKeyFocusManager.js +++ b/src/components/ArrowKeyFocusManager.js @@ -33,55 +33,16 @@ class ArrowKeyFocusManager extends Component { const arrowUpConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_UP; const arrowDownConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN; - this.unsubscribeArrowUpKey = KeyboardShortcut.subscribe( - arrowUpConfig.shortcutKey, - () => { - if (this.props.maxIndex < 0) { - return; - } - - const currentFocusedIndex = this.props.focusedIndex > 0 ? this.props.focusedIndex - 1 : this.props.maxIndex; - let newFocusedIndex = currentFocusedIndex; - - while (this.props.disabledIndexes.includes(newFocusedIndex)) { - newFocusedIndex = newFocusedIndex > 0 ? newFocusedIndex - 1 : this.props.maxIndex; - if (newFocusedIndex === currentFocusedIndex) { - // all indexes are disabled - return; // no-op - } - } - - this.props.onFocusedIndexChanged(newFocusedIndex); - }, - arrowUpConfig.descriptionKey, - arrowUpConfig.modifiers, - true, - false, - 0, - true, - [this.props.shouldExcludeTextAreaNodes && 'TEXTAREA'], - ); + this.onArrowUpKey = this.onArrowUpKey.bind(this); + this.onArrowDownKey = this.onArrowDownKey.bind(this); + + this.unsubscribeArrowUpKey = KeyboardShortcut.subscribe(arrowUpConfig.shortcutKey, this.onArrowUpKey, arrowUpConfig.descriptionKey, arrowUpConfig.modifiers, true, false, 0, true, [ + this.props.shouldExcludeTextAreaNodes && 'TEXTAREA', + ]); this.unsubscribeArrowDownKey = KeyboardShortcut.subscribe( arrowDownConfig.shortcutKey, - () => { - if (this.props.maxIndex < 0) { - return; - } - - const currentFocusedIndex = this.props.focusedIndex < this.props.maxIndex ? this.props.focusedIndex + 1 : 0; - let newFocusedIndex = currentFocusedIndex; - - while (this.props.disabledIndexes.includes(newFocusedIndex)) { - newFocusedIndex = newFocusedIndex < this.props.maxIndex ? newFocusedIndex + 1 : 0; - if (newFocusedIndex === currentFocusedIndex) { - // all indexes are disabled - return; // no-op - } - } - - this.props.onFocusedIndexChanged(newFocusedIndex); - }, + this.onArrowDownKey, arrowDownConfig.descriptionKey, arrowDownConfig.modifiers, true, @@ -92,6 +53,15 @@ class ArrowKeyFocusManager extends Component { ); } + componentDidUpdate(prevProps) { + if (prevProps.maxIndex === this.props.maxIndex) { + return; + } + if (this.props.focusedIndex > this.props.maxIndex) { + this.onArrowDownKey(); + } + } + componentWillUnmount() { if (this.unsubscribeArrowUpKey) { this.unsubscribeArrowUpKey(); @@ -102,6 +72,44 @@ class ArrowKeyFocusManager extends Component { } } + onArrowUpKey() { + if (this.props.maxIndex < 0) { + return; + } + + const currentFocusedIndex = this.props.focusedIndex > 0 ? this.props.focusedIndex - 1 : this.props.maxIndex; + let newFocusedIndex = currentFocusedIndex; + + while (this.props.disabledIndexes.includes(newFocusedIndex)) { + newFocusedIndex = newFocusedIndex > 0 ? newFocusedIndex - 1 : this.props.maxIndex; + if (newFocusedIndex === currentFocusedIndex) { + // all indexes are disabled + return; // no-op + } + } + + this.props.onFocusedIndexChanged(newFocusedIndex); + } + + onArrowDownKey() { + if (this.props.maxIndex < 0) { + return; + } + + const currentFocusedIndex = this.props.focusedIndex < this.props.maxIndex ? this.props.focusedIndex + 1 : 0; + let newFocusedIndex = currentFocusedIndex; + + while (this.props.disabledIndexes.includes(newFocusedIndex)) { + newFocusedIndex = newFocusedIndex < this.props.maxIndex ? newFocusedIndex + 1 : 0; + if (newFocusedIndex === currentFocusedIndex) { + // all indexes are disabled + return; // no-op + } + } + + this.props.onFocusedIndexChanged(newFocusedIndex); + } + render() { return this.props.children; } diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js index b9315894a4b4..380f865394ce 100644 --- a/src/components/AttachmentCarousel/index.js +++ b/src/components/AttachmentCarousel/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import {View, FlatList} from 'react-native'; +import {View, FlatList, PixelRatio} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -244,8 +244,8 @@ class AttachmentCarousel extends React.Component { renderCell(props) { // Use window width instead of layout width to address the issue in https://github.com/Expensify/App/issues/17760 // considering horizontal margin and border width in centered modal - const modalStyles = styles.centeredModalStyles(this.props.isSmallScreenWidth); - const style = [props.style, styles.h100, {width: this.props.windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2 + 1}]; + const modalStyles = styles.centeredModalStyles(this.props.isSmallScreenWidth, true); + const style = [props.style, styles.h100, {width: PixelRatio.roundToNearestPixel(this.props.windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2)}]; return ( this.setState({containerWidth: nativeEvent.layout.width + 1})} + onLayout={({nativeEvent}) => this.setState({containerWidth: PixelRatio.roundToNearestPixel(nativeEvent.layout.width)})} onMouseEnter={() => !this.canUseTouchScreen && this.toggleArrowsVisibility(true)} onMouseLeave={() => !this.canUseTouchScreen && this.toggleArrowsVisibility(false)} > diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 83be46c352ad..ea5f5baa1558 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -167,8 +167,8 @@ class AttachmentModal extends PureComponent { */ isValidFile(file) { const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); - if (!_.contains(CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_EXTENSIONS, fileExtension.toLowerCase())) { - const invalidReason = `${this.props.translate('attachmentPicker.notAllowedExtension')} ${CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_EXTENSIONS.join(', ')}`; + if (_.contains(CONST.API_ATTACHMENT_VALIDATIONS.UNALLOWED_EXTENSIONS, fileExtension.toLowerCase())) { + const invalidReason = this.props.translate('attachmentPicker.notAllowedExtension'); this.setState({ isAttachmentInvalid: true, attachmentInvalidReasonTitle: this.props.translate('attachmentPicker.wrongFileType'), @@ -289,6 +289,7 @@ class AttachmentModal extends PureComponent { Boolean(this.state.source) && this.state.shouldLoadAttachment && ( {}, onToggleKeyboard: () => {}, + containerStyles: [], }; const AttachmentView = (props) => { @@ -124,7 +129,7 @@ const AttachmentView = (props) => { } return ( - + diff --git a/src/components/Avatar.js b/src/components/Avatar.js index 9bdd0049aef3..b2d90bddee17 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -21,6 +21,10 @@ const propTypes = { // eslint-disable-next-line react/forbid-prop-types imageStyles: PropTypes.arrayOf(PropTypes.object), + /** Additional styles to pass to Icon */ + // eslint-disable-next-line react/forbid-prop-types + iconAdditionalStyles: PropTypes.arrayOf(PropTypes.object), + /** Extra styles to pass to View wrapper */ containerStyles: stylePropTypes, @@ -48,6 +52,7 @@ const propTypes = { const defaultProps = { source: null, imageStyles: [], + iconAdditionalStyles: [], containerStyles: [], size: CONST.AVATAR_SIZE.DEFAULT, fill: themeColors.icon, @@ -68,9 +73,9 @@ function Avatar(props) { const isWorkspace = props.type === CONST.ICON_TYPE_WORKSPACE; const iconSize = StyleUtils.getAvatarSize(props.size); - const imageStyle = [StyleUtils.getAvatarStyle(props.size), ...props.imageStyles, StyleUtils.getAvatarBorderRadius(props.size, props.type)]; + const imageStyle = props.imageStyles ? [StyleUtils.getAvatarStyle(props.size), ...props.imageStyles, StyleUtils.getAvatarBorderRadius(props.size, props.type)] : undefined; - const iconStyle = [StyleUtils.getAvatarStyle(props.size), styles.bgTransparent, ...props.imageStyles]; + const iconStyle = props.imageStyles ? [StyleUtils.getAvatarStyle(props.size), styles.bgTransparent, ...props.imageStyles] : undefined; const iconFillColor = isWorkspace ? StyleUtils.getDefaultWorkspaceAvatarColor(props.name).fill : props.fill; const fallbackAvatar = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatar(props.name) : props.fallbackIcon; @@ -91,6 +96,7 @@ function Avatar(props) { StyleUtils.getAvatarBorderStyle(props.size, props.type), isWorkspace ? StyleUtils.getDefaultWorkspaceAvatarColor(props.name) : {}, imageError ? StyleUtils.getBackgroundColorStyle(themeColors.fallbackIconColor) : {}, + ...props.iconAdditionalStyles, ]} /> diff --git a/src/components/AvatarWithDisplayName.js b/src/components/AvatarWithDisplayName.js index 3be660ab09c5..df6dc10b2cf8 100644 --- a/src/components/AvatarWithDisplayName.js +++ b/src/components/AvatarWithDisplayName.js @@ -8,6 +8,7 @@ import participantPropTypes from './participantPropTypes'; import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import styles from '../styles/styles'; +import themeColors from '../styles/themes/default'; import SubscriptAvatar from './SubscriptAvatar'; import * as ReportUtils from '../libs/ReportUtils'; import Avatar from './Avatar'; @@ -45,7 +46,7 @@ const defaultProps = { const AvatarWithDisplayName = (props) => { const title = ReportUtils.getDisplayNameForParticipant(props.report.ownerEmail, true); - const subtitle = ReportUtils.getChatRoomSubtitle(props.report, props.policies); + const subtitle = ReportUtils.getChatRoomSubtitle(props.report); const isExpenseReport = ReportUtils.isExpenseReport(props.report); const icons = ReportUtils.getIcons(props.report, props.personalDetails, props.policies); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForLogins([props.report.ownerEmail], props.personalDetails); @@ -53,9 +54,10 @@ const AvatarWithDisplayName = (props) => { return ( {Boolean(props.report && title) && ( - + {isExpenseReport ? ( { onPress={props.onPress} > {props.text} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index 94023227867f..82a85c4cd622 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -227,7 +227,8 @@ class EmojiPickerMenu extends Component { * @param {Object} emojiObject */ addToFrequentAndSelectEmoji(emoji, emojiObject) { - EmojiUtils.addToFrequentlyUsedEmojis(emojiObject); + const frequentEmojiList = EmojiUtils.getFrequentlyUsedEmojis(emojiObject); + User.updateFrequentlyUsedEmojis(frequentEmojiList); this.props.onEmojiSelected(emoji, emojiObject); } diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js index ed8592fadc8f..caace14505ef 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js @@ -70,7 +70,8 @@ class EmojiPickerMenu extends Component { * @param {Object} emojiObject */ addToFrequentAndSelectEmoji(emoji, emojiObject) { - EmojiUtils.addToFrequentlyUsedEmojis(emojiObject); + const frequentEmojiList = EmojiUtils.getFrequentlyUsedEmojis(emojiObject); + User.updateFrequentlyUsedEmojis(frequentEmojiList); this.props.onEmojiSelected(emoji, emojiObject); } diff --git a/src/components/EnvironmentBadge.js b/src/components/EnvironmentBadge.js index 9e85b3ef2d97..73d97e8060cb 100644 --- a/src/components/EnvironmentBadge.js +++ b/src/components/EnvironmentBadge.js @@ -26,7 +26,8 @@ const EnvironmentBadge = (props) => { success={props.environment === CONST.ENVIRONMENT.STAGING || props.environment === CONST.ENVIRONMENT.ADHOC} error={props.environment !== CONST.ENVIRONMENT.STAGING && props.environment !== CONST.ENVIRONMENT.ADHOC} text={text} - badgeStyles={[styles.alignSelfCenter]} + badgeStyles={[styles.alignSelfEnd, styles.headerEnvBadge]} + textStyles={[styles.headerEnvBadgeText]} environment={props.environment} /> ); diff --git a/src/components/Header.js b/src/components/Header.js index 3944a0f3a973..1605231994ba 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -8,7 +8,7 @@ import EnvironmentBadge from './EnvironmentBadge'; const propTypes = { /** Title of the Header */ - title: PropTypes.string.isRequired, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), /** Subtitle of the header */ subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), @@ -22,19 +22,24 @@ const propTypes = { }; const defaultProps = { - shouldShowEnvironmentBadge: false, + title: '', subtitle: '', textStyles: [], + shouldShowEnvironmentBadge: false, }; const Header = (props) => ( - - {props.title} - + {_.isString(props.title) + ? Boolean(props.title) && ( + + {props.title} + + ) + : props.title} {/* If there's no subtitle then display a fragment to avoid an empty space which moves the main title */} {_.isString(props.subtitle) ? Boolean(props.subtitle) && ( diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index b33e686001db..2d4a31fde889 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -8,6 +8,7 @@ import CONST from '../CONST'; import Text from './Text'; import TextInput from './TextInput'; import FormHelpMessage from './FormHelpMessage'; +import * as Browser from '../libs/Browser'; const propTypes = { /** Name attribute for the input */ @@ -38,6 +39,9 @@ const propTypes = { /** Function to call when the input is submitted or fully complete */ onFulfill: PropTypes.func, + + /** Specifies if the input has a validation error */ + hasError: PropTypes.bool, }; const defaultProps = { @@ -50,6 +54,7 @@ const defaultProps = { innerRef: null, onChangeText: () => {}, onFulfill: () => {}, + hasError: false, }; /** @@ -255,6 +260,11 @@ function MagicCodeInput(props) { } }; + // We need to check the browser because, in iOS Safari, an input in a container with its opacity set to + // 0 (completely transparent) cannot handle user interaction, hence the Paste option is never shown. + // Alternate styling will be applied based on this condition. + const isMobileSafari = Browser.isMobileSafari(); + return ( <> @@ -263,10 +273,10 @@ function MagicCodeInput(props) { key={index} style={[styles.w15]} > - + {decomposeString(props.value)[index] || ''} - + (inputRefs.current[index] = ref)} autoFocus={index === 0 && props.autoFocus} @@ -291,6 +301,8 @@ function MagicCodeInput(props) { onKeyPress={onKeyPress} onPress={(event) => onPress(event, index)} onFocus={onFocus} + caretHidden={isMobileSafari} + inputStyle={[isMobileSafari ? styles.magicCodeInputTransparent : undefined]} /> diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js index 7dff0fbd5330..51325f619f1a 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.js @@ -35,6 +35,8 @@ class BaseModal extends PureComponent { return; } + Modal.willAlertModalBecomeVisible(true); + // To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu Modal.setCloseModal(this.props.onClose); } @@ -52,6 +54,10 @@ class BaseModal extends PureComponent { // we don't want to call the onModalHide on unmount this.hideModal(this.props.isVisible); + if (this.props.isVisible) { + Modal.willAlertModalBecomeVisible(false); + } + // To prevent closing any modal already unmounted when this modal still remains as visible state Modal.setCloseModal(null); } diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 586d98934291..916ed12219b3 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.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 styles from '../styles/styles'; import * as OptionsListUtils from '../libs/OptionsListUtils'; @@ -105,13 +104,12 @@ class MoneyRequestConfirmationList extends Component { * @returns {Array} */ getSplitOrRequestOptions() { + const text = this.props.translate(this.props.hasMultipleParticipants ? 'iou.splitAmount' : 'iou.requestAmount', { + amount: CurrencyUtils.convertToDisplayString(this.props.iouAmount, this.props.iou.selectedCurrencyCode), + }); return [ { - text: Str.recapitalize( - this.props.translate(this.props.hasMultipleParticipants ? 'iou.splitAmount' : 'iou.requestAmount', { - amount: CurrencyUtils.convertToDisplayString(this.props.iouAmount, this.props.iou.selectedCurrencyCode), - }), - ), + text: text[0].toUpperCase() + text.slice(1), value: this.props.hasMultipleParticipants ? CONST.IOU.MONEY_REQUEST_TYPE.SPLIT : CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, }, ]; diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index 91f9e782930d..ac7908677d68 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -71,17 +71,17 @@ const MoneyRequestHeader = (props) => { shouldShowBackButton={props.isSmallScreenWidth} onBackButtonPress={() => Navigation.navigate(ROUTES.HOME)} /> - + {props.translate('common.to')} - + - + { @@ -80,7 +84,7 @@ const MultipleAvatars = (props) => { } const oneAvatarSize = StyleUtils.getAvatarStyle(props.size); - const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(props.size); + const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(props.size).borderWidth; const overlapSize = oneAvatarSize.width / 3; if (props.shouldStackHorizontally) { @@ -108,7 +112,7 @@ const MultipleAvatars = (props) => { style={[ styles.justifyContentCenter, styles.alignItemsCenter, - StyleUtils.getHorizontalStackedAvatarBorderStyle(props.isHovered, props.isPressed), + StyleUtils.getHorizontalStackedAvatarBorderStyle(props.isHovered, props.isPressed, props.isInReportAction), StyleUtils.getHorizontalStackedAvatarStyle(index, overlapSize, oneAvatarBorderWidth, oneAvatarSize.width), icon.type === CONST.ICON_TYPE_WORKSPACE ? StyleUtils.getAvatarBorderRadius(props.size, icon.type) : {}, ]} @@ -127,7 +131,7 @@ const MultipleAvatars = (props) => { style={[ styles.alignItemsCenter, styles.justifyContentCenter, - StyleUtils.getHorizontalStackedAvatarBorderStyle(props.isHovered, props.isPressed), + StyleUtils.getHorizontalStackedAvatarBorderStyle(props.isHovered, props.isPressed, props.isInReportAction), // Set overlay background color with RGBA value so that the text will not inherit opacity StyleUtils.getBackgroundColorWithOpacityStyle(themeColors.overlay, variables.overlayOpacity), diff --git a/src/components/OpacityView.js b/src/components/OpacityView.js index 1592befdf127..241d83973cf8 100644 --- a/src/components/OpacityView.js +++ b/src/components/OpacityView.js @@ -21,7 +21,7 @@ const propTypes = { * @default [] */ // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.arrayOf(PropTypes.object), + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), /** * The value to use for the opacity when the view is dimmed diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 225ef6054e64..2c4cc0d9e176 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -160,7 +160,7 @@ class OptionRow extends Component { result = Promise.resolve(); } InteractionManager.runAfterInteractions(() => { - result.then(() => this.setState({isDisabled: this.props.isDisabled})); + result.finally(() => this.setState({isDisabled: this.props.isDisabled})); }); }} disabled={this.state.isDisabled} @@ -208,7 +208,7 @@ class OptionRow extends Component { tooltipEnabled={this.props.showTitleTooltip} numberOfLines={1} textStyles={displayNameStyle} - shouldUseFullTitle={this.props.option.isChatRoom || this.props.option.isPolicyExpenseChat} + shouldUseFullTitle={this.props.option.isChatRoom || this.props.option.isPolicyExpenseChat || this.props.option.isMoneyRequestReport} /> {this.props.option.alternateText ? ( { const numItems = Math.ceil(event.nativeEvent.layout.height / CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT); this.generateSkeletonViewItems(numItems); diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 21af8d1762db..f2ae4ee96718 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -47,6 +47,7 @@ class BaseOptionsSelector extends Component { this.state = { allOptions, focusedIndex, + shouldDisableRowSelection: false, }; } @@ -59,8 +60,16 @@ class BaseOptionsSelector extends Component { if (!focusedOption) { return; } - - this.selectRow(focusedOption); + if (this.props.canSelectMultipleOptions) { + this.selectRow(focusedOption); + } else if (!this.state.shouldDisableRowSelection) { + this.setState({shouldDisableRowSelection: true}); + let result = this.selectRow(focusedOption); + if (!(result instanceof Promise)) { + result = Promise.resolve(); + } + setTimeout(() => result.finally(() => this.setState({shouldDisableRowSelection: false})), 500); + } }, enterConfig.descriptionKey, enterConfig.modifiers, diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js index 77886ff512f2..85821b8d8ca3 100644 --- a/src/components/PDFView/index.js +++ b/src/components/PDFView/index.js @@ -108,8 +108,7 @@ class PDFView extends Component { render() { const pdfContainerWidth = this.state.windowWidth - 100; const pageWidthOnLargeScreen = pdfContainerWidth <= variables.pdfPageMaxWidth ? pdfContainerWidth : variables.pdfPageMaxWidth; - const pageWidth = this.props.isSmallScreenWidth ? this.state.windowWidth - 30 : pageWidthOnLargeScreen; - + const pageWidth = this.props.isSmallScreenWidth ? this.state.windowWidth : pageWidthOnLargeScreen; const outerContainerStyle = [styles.w100, styles.h100, styles.justifyContentCenter, styles.alignItemsCenter]; // If we're requesting a password then we need to hide - but still render - diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js index 50c933823e63..6b9fa8654a29 100644 --- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, forwardRef} from 'react'; +import React, {useCallback, useEffect, useState, useMemo, forwardRef} from 'react'; import {Pressable} from 'react-native'; import _ from 'underscore'; import Accessibility from '../../../libs/Accessibility'; @@ -63,6 +63,8 @@ const GenericPressable = forwardRef((props, ref) => { return props.disabled || shouldBeDisabledByScreenReader; }, [isScreenReaderActive, enableInScreenReaderStates, props.disabled]); + const [shouldUseDisabledCursor, setShouldUseDisabledCursor] = useState(isDisabled); + const onLongPressHandler = useCallback( (event) => { if (isDisabled) { @@ -112,6 +114,14 @@ const GenericPressable = forwardRef((props, ref) => { [onPressHandler], ); + useEffect(() => { + if (isDisabled) { + const timer = setTimeout(() => setShouldUseDisabledCursor(true), 1000); + return () => clearTimeout(timer); + } + setShouldUseDisabledCursor(false); + }, [isDisabled]); + useEffect(() => { if (!keyboardShortcut) { return () => {}; @@ -131,7 +141,7 @@ const GenericPressable = forwardRef((props, ref) => { onPressIn={!isDisabled ? onPressIn : undefined} onPressOut={!isDisabled ? onPressOut : undefined} style={(state) => [ - getCursorStyle(isDisabled, [props.accessibilityRole, props.role].includes('text')), + getCursorStyle(shouldUseDisabledCursor, [props.accessibilityRole, props.role].includes('text')), StyleUtils.parseStyleFromFunction(props.style, state), isScreenReaderActive && StyleUtils.parseStyleFromFunction(props.screenReaderActiveStyle, state), state.focused && StyleUtils.parseStyleFromFunction(props.focusStyle, state), diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js index d2311654d87d..0f4c07c63f64 100644 --- a/src/components/Pressable/PressableWithFeedback.js +++ b/src/components/Pressable/PressableWithFeedback.js @@ -46,9 +46,10 @@ const PressableWithFeedback = forwardRef((props, ref) => { setDisabled(props.disabled); return; } - onPress.then(() => { - setDisabled(props.disabled); - }); + onPress + .finally(() => { + setDisabled(props.disabled); + }); }); }} > diff --git a/src/components/QRShare/QRShareWithDownload/index.js b/src/components/QRShare/QRShareWithDownload/index.js index 8cb7be79f7ad..310122b96d40 100644 --- a/src/components/QRShare/QRShareWithDownload/index.js +++ b/src/components/QRShare/QRShareWithDownload/index.js @@ -1,6 +1,6 @@ import React, {Component} from 'react'; import fileDownload from '../../../libs/fileDownload'; -import QRShare from '..' +import QRShare from '..'; import {qrShareDefaultProps, qrSharePropTypes} from '../propTypes'; import getQrCodeFileName from '../getQrCodeDownloadFileName'; diff --git a/src/components/QRShare/QRShareWithDownload/index.native.js b/src/components/QRShare/QRShareWithDownload/index.native.js index 27f05038733a..6154b8137bf3 100644 --- a/src/components/QRShare/QRShareWithDownload/index.native.js +++ b/src/components/QRShare/QRShareWithDownload/index.native.js @@ -1,11 +1,10 @@ import React, {Component} from 'react'; import ViewShot from 'react-native-view-shot'; import fileDownload from '../../../libs/fileDownload'; -import QRShare from '..' +import QRShare from '..'; import {qrShareDefaultProps, qrSharePropTypes} from '../propTypes'; import getQrCodeFileName from '../getQrCodeDownloadFileName'; - class QRShareWithDownload extends Component { qrCodeScreenshotRef = React.createRef(); diff --git a/src/components/QRShare/index.js b/src/components/QRShare/index.js index 014cddcf5090..66ffaea692f3 100644 --- a/src/components/QRShare/index.js +++ b/src/components/QRShare/index.js @@ -1,5 +1,5 @@ import React, {Component} from 'react'; -import QRCodeLibrary from 'react-native-qrcode-svg'; +import QrCode from 'react-native-qrcode-svg'; import {View} from 'react-native'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import defaultTheme from '../../styles/themes/default'; @@ -9,7 +9,7 @@ import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDime import compose from '../../libs/compose'; import variables from '../../styles/variables'; import ExpensifyWordmark from '../../../assets/images/expensify-wordmark.svg'; -import {qrSharePropTypes, qrShareDefaultProps} from './propTypes' +import {qrSharePropTypes, qrShareDefaultProps} from './propTypes'; const propTypes = { ...qrSharePropTypes, @@ -22,7 +22,7 @@ class QRShare extends Component { super(props); this.state = { - qrCodeSize: 0, + qrCodeSize: 1, }; this.onLayout = this.onLayout.bind(this); @@ -30,8 +30,10 @@ class QRShare extends Component { } onLayout(event) { + const containerWidth = event.nativeEvent.layout.width - variables.qrShareHorizontalPadding * 2 || 0; + this.setState({ - qrCodeSize: event.nativeEvent.layout.width - variables.qrShareHorizontalPadding * 2, + qrCodeSize: Math.max(1, containerWidth), }); } @@ -59,7 +61,7 @@ class QRShare extends Component { /> - (this.svg = svg)} @@ -74,6 +76,7 @@ class QRShare extends Component { {this.props.title} @@ -83,7 +86,9 @@ class QRShare extends Component { {this.props.subtitle} diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.js index 45d6f4b90230..e60712d45c6f 100644 --- a/src/components/Reactions/AddReactionBubble.js +++ b/src/components/Reactions/AddReactionBubble.js @@ -72,7 +72,7 @@ const AddReactionBubble = (props) => { > [styles.emojiReactionBubble, StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, false, props.isContextMenu)]} + style={({hovered, pressed}) => [styles.emojiReactionBubble, styles.userSelectNone, StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, false, props.isContextMenu)]} onPress={onPress} // Prevent text input blur when Add reaction is clicked onMouseDown={(e) => e.preventDefault()} diff --git a/src/components/ReportActionItem/IOUPreview.js b/src/components/ReportActionItem/IOUPreview.js index 3fab478bb42d..0f538b74326a 100644 --- a/src/components/ReportActionItem/IOUPreview.js +++ b/src/components/ReportActionItem/IOUPreview.js @@ -134,19 +134,21 @@ const IOUPreview = (props) => { const sessionEmail = lodashGet(props.session, 'email', null); const managerEmail = props.iouReport.managerEmail || ''; const ownerEmail = props.iouReport.ownerEmail || ''; - const comment = Str.htmlDecode(lodashGet(props.action, 'originalMessage.comment', '')).trim(); // When displaying within a IOUDetailsModal we cannot guarantee that participants are included in the originalMessage data // Because an IOUPreview of type split can never be rendered within the IOUDetailsModal, manually building the email array is only needed for non-billSplit ious - const participantEmails = props.isBillSplit ? props.action.originalMessage.participants : [managerEmail, ownerEmail]; + const participantEmails = props.isBillSplit ? lodashGet(props.action, 'originalMessage.participants', []) : [managerEmail, ownerEmail]; const participantAvatars = OptionsListUtils.getAvatarsForLogins(participantEmails, props.personalDetails); // Pay button should only be visible to the manager of the report. const isCurrentUserManager = managerEmail === sessionEmail; + const moneyRequestAction = ReportUtils.getMoneyRequestAction(props.action); + // If props.action is undefined then we are displaying within IOUDetailsModal and should use the full report amount - const requestAmount = props.isIOUAction ? lodashGet(props.action, 'originalMessage.amount', 0) : ReportUtils.getMoneyRequestTotal(props.iouReport); - const requestCurrency = props.isIOUAction ? lodashGet(props.action, 'originalMessage.currency', CONST.CURRENCY.USD) : props.iouReport.currency; + const requestAmount = props.isIOUAction ? moneyRequestAction.total : ReportUtils.getMoneyRequestTotal(props.iouReport); + const requestCurrency = props.isIOUAction ? moneyRequestAction.currency : props.iouReport.currency; + const requestComment = Str.htmlDecode(moneyRequestAction.comment).trim(); const getSettledMessage = () => { switch (lodashGet(props.action, 'originalMessage.paymentType', '')) { @@ -228,7 +230,7 @@ const IOUPreview = (props) => { {!isCurrentUserManager && props.shouldShowPendingConversionMessage && ( {props.translate('iou.pendingConversionMessage')} )} - {!_.isEmpty(comment) && {comment}} + {!_.isEmpty(requestComment) && {requestComment}} {props.isBillSplit && !_.isEmpty(participantEmails) && ( diff --git a/src/components/ReportActionItem/MoneyRequestAction.js b/src/components/ReportActionItem/MoneyRequestAction.js index 7efc36baf8ae..b6f9cd88d5bb 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.js +++ b/src/components/ReportActionItem/MoneyRequestAction.js @@ -16,6 +16,11 @@ import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; import styles from '../../styles/styles'; import * as IOUUtils from '../../libs/IOUUtils'; +import * as OptionsListUtils from '../../libs/OptionsListUtils'; +import * as ReportUtils from '../../libs/ReportUtils'; +import * as Report from '../../libs/actions/Report'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import * as CurrencyUtils from '../../libs/CurrencyUtils'; const propTypes = { /** All the data of the action */ @@ -56,6 +61,14 @@ const propTypes = { isHovered: PropTypes.bool, network: networkPropTypes.isRequired, + + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user email */ + email: PropTypes.string, + }), + + ...withLocalizePropTypes, }; const defaultProps = { @@ -67,15 +80,46 @@ const defaultProps = { iouReport: {}, reportActions: {}, isHovered: false, + session: { + email: null, + }, }; const MoneyRequestAction = (props) => { const hasMultipleParticipants = lodashGet(props.chatReport, 'participants', []).length > 1; const onIOUPreviewPressed = () => { - if (hasMultipleParticipants) { + if (lodashGet(props.action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT && hasMultipleParticipants) { Navigation.navigate(ROUTES.getReportParticipantsRoute(props.chatReportID)); + return; + } + + // If the childReportID is not present, we need to create a new thread + const childReportID = lodashGet(props.action, 'childReportID', '0'); + if (childReportID === '0') { + const participants = _.uniq([props.session.email, props.action.actorEmail]); + const formattedUserLogins = _.map(participants, (login) => OptionsListUtils.addSMSDomainIfPhoneNumber(login).toLowerCase()); + const thread = ReportUtils.buildOptimisticChatReport( + formattedUserLogins, + props.translate('iou.threadReportName', { + formattedAmount: CurrencyUtils.convertToDisplayString(lodashGet(props.action, 'originalMessage.amount', 0), lodashGet(props.action, 'originalMessage.currency', '')), + comment: props.action.originalMessage.comment, + }), + '', + CONST.POLICY.OWNER_EMAIL_FAKE, + CONST.POLICY.OWNER_EMAIL_FAKE, + false, + '', + undefined, + CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + props.action.reportActionID, + props.requestReportID, + ); + + Report.openReport(thread.reportID, thread.participants, thread, props.action.reportActionID); + Navigation.navigate(ROUTES.getReportRoute(thread.reportID)); } else { - Navigation.navigate(ROUTES.getIouDetailsRoute(props.chatReportID, props.action.originalMessage.IOUReportID)); + Report.openReport(childReportID); + Navigation.navigate(ROUTES.getReportRoute(childReportID)); } }; @@ -125,6 +169,7 @@ MoneyRequestAction.defaultProps = defaultProps; MoneyRequestAction.displayName = 'MoneyRequestAction'; export default compose( + withLocalize, withOnyx({ chatReport: { key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, @@ -136,6 +181,9 @@ export default compose( key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, canEvict: false, }, + session: { + key: ONYXKEYS.SESSION, + }, }), withNetwork(), )(MoneyRequestAction); diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 24f209ff20c4..add769e2cc50 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -36,6 +36,15 @@ const propTypes = { // eslint-disable-next-line react/no-unused-prop-types iouReportID: PropTypes.string.isRequired, + /** chatReport associated with iouReport */ + chatReport: PropTypes.shape({ + /** The participants of this report */ + participants: PropTypes.arrayOf(PropTypes.string), + + /** Whether the chat report has an outstanding IOU */ + hasOutstandingIOU: PropTypes.bool.isRequired, + }), + /** Active IOU Report for current report */ iouReport: PropTypes.shape({ /** Email address of the manager in this iou report */ @@ -78,6 +87,7 @@ const propTypes = { const defaultProps = { contextMenuAnchor: null, isHovered: false, + chatReport: {}, iouReport: {}, onViewDetailsPressed: () => {}, checkIfContextMenuActive: () => {}, @@ -89,7 +99,7 @@ const defaultProps = { const ReportPreview = (props) => { const reportAmount = CurrencyUtils.convertToDisplayString(ReportUtils.getMoneyRequestTotal(props.iouReport), props.iouReport.currency); const managerEmail = props.iouReport.managerEmail || ''; - const managerName = ReportUtils.getDisplayNameForParticipant(managerEmail, true); + const managerName = ReportUtils.isPolicyExpenseChat(props.chatReport) ? ReportUtils.getPolicyName(props.chatReport) : ReportUtils.getDisplayNameForParticipant(managerEmail, true); const isCurrentUserManager = managerEmail === lodashGet(props.session, 'email', null); return ( @@ -103,7 +113,7 @@ const ReportPreview = (props) => { style={[styles.flexRow, styles.justifyContentBetween]} focusable > - + {props.iouReport.hasOutstandingIOU ? ( {props.translate('iou.payerOwesAmount', {payer: managerName, amount: reportAmount})} ) : ( @@ -149,6 +159,9 @@ ReportPreview.displayName = 'ReportPreview'; export default compose( withLocalize, withOnyx({ + chatReport: { + key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, + }, iouReport: { key: ({iouReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, }, diff --git a/src/components/ReportActionItem/TaskAction.js b/src/components/ReportActionItem/TaskAction.js new file mode 100644 index 000000000000..1406c78956f7 --- /dev/null +++ b/src/components/ReportActionItem/TaskAction.js @@ -0,0 +1,95 @@ +import React from 'react'; +import {View, Pressable} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import Navigation from '../../libs/Navigation/Navigation'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import ROUTES from '../../ROUTES'; +import compose from '../../libs/compose'; +import ONYXKEYS from '../../ONYXKEYS'; +import Text from '../Text'; +import styles from '../../styles/styles'; +import Icon from '../Icon'; +import * as Expensicons from '../Icon/Expensicons'; +import * as StyleUtils from '../../styles/StyleUtils'; +import getButtonState from '../../libs/getButtonState'; +import CONST from '../../CONST'; + +const propTypes = { + /** The ID of the associated taskReport */ + taskReportID: PropTypes.string.isRequired, + + /** Whether the task preview is hovered so we can modify its style */ + isHovered: PropTypes.bool, + + /** Name of the reportAction action */ + actionName: PropTypes.string.isRequired, + + /* Onyx Props */ + + taskReport: PropTypes.shape({ + /** Title of the task */ + reportName: PropTypes.string, + + /** Email address of the manager in this iou report */ + managerEmail: PropTypes.string, + + /** Email address of the creator of this iou report */ + ownerEmail: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + taskReport: {}, + isHovered: false, +}; +const TaskAction = (props) => { + const taskReportID = props.taskReportID; + const taskReportName = props.taskReport.reportName || ''; + + let messageLinkText = ''; + switch (props.actionName) { + case CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED: + messageLinkText = props.translate('task.messages.completed'); + break; + case CONST.REPORT.ACTIONS.TYPE.TASKCANCELED: + messageLinkText = props.translate('task.messages.canceled'); + break; + case CONST.REPORT.ACTIONS.TYPE.TASKREOPENED: + messageLinkText = props.translate('task.messages.reopened'); + break; + default: + messageLinkText = props.translate('newTaskPage.task'); + } + + return ( + Navigation.navigate(ROUTES.getReportRoute(taskReportID))} + style={[styles.flexRow, styles.justifyContentBetween]} + > + + {messageLinkText} + {` ${taskReportName}`} + + + + ); +}; + +TaskAction.propTypes = propTypes; +TaskAction.defaultProps = defaultProps; +TaskAction.displayName = 'TaskAction'; + +export default compose( + withLocalize, + withOnyx({ + taskReport: { + key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, + }, + }), +)(TaskAction); diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index 19c8349571f3..ae2c5accab26 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -16,6 +16,7 @@ import getButtonState from '../../libs/getButtonState'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes'; +import * as TaskUtils from '../../libs/actions/Task'; const propTypes = { /** The ID of the associated taskReport */ @@ -54,8 +55,9 @@ const TaskPreview = (props) => { // Other linked reportActions will only contain the taskReportID and we will grab the details from there const isTaskCompleted = (props.taskReport.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && props.taskReport.statusNum === CONST.REPORT.STATUS.APPROVED) || - (props.action.childStateNum === CONST.REPORT.STATE_NUM.CLOSED && props.action.childStatusNum === CONST.REPORT.STATUS.APPROVED); + (props.action.childStateNum === CONST.REPORT.STATE_NUM.SUBMITTED && props.action.childStatusNum === CONST.REPORT.STATUS.APPROVED); const taskTitle = props.action.taskTitle || props.taskReport.reportName; + const parentReportID = props.action.parentReportID || props.taskReport.parentReportID; return ( { style={[styles.mr2]} containerStyle={[styles.taskCheckbox]} isChecked={isTaskCompleted} + disabled={TaskUtils.isTaskCanceled(props.taskReport)} onPress={() => { - // Being implemented in https://github.com/Expensify/App/issues/16858 + if (isTaskCompleted) { + TaskUtils.reopenTask(props.taskReportID, parentReportID, taskTitle); + } else { + TaskUtils.completeTask(props.taskReportID, parentReportID, taskTitle); + } }} /> {taskTitle} diff --git a/src/components/SubscriptAvatar.js b/src/components/SubscriptAvatar.js index d9f6b5246cb3..65c016e29ff3 100644 --- a/src/components/SubscriptAvatar.js +++ b/src/components/SubscriptAvatar.js @@ -63,23 +63,21 @@ const SubscriptAvatar = (props) => { type={props.mainAvatar.type} /> - - - - - + + + ); }; diff --git a/src/components/Switch.js b/src/components/Switch.js index e4de23650556..f0fd20f7af48 100644 --- a/src/components/Switch.js +++ b/src/components/Switch.js @@ -1,7 +1,8 @@ import React, {Component} from 'react'; -import {TouchableOpacity, Animated} from 'react-native'; +import {Animated} from 'react-native'; import PropTypes from 'prop-types'; import styles from '../styles/styles'; +import * as Pressables from './Pressable'; const propTypes = { /** Whether the switch is toggled to the on position */ @@ -9,8 +10,12 @@ const propTypes = { /** Callback to fire when the switch is toggled */ onToggle: PropTypes.func.isRequired, + + /** Accessibility label for the switch */ + accessibilityLabel: PropTypes.string.isRequired, }; +const PressableWithFeedback = Pressables.PressableWithFeedback; class Switch extends Component { constructor(props) { super(props); @@ -19,6 +24,7 @@ class Switch extends Component { this.offsetX = new Animated.Value(props.isOn ? this.onPosition : this.offPosition); this.toggleSwitch = this.toggleSwitch.bind(this); + this.toggleAction = this.toggleAction.bind(this); } componentDidUpdate(prevProps) { @@ -29,6 +35,7 @@ class Switch extends Component { this.toggleSwitch(); } + // animates the switch to the on or off position toggleSwitch() { Animated.timing(this.offsetX, { toValue: this.props.isOn ? this.onPosition : this.offPosition, @@ -37,16 +44,28 @@ class Switch extends Component { }).start(); } + // executes the callback passed in as a prop + toggleAction() { + this.props.onToggle(!this.props.isOn); + } + render() { const switchTransform = {transform: [{translateX: this.offsetX}]}; return ( - this.props.onToggle(!this.props.isOn)} + onPress={this.toggleAction} + onLongPress={this.toggleAction} + accessibilityRole="switch" + accessibilityState={{checked: this.props.isOn}} + aria-checked={this.props.isOn} + accessibilityLabel={this.props.accessibilityLabel} + // disable hover dim for switch + hoverDimmingValue={1} + pressDimmingValue={0.8} > - + ); } } diff --git a/src/components/TaskHeader.js b/src/components/TaskHeader.js new file mode 100644 index 000000000000..474a009693ee --- /dev/null +++ b/src/components/TaskHeader.js @@ -0,0 +1,121 @@ +import React, {useEffect} from 'react'; +import {View, TouchableOpacity} from 'react-native'; +import PropTypes from 'prop-types'; +import lodashGet from 'lodash/get'; +import reportPropTypes from '../pages/reportPropTypes'; +import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import * as ReportUtils from '../libs/ReportUtils'; +import * as Expensicons from './Icon/Expensicons'; +import Text from './Text'; +import participantPropTypes from './participantPropTypes'; +import Avatar from './Avatar'; +import styles from '../styles/styles'; +import themeColors from '../styles/themes/default'; +import CONST from '../CONST'; +import withWindowDimensions from './withWindowDimensions'; +import compose from '../libs/compose'; +import Navigation from '../libs/Navigation/Navigation'; +import ROUTES from '../ROUTES'; +import Icon from './Icon'; +import MenuItemWithTopDescription from './MenuItemWithTopDescription'; +import Button from './Button'; +import * as TaskUtils from '../libs/actions/Task'; + +const propTypes = { + /** The report currently being looked at */ + report: reportPropTypes.isRequired, + + /** Personal details so we can get the ones for the report participants */ + personalDetails: PropTypes.objectOf(participantPropTypes).isRequired, + + ...withLocalizePropTypes, +}; + +function TaskHeader(props) { + const title = ReportUtils.getReportName(props.report); + const assigneeName = ReportUtils.getDisplayNameForParticipant(props.report.managerEmail); + const assigneeAvatar = ReportUtils.getAvatar(lodashGet(props.personalDetails, [props.report.managerEmail, 'avatar']), props.report.managerEmail); + const isOpen = props.report.stateNum === CONST.REPORT.STATE_NUM.OPEN && props.report.statusNum === CONST.REPORT.STATUS.OPEN; + const isCompleted = props.report.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && props.report.statusNum === CONST.REPORT.STATUS.APPROVED; + const parentReportID = props.report.parentReportID; + + useEffect(() => { + TaskUtils.setTaskReport(props.report); + }, [props.report]); + + return ( + + + + {props.translate('common.to')} + Navigation.navigate(ROUTES.getTaskReportAssigneeRoute(props.report.reportID))} + disabled={!isOpen} + > + + + {props.report.managerEmail && ( + <> + + + + {assigneeName} + + + + )} + + + {isCompleted ? ( + <> + {props.translate('task.completed')} + + + + + ) : ( + + + + + + Navigation.navigate(ROUTES.getTaskReportTitleRoute(props.report.reportID))} + disabled={!isOpen} + /> + Navigation.navigate(ROUTES.getTaskReportDescriptionRoute(props.report.reportID))} + disabled={!isOpen} + /> + + ); +} + +TaskHeader.propTypes = propTypes; +TaskHeader.displayName = 'TaskHeader'; + +export default compose(withWindowDimensions, withLocalize)(TaskHeader); diff --git a/src/components/TestToolMenu.js b/src/components/TestToolMenu.js index acf9a426d54f..2e9c7fd4027c 100644 --- a/src/components/TestToolMenu.js +++ b/src/components/TestToolMenu.js @@ -51,6 +51,7 @@ const TestToolMenu = (props) => ( {!CONFIG.IS_USING_LOCAL_WEB && ( User.setShouldUseStagingServer(!lodashGet(props, 'user.shouldUseStagingServer', ApiUtils.isUsingStagingApi()))} /> @@ -60,6 +61,7 @@ const TestToolMenu = (props) => ( {/* When toggled the app will be forced offline. */} Network.setShouldForceOffline(!props.network.shouldForceOffline)} /> @@ -68,6 +70,7 @@ const TestToolMenu = (props) => ( {/* When toggled all network requests will fail. */} Network.setShouldFailAllRequests(!props.network.shouldFailAllRequests)} /> diff --git a/src/components/withLocalize.js b/src/components/withLocalize.js index 3b67ff939b23..4cbdda876231 100755 --- a/src/components/withLocalize.js +++ b/src/components/withLocalize.js @@ -109,10 +109,11 @@ class LocaleContextProvider extends React.Component { /** * @param {String} datetime - ISO-formatted datetime string * @param {Boolean} [includeTimezone] + * @param {Boolean} isLowercase * @returns {String} */ - datetimeToCalendarTime(datetime, includeTimezone) { - return DateUtils.datetimeToCalendarTime(this.props.preferredLocale, datetime, includeTimezone, lodashGet(this.props, 'currentUserPersonalDetails.timezone.selected')); + datetimeToCalendarTime(datetime, includeTimezone, isLowercase = false) { + return DateUtils.datetimeToCalendarTime(this.props.preferredLocale, datetime, includeTimezone, lodashGet(this.props, 'currentUserPersonalDetails.timezone.selected'), isLowercase); } /** diff --git a/src/languages/en.js b/src/languages/en.js index c3543cd926e1..48057765ce67 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -1,5 +1,6 @@ import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; import CONST from '../CONST'; +import * as ReportActionsUtils from '../libs/ReportActionsUtils'; /* eslint-disable max-len */ export default { @@ -151,7 +152,7 @@ export default { attachmentTooSmall: 'Attachment too small', sizeNotMet: 'Attachment size must be greater than 240 bytes.', wrongFileType: 'Attachment is the wrong type', - notAllowedExtension: 'Attachments must be one of the following types:', + notAllowedExtension: 'This filetype is not allowed', }, avatarCropModal: { title: 'Edit photo', @@ -253,8 +254,8 @@ export default { copyEmailToClipboard: 'Copy email to clipboard', markAsUnread: 'Mark as unread', editComment: 'Edit comment', - deleteComment: 'Delete comment', - deleteConfirmation: 'Are you sure you want to delete this comment?', + deleteAction: ({action}) => `Delete ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}`, + deleteConfirmation: ({action}) => `Are you sure you want to delete this ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}?`, onlyVisible: 'Only visible to', replyInThread: 'Reply in thread', }, @@ -301,7 +302,6 @@ export default { newChat: 'New chat', newGroup: 'New group', newRoom: 'New room', - headerChat: 'Chats', buttonSearch: 'Search', buttonMySettings: 'My settings', fabNewChat: 'New chat (Floating action)', @@ -333,6 +333,7 @@ export default { payerSettled: ({amount}) => `settled up ${amount}`, noReimbursableExpenses: 'This report has an invalid amount', pendingConversionMessage: "Total will update when you're back online", + threadReportName: ({formattedAmount, comment}) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`, error: { invalidSplit: 'Split amounts do not equal total amount', other: 'Unexpected error, please try again later', @@ -1187,11 +1188,19 @@ export default { descriptionOptional: 'Description (optional)', shareSomewhere: 'Share somewhere', pleaseEnterTaskName: 'Please enter a title', - markAsComplete: 'Mark as complete', + markAsDone: 'Mark as done', markAsIncomplete: 'Mark as incomplete', pleaseEnterTaskAssignee: 'Please select an assignee', pleaseEnterTaskDestination: 'Please select a share destination', }, + task: { + completed: 'Completed', + messages: { + completed: 'Completed task', + canceled: 'Canceled task', + reopened: 'Reopened task', + }, + }, statementPage: { generatingPDF: "We're generating your PDF right now. Please come back later!", }, @@ -1314,8 +1323,12 @@ export default { deletedMessage: '[Deleted message]', }, threads: { - lastReply: 'Last Reply', + lastReply: 'Last reply', replies: 'Replies', reply: 'Reply', }, + qrCodes: { + copyUrlToClipboard: 'Copy URL to clipboard', + copied: 'Copied!', + }, }; diff --git a/src/languages/es.js b/src/languages/es.js index b936272d4622..43ccdfcdf2db 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -1,4 +1,5 @@ import CONST from '../CONST'; +import * as ReportActionsUtils from '../libs/ReportActionsUtils'; /* eslint-disable max-len */ export default { @@ -150,7 +151,7 @@ export default { attachmentTooSmall: 'Archivo adjunto demasiado pequeño', sizeNotMet: 'El archivo adjunto debe ser mas grande que 240 bytes.', wrongFileType: 'El tipo del archivo adjunto es incorrecto', - notAllowedExtension: 'Los archivos adjuntos deben ser de uno de los siguientes tipos:', + notAllowedExtension: 'Este tipo de archivo no está permitido', }, avatarCropModal: { title: 'Editar foto', @@ -252,8 +253,8 @@ export default { copyEmailToClipboard: 'Copiar email al portapapeles', markAsUnread: 'Marcar como no leído', editComment: 'Editar comentario', - deleteComment: 'Eliminar comentario', - deleteConfirmation: '¿Estás seguro de que quieres eliminar este comentario?', + deleteAction: ({action}) => `Eliminar ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`, + deleteConfirmation: ({action}) => `¿Estás seguro de que quieres eliminar este ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`, onlyVisible: 'Visible sólo para', replyInThread: 'Responder en el hilo', }, @@ -300,7 +301,6 @@ export default { newChat: 'Nuevo chat', newGroup: 'Nuevo grupo', newRoom: 'Nueva sala de chat', - headerChat: 'Chats', buttonSearch: 'Buscar', buttonMySettings: 'Mi configuración', fabNewChat: 'Nuevo chat', @@ -332,6 +332,7 @@ export default { payerSettled: ({amount}) => `pagado ${amount}`, noReimbursableExpenses: 'El monto de este informe es inválido', pendingConversionMessage: 'El total se actualizará cuando estés online', + threadReportName: ({formattedAmount, comment}) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, error: { invalidSplit: 'La suma de las partes no equivale al monto total', other: 'Error inesperado, por favor inténtalo más tarde', @@ -1192,11 +1193,19 @@ export default { descriptionOptional: 'Descripción (opcional)', shareSomewhere: 'Compartir en algún lugar', pleaseEnterTaskName: 'Por favor introduce un título', - markAsComplete: 'Marcar como completa', + markAsDone: 'Marcar como hecho', markAsIncomplete: 'Marcar como incompleta', pleaseEnterTaskAssignee: 'Por favor, asigna una persona a esta tarea', pleaseEnterTaskDestination: 'Por favor, selecciona un destino de tarea', }, + task: { + completed: 'Completada', + messages: { + completed: 'Tarea completada', + canceled: 'Tarea cancelada', + reopened: 'Tarea reabrir', + }, + }, statementPage: { generatingPDF: 'Estamos generando tu PDF ahora mismo. ¡Por favor, vuelve más tarde!', }, @@ -1783,4 +1792,8 @@ export default { replies: 'Respuestas', reply: 'Respuesta', }, + qrCodes: { + copyUrlToClipboard: 'Copiar URL al portapapeles', + copied: '¡Copiado!', + }, }; diff --git a/src/libs/CurrencyUtils.js b/src/libs/CurrencyUtils.js index 2784b32edbdf..f8d56f7e60e9 100644 --- a/src/libs/CurrencyUtils.js +++ b/src/libs/CurrencyUtils.js @@ -86,7 +86,8 @@ function isCurrencySymbolLTR(currencyCode) { */ function convertToSmallestUnit(currency, amountAsFloat) { const currencyUnit = getCurrencyUnit(currency); - return Math.trunc(amountAsFloat * currencyUnit); + // We round off the number to resolve floating-point precision issues. + return Math.round(amountAsFloat * currencyUnit); } /** diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js index 91609af7b173..74a18a653ad8 100644 --- a/src/libs/DateUtils.js +++ b/src/libs/DateUtils.js @@ -65,18 +65,25 @@ function getLocalMomentFromDatetime(locale, datetime, currentSelectedTimezone = * @param {String} datetime * @param {Boolean} includeTimeZone * @param {String} [currentSelectedTimezone] + * @param {Boolean} isLowercase * * @returns {String} */ -function datetimeToCalendarTime(locale, datetime, includeTimeZone = false, currentSelectedTimezone) { +function datetimeToCalendarTime(locale, datetime, includeTimeZone = false, currentSelectedTimezone, isLowercase = false) { const date = getLocalMomentFromDatetime(locale, datetime, currentSelectedTimezone); const tz = includeTimeZone ? ' [UTC]Z' : ''; - const todayAt = Localize.translate(locale, 'common.todayAt'); - const tomorrowAt = Localize.translate(locale, 'common.tomorrowAt'); - const yesterdayAt = Localize.translate(locale, 'common.yesterdayAt'); + let todayAt = Localize.translate(locale, 'common.todayAt'); + let tomorrowAt = Localize.translate(locale, 'common.tomorrowAt'); + let yesterdayAt = Localize.translate(locale, 'common.yesterdayAt'); const at = Localize.translate(locale, 'common.conjunctionAt'); + if (isLowercase) { + todayAt = todayAt.toLowerCase(); + tomorrowAt = tomorrowAt.toLowerCase(); + yesterdayAt = yesterdayAt.toLowerCase(); + } + return moment(date).calendar({ sameDay: `[${todayAt}] LT${tz}`, nextDay: `[${tomorrowAt}] LT${tz}`, diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js index f8954e389d57..a3133ce21006 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -4,7 +4,6 @@ import Str from 'expensify-common/lib/str'; import Onyx from 'react-native-onyx'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; -import * as User from './actions/User'; import emojisTrie from './EmojiTrie'; import FrequentlyUsed from '../../assets/images/history.svg'; @@ -170,10 +169,11 @@ function mergeEmojisWithFrequentlyUsedEmojis(emojis) { } /** - * Update the frequently used emojis list by usage and sync with API + * Get the updated frequently used emojis list by usage * @param {Object|Object[]} newEmoji + * @return {Object[]} */ -function addToFrequentlyUsedEmojis(newEmoji) { +function getFrequentlyUsedEmojis(newEmoji) { let frequentEmojiList = [...frequentlyUsedEmojis]; const maxFrequentEmojiCount = CONST.EMOJI_FREQUENT_ROW_COUNT * CONST.EMOJI_NUM_PER_ROW - 1; @@ -196,7 +196,7 @@ function addToFrequentlyUsedEmojis(newEmoji) { frequentEmojiList.sort((a, b) => b.count - a.count || b.lastUpdatedAt - a.lastUpdatedAt); }); - User.updateFrequentlyUsedEmojis(frequentEmojiList); + return frequentEmojiList; } /** @@ -219,20 +219,18 @@ const getEmojiCodeWithSkinColor = (item, preferredSkinToneIndex) => { * Replace any emoji name in a text with the emoji icon. * If we're on mobile, we also add a space after the emoji granted there's no text after it. * - * All replaced emojis will be added to the frequently used emojis list. - * * @param {String} text * @param {Boolean} isSmallScreenWidth * @param {Number} preferredSkinTone - * @returns {String} + * @returns {Object} */ function replaceEmojis(text, isSmallScreenWidth = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE) { let newText = text; + const emojis = []; const emojiData = text.match(CONST.REGEX.EMOJI_NAME); if (!emojiData || emojiData.length === 0) { - return text; + return {text: newText, emojis}; } - const emojis = []; for (let i = 0; i < emojiData.length; i++) { const name = emojiData[i].slice(1, -1); const checkEmoji = emojisTrie.search(name); @@ -253,11 +251,7 @@ function replaceEmojis(text, isSmallScreenWidth = false, preferredSkinTone = CON } } - // Add all replaced emojis to the frequently used emojis list - if (!_.isEmpty(emojis)) { - addToFrequentlyUsedEmojis(emojis); - } - return newText; + return {text: newText, emojis}; } /** @@ -293,4 +287,28 @@ function suggestEmojis(text, limit = 5) { return []; } -export {getHeaderEmojis, mergeEmojisWithFrequentlyUsedEmojis, addToFrequentlyUsedEmojis, containsOnlyEmojis, replaceEmojis, suggestEmojis, trimEmojiUnicode, getEmojiCodeWithSkinColor}; +/** + * Retrieve preferredSkinTone as Number to prevent legacy 'default' String value + * + * @param {Number | String} val + * @returns {Number} + */ +const getPreferredSkinToneIndex = (val) => { + if (!_.isNull(val) && Number.isInteger(Number(val))) { + return val; + } + + return CONST.EMOJI_DEFAULT_SKIN_TONE; +}; + +export { + getHeaderEmojis, + mergeEmojisWithFrequentlyUsedEmojis, + getFrequentlyUsedEmojis, + containsOnlyEmojis, + replaceEmojis, + suggestEmojis, + trimEmojiUnicode, + getEmojiCodeWithSkinColor, + getPreferredSkinToneIndex, +}; diff --git a/src/libs/IOUUtils.js b/src/libs/IOUUtils.js index f5ebefbad50c..7224e63f31e0 100644 --- a/src/libs/IOUUtils.js +++ b/src/libs/IOUUtils.js @@ -1,5 +1,6 @@ import _ from 'underscore'; import CONST from '../CONST'; +import * as ReportActionsUtils from './ReportActionsUtils'; /** * Calculates the amount per user given a list of participants @@ -74,7 +75,7 @@ function updateIOUOwnerAndTotal(iouReport, actorEmail, amount, currency, type = */ function getIOUReportActions(reportActions, iouReport, type = '', pendingAction = '', filterRequestsInDifferentCurrency = false) { return _.chain(reportActions) - .filter((action) => action.originalMessage && action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && (!_.isEmpty(type) ? action.originalMessage.type === type : true)) + .filter((action) => action.originalMessage && ReportActionsUtils.isMoneyRequestAction(action) && (!_.isEmpty(type) ? action.originalMessage.type === type : true)) .filter((action) => action.originalMessage.IOUReportID.toString() === iouReport.reportID.toString()) .filter((action) => (!_.isEmpty(pendingAction) ? action.pendingAction === pendingAction : true)) .filter((action) => (filterRequestsInDifferentCurrency ? action.originalMessage.currency !== iouReport.currency : true)) diff --git a/src/libs/NetworkConnection.js b/src/libs/NetworkConnection.js index 67baec1ad3ed..f3b16927becc 100644 --- a/src/libs/NetworkConnection.js +++ b/src/libs/NetworkConnection.js @@ -82,16 +82,7 @@ function subscribeToNetInfo() { // When App is served locally (or from Electron) this address is always reachable - even offline // Using the API url ensures reachability is tested over internet reachabilityUrl: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api`, - reachabilityMethod: 'GET', - reachabilityTest: (response) => { - if (!response.ok) { - return Promise.resolve(false); - } - return response - .json() - .then((json) => Promise.resolve(json.jsonCode === 200)) - .catch(() => Promise.resolve(false)); - }, + reachabilityTest: (response) => Promise.resolve(response.status === 200), // If a check is taking longer than this time we're considered offline reachabilityRequestTimeout: CONST.NETWORK.MAX_PENDING_TIME_MS, diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 5d9110ea6930..cb19a449dd11 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -408,7 +408,9 @@ function createOption(logins, personalDetails, report, reportActions = {}, {show result.isDefaultRoom = ReportUtils.isDefaultRoom(report); result.isArchivedRoom = ReportUtils.isArchivedRoom(report); result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); + result.isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); result.isThread = ReportUtils.isThread(report); + result.isTaskReport = ReportUtils.isTaskReport(report); result.shouldShowSubscript = result.isPolicyExpenseChat && !report.isOwnPolicyExpenseChat && !result.isArchivedRoom; result.allReportErrors = getAllReportErrors(report, reportActions); result.brickRoadIndicator = !_.isEmpty(result.allReportErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; @@ -430,7 +432,7 @@ function createOption(logins, personalDetails, report, reportActions = {}, {show if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml})) { lastMessageTextFromReport = `[${Localize.translateLocal('common.attachment')}]`; } else { - lastMessageTextFromReport = report ? report.lastMessageText : ''; + lastMessageTextFromReport = report ? report.lastMessageText || '' : ''; } const lastActorDetails = personalDetailMap[report.lastActorEmail] || null; @@ -449,6 +451,8 @@ function createOption(logins, personalDetails, report, reportActions = {}, {show if (result.isChatRoom || result.isPolicyExpenseChat) { result.alternateText = showChatPreviewLine && !forcePolicyNamePreview && lastMessageText ? lastMessageText : subtitle; + } else if (result.isMoneyRequestReport) { + result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } else { result.alternateText = showChatPreviewLine && lastMessageText ? lastMessageText : LocalePhoneNumber.formatPhoneNumber(personalDetail.login); } @@ -546,6 +550,7 @@ function getOptions( forcePolicyNamePreview = false, includeOwnedWorkspaceChats = false, includeThreads = false, + includeTasks = false, }, ) { if (!isPersonalDetailsReady(personalDetails)) { @@ -587,6 +592,7 @@ function getOptions( const isThread = ReportUtils.isThread(report); const isChatRoom = ReportUtils.isChatRoom(report); + const isTaskReport = ReportUtils.isTaskReport(report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); const logins = report.participants || []; @@ -598,6 +604,10 @@ function getOptions( return; } + if (isTaskReport && !includeTasks) { + return; + } + // Save the report in the map if this is a single participant so we can associate the reportID with the // personal detail option later. Individuals should not be associated with single participant // policyExpenseChats or chatRooms since those are not people. diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index e4ee0c1a899f..20062514675c 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -40,6 +40,14 @@ function isDeletedAction(reportAction) { return message.length === 0 || lodashGet(message, [0, 'html']) === ''; } +/** + * @param {Object} reportAction + * @returns {Boolean} + */ +function isMoneyRequestAction(reportAction) { + return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.IOU; +} + /** * Returns the parentReportAction if the given report is a thread. * @@ -53,6 +61,19 @@ function getParentReportAction(report) { return lodashGet(allReportActions, [report.parentReportID, report.parentReportActionID], {}); } +/** + * Returns whether the thread is a transaction thread, which is any thread with IOU parent + * report action of type create. + * + * @param {Object} parentReportAction + * @returns {Boolean} + */ +function isTransactionThread(parentReportAction) { + return ( + parentReportAction && parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && lodashGet(parentReportAction, 'originalMessage.type') === CONST.IOU.REPORT_ACTION_TYPE.CREATE + ); +} + /** * Sort an array of reportActions by their created timestamp first, and reportActionID second * This gives us a stable order even in the case of multiple reportActions created on the same millisecond @@ -131,6 +152,11 @@ function isConsecutiveActionMadeByPreviousActor(reportActions, actionIndex) { return false; } + // Do not group if previous action was a created action + if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return false; + } + // Do not group if previous or current action was a renamed action if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED || currentAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { return false; @@ -171,7 +197,7 @@ function getLastVisibleMessageText(reportID, actionsToMerge = {}) { const htmlText = lodashGet(lastVisibleAction, 'message[0].html', ''); const parser = new ExpensiMark(); const messageText = parser.htmlToText(htmlText); - return String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH); + return String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); } /** @@ -214,7 +240,11 @@ function shouldReportActionBeVisible(reportAction, key) { } // Filter out any unsupported reportAction types - if (!_.has(CONST.REPORT.ACTIONS.TYPE, reportAction.actionName) && !_.contains(_.values(CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG), reportAction.actionName)) { + if ( + !_.has(CONST.REPORT.ACTIONS.TYPE, reportAction.actionName) && + !_.contains(_.values(CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG), reportAction.actionName) && + !_.contains(_.values(CONST.REPORT.ACTIONS.TYPE.TASK), reportAction.actionName) + ) { return false; } @@ -319,7 +349,9 @@ export { getSortedReportActionsForDisplay, getLastClosedReportAction, getLatestReportActionFromOnyxData, + isMoneyRequestAction, getLinkedTransactionID, isCreatedTaskReportAction, getParentReportAction, + isTransactionThread, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 08ea0356eac6..c8942a72b450 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -108,6 +108,16 @@ function getReportParticipantsTitle(logins) { ); } +/** + * Checks if a report is a chat report. + * + * @param {Object} report + * @returns {Boolean} + */ +function isChatReport(report) { + return lodashGet(report, 'type') === CONST.REPORT.TYPE.CHAT; +} + /** * Checks if a report is an Expense report. * @@ -184,6 +194,8 @@ function canEditReportAction(reportAction) { } /** + * Whether the Money Request report is settled + * * @param {String} reportID * @returns {Boolean} */ @@ -192,7 +204,7 @@ function isSettled(reportID) { } /** - * Can only delete if it's an ADDCOMMENT, the author is this user. + * Can only delete if the author is this user and the action is an ADDCOMMENT action or an IOU action in an unsettled report * * @param {Object} reportAction * @returns {Boolean} @@ -200,8 +212,8 @@ function isSettled(reportID) { function canDeleteReportAction(reportAction) { return ( reportAction.actorEmail === sessionEmail && - reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && - !ReportActionsUtils.isCreatedTaskReportAction(reportAction) && + ((reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportActionsUtils.isCreatedTaskReportAction(reportAction)) || + (ReportActionsUtils.isMoneyRequestAction(reportAction) && !isSettled(reportAction.originalMessage.IOUReportID))) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ); } @@ -434,7 +446,7 @@ function isPolicyExpenseChatAdmin(report, policies) { * @returns {Boolean} */ function isThread(report) { - return Boolean(report && report.parentReportID && report.parentReportActionID); + return Boolean(report && report.parentReportID && report.parentReportActionID && report.type === CONST.REPORT.TYPE.CHAT); } /** @@ -461,9 +473,10 @@ function isThreadFirstChat(reportAction, reportID) { /** * Get either the policyName or domainName the chat is tied to * @param {Object} report + * @param {Object} parentReport * @returns {String} */ -function getChatRoomSubtitle(report) { +function getChatRoomSubtitle(report, parentReport = null) { if (isThread(report)) { if (!getChatType(report)) { return ''; @@ -471,7 +484,15 @@ function getChatRoomSubtitle(report) { // If thread is not from a DM or group chat, the subtitle will follow the pattern 'Workspace Name • #roomName' const workspaceName = getPolicyName(report); - const roomName = isChatRoom(report) ? lodashGet(report, 'displayName') : ''; + let roomName = ''; + if (isChatRoom(report)) { + if (parentReport) { + roomName = lodashGet(parentReport, 'displayName', ''); + } else { + roomName = lodashGet(report, 'displayName', ''); + } + } + return [workspaceName, roomName].join(' • '); } if (!isDefaultRoom(report) && !isUserCreatedPolicyRoom(report) && !isPolicyExpenseChat(report)) { @@ -574,12 +595,12 @@ function canShowReportRecipientLocalTime(personalDetails, report) { } /** - * Trim the last message text to a fixed limit. + * Html decode, shorten last message text to fixed length and trim spaces. * @param {String} lastMessageText * @returns {String} */ function formatReportLastMessageText(lastMessageText) { - return String(lastMessageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH); + return Str.htmlDecode(String(lastMessageText)).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); } /** @@ -937,6 +958,32 @@ function getDisplayNamesWithTooltips(participants, isMultipleParticipantReport) }); } +/** + * We get the amount, currency and comment money request value from the action.originalMessage. + * But for the send money action, the above value is put in the IOUDetails object. + * + * @param {Object} reportAction + * @param {Number} reportAction.amount + * @param {String} reportAction.currency + * @param {String} reportAction.comment + * @param {Object} [reportAction.IOUDetails] + * @returns {Object} + */ +function getMoneyRequestAction(reportAction = {}) { + const originalMessage = lodashGet(reportAction, 'originalMessage', {}); + let total = originalMessage.amount || 0; + let currency = originalMessage.currency || CONST.CURRENCY.USD; + let comment = originalMessage.comment || ''; + + if (_.has(originalMessage, 'IOUDetails')) { + total = lodashGet(originalMessage, 'IOUDetails.amount', 0); + currency = lodashGet(originalMessage, 'IOUDetails.currency', CONST.CURRENCY.USD); + comment = lodashGet(originalMessage, 'IOUDetails.comment', ''); + } + + return {total, currency, comment}; +} + /** * @param {Object} report * @param {String} report.iouReportID @@ -1001,6 +1048,19 @@ function getMoneyRequestReportName(report) { return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName, amount: formattedAmount}); } +/** + * Given a parent IOU report action get report name for the LHN. + * + * @param {Object} reportAction + * @returns {String} + */ +function getTransactionReportName(reportAction) { + return Localize.translateLocal('iou.threadReportName', { + formattedAmount: CurrencyUtils.convertToDisplayString(lodashGet(reportAction, 'originalMessage.amount', 0), lodashGet(reportAction, 'originalMessage.currency', '')), + comment: lodashGet(reportAction, 'originalMessage.comment'), + }); +} + /** * Get the title for a report. * @@ -1011,6 +1071,9 @@ function getReportName(report) { let formattedName; if (isThread(report)) { const parentReportAction = ReportActionsUtils.getParentReportAction(report); + if (ReportActionsUtils.isTransactionThread(parentReportAction)) { + return getTransactionReportName(parentReportAction); + } const parentReportActionMessage = lodashGet(parentReportAction, ['message', 0, 'text'], '').replace(/(\r\n|\n|\r)/gm, ' '); return parentReportActionMessage || Localize.translateLocal('parentReportAction.deletedMessage'); } @@ -1149,9 +1212,10 @@ function buildOptimisticAddCommentReportAction(text, file) { * @param {String} taskTitle - Title of the task * @param {String} taskAssignee - Email of the person assigned to the task * @param {String} text - Text of the comment + * @param {String} parentReportID - Report ID of the parent report * @returns {Object} */ -function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssignee, text) { +function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssignee, text, parentReportID) { const reportAction = buildOptimisticAddCommentReportAction(text); reportAction.reportAction.message[0].taskReportID = taskReportID; @@ -1162,6 +1226,7 @@ function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAss taskReportID: reportAction.reportAction.message[0].taskReportID, }; reportAction.reportAction.childReportID = taskReportID; + reportAction.reportAction.parentReportID = parentReportID; reportAction.reportAction.childType = CONST.REPORT.TYPE.TASK; reportAction.reportAction.taskTitle = taskTitle; reportAction.reportAction.taskAssignee = taskAssignee; @@ -1362,6 +1427,43 @@ function buildOptimisticIOUReportAction(type, amount, currency, comment, partici }; } +function buildOptimisticTaskReportAction(taskReportID, actionName, message = '') { + const originalMessage = { + taskReportID, + type: actionName, + text: message, + }; + + return { + actionName, + actorAccountID: currentUserAccountID, + actorEmail: currentUserEmail, + automatic: false, + avatar: lodashGet(currentUserPersonalDetails, 'avatar', getDefaultAvatar(currentUserEmail)), + isAttachment: false, + originalMessage, + message: [ + { + text: message, + taskReportID, + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + ], + person: [ + { + style: 'strong', + text: lodashGet(currentUserPersonalDetails, 'displayName', currentUserEmail), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created: DateUtils.getDBTime(), + isFirstItem: false, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + /** * Builds an optimistic chat report with a randomly generated reportID and as much information as we currently have * @@ -1388,8 +1490,8 @@ function buildOptimisticChatReport( oldPolicyName = '', visibility = undefined, notificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, - parentReportActionID, - parentReportID, + parentReportActionID = '', + parentReportID = '', ) { const currentTime = DateUtils.getDBTime(); return { @@ -1613,7 +1715,7 @@ function buildOptimisticTaskReport(ownerEmail, assignee = null, parentReportID, reportName: title, description, ownerEmail, - assignee, + managerEmail: assignee, type: CONST.REPORT.TYPE.TASK, parentReportID, stateNum: CONST.REPORT.STATE_NUM.OPEN, @@ -1754,7 +1856,11 @@ function shouldReportBeInOptionList(report, reportIDFromRoute, isInGSDMode, curr // Exclude reports that have no data because there wouldn't be anything to show in the option item. // This can happen if data is currently loading from the server or a report is in various stages of being created. // This can also happen for anyone accessing a public room or archived room for which they don't have access to the underlying policy. - if (!report || !report.reportID || (_.isEmpty(report.participants) && !isThread(report) && !isPublicRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report))) { + if ( + !report || + !report.reportID || + (_.isEmpty(report.participants) && !isThread(report) && !isPublicRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && !isTaskReport(report)) + ) { return false; } @@ -2113,6 +2219,7 @@ export { buildOptimisticIOUReport, buildOptimisticExpenseReport, buildOptimisticIOUReportAction, + buildOptimisticTaskReportAction, buildOptimisticAddCommentReportAction, buildOptimisticTaskCommentReportAction, shouldReportBeInOptionList, @@ -2121,6 +2228,7 @@ export { getAllPolicyReports, getIOUReportActionMessage, getDisplayNameForParticipant, + isChatReport, isExpenseReport, isIOUReport, isTaskReport, @@ -2147,4 +2255,5 @@ export { shouldReportShowSubscript, isReportDataReady, isSettled, + getMoneyRequestAction, }; diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index ae28a75a5225..c8e15e929fa1 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -161,7 +161,8 @@ function getOrderedReportIDs(reportIDFromRoute) { return; } - if (ReportUtils.isTaskReport(report)) { + if (ReportUtils.isTaskReport(report) && report.stateNum !== CONST.REPORT.STATE.OPEN && report.statusNum !== CONST.REPORT.STATUS.OPEN) { + archivedReports.push(report); return; } @@ -262,7 +263,7 @@ function getOptionData(reportID) { const parentReport = result.parentReportID ? chatReports[`${ONYXKEYS.COLLECTION.REPORT}${result.parentReportID}`] : null; const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat; - const subtitle = ReportUtils.getChatRoomSubtitle(report); + const subtitle = ReportUtils.getChatRoomSubtitle(report, parentReport); const login = Str.removeSMSDomain(lodashGet(personalDetail, 'login', '')); const formattedLogin = Str.isSMSLogin(login) ? LocalePhoneNumber.formatPhoneNumber(login) : login; @@ -303,10 +304,8 @@ function getOptionData(reportID) { }); } - if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread) && !result.isArchivedRoom) { + if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread || result.isTaskReport) && !result.isArchivedRoom) { result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); - } else if (result.isTaskReport) { - result.alternateText = Localize.translate(preferredLocale, 'newTaskPage.task'); } else { if (!lastMessageText) { // Here we get the beginning of chat history message and append the display name for each user, adding pronouns if there are any. @@ -349,7 +348,6 @@ function getOptionData(reportID) { result.icons = ReportUtils.getIcons(result.isTaskReport ? parentReport : report, personalDetails, policies, ReportUtils.getAvatar(personalDetail.avatar, personalDetail.login)); result.searchText = OptionsListUtils.getSearchText(report, reportName, participantPersonalDetailList, result.isChatRoom || result.isPolicyExpenseChat); result.displayNamesWithTooltips = displayNamesWithTooltips; - return result; } diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 3a5265286bf1..46e2573bc6a8 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -43,6 +43,200 @@ Onyx.connect({ }, }); +function buildOnyxDataForMoneyRequest(chatReport, iouReport, transaction, chatCreatedAction, iouCreatedAction, iouAction, isNewChatReport, isNewIOUReport) { + const optimisticData = [ + { + // Use SET for new reports because it doesn't exist yet, is faster and we need the data to be available when we navigate to the chat page + onyxMethod: isNewChatReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + ...chatReport, + lastReadTime: DateUtils.getDBTime(), + hasOutstandingIOU: iouReport.total !== 0, + iouReportID: iouReport.reportID, + ...(isNewChatReport ? {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}} : {}), + }, + }, + { + onyxMethod: isNewIOUReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + ...iouReport, + lastMessageText: iouAction.message[0].text, + lastMessageHtml: iouAction.message[0].html, + ...(isNewIOUReport ? {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}} : {}), + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transaction, + }, + ...(isNewChatReport + ? [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [chatCreatedAction.reportActionID]: chatCreatedAction, + }, + }, + ] + : []), + { + onyxMethod: isNewIOUReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + ...(isNewIOUReport ? {[iouCreatedAction.reportActionID]: iouCreatedAction} : {}), + [iouAction.reportActionID]: iouAction, + }, + }, + ]; + + const successData = [ + ...(isNewChatReport + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [chatCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ] + : []), + ...(isNewIOUReport + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + ] + : []), + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: {pendingAction: null}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + ...(isNewIOUReport + ? { + [iouCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + } + : {}), + [iouAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + hasOutstandingIOU: chatReport.hasOutstandingIOU, + iouReportID: chatReport.iouReportID, + lastReadTime: chatReport.lastReadTime, + ...(isNewChatReport + ? { + errorFields: { + createChat: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), + }, + }, + } + : {}), + }, + }, + ...(isNewIOUReport + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + errorFields: { + createChat: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), + }, + }, + }, + }, + ] + : []), + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ...(isNewChatReport + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [chatCreatedAction.reportActionID]: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, + }, + }, + ] + : []), + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + ...(isNewIOUReport + ? { + [iouCreatedAction.reportActionID]: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, + } + : { + [iouAction.reportActionID]: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, + }), + }, + }, + ]; + + return [optimisticData, successData, failureData]; +} + /** * Request money from another user * @@ -55,9 +249,11 @@ Onyx.connect({ */ function requestMoney(report, amount, currency, payeeEmail, participant, comment) { const payerEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login); - let chatReport = lodashGet(report, 'reportID', null) ? report : null; const isPolicyExpenseChat = participant.isPolicyExpenseChat || participant.isOwnPolicyExpenseChat; - let isNewChat = false; + + // STEP 1: Get existing chat report OR build a new optimistic one + let isNewChatReport = false; + let chatReport = lodashGet(report, 'reportID', null) ? report : null; // If this is a policyExpenseChat, the chatReport must exist and we can get it from Onyx. // report is null if the flow is initiated from the global create menu. However, participant always stores the reportID if it exists, which is the case for policyExpenseChats @@ -68,52 +264,40 @@ function requestMoney(report, amount, currency, payeeEmail, participant, comment if (!chatReport) { chatReport = ReportUtils.getChatByParticipants([payerEmail]); } + + // If we still don't have a report, it likely doens't exist and we need to build an optimistic one if (!chatReport) { + isNewChatReport = true; chatReport = ReportUtils.buildOptimisticChatReport([payerEmail]); - isNewChat = true; } - let moneyRequestReport; - if (chatReport.iouReportID) { + + // STEP 2: Get existing IOU report and update its total OR build a new optimistic one + const isNewIOUReport = !chatReport.iouReportID; + let iouReport; + + if (!isNewIOUReport) { if (isPolicyExpenseChat) { - moneyRequestReport = {...iouReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`]}; + iouReport = {...iouReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`]}; // Because of the Expense reports are stored as negative values, we substract the total from the amount - moneyRequestReport.total = ReportUtils.isExpenseReport(moneyRequestReport) ? moneyRequestReport.total - amount : moneyRequestReport.total + amount; + iouReport.total -= amount; } else { - moneyRequestReport = IOUUtils.updateIOUOwnerAndTotal(iouReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`], payeeEmail, amount, currency); + iouReport = IOUUtils.updateIOUOwnerAndTotal(iouReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`], payeeEmail, amount, currency); } } else { - moneyRequestReport = isPolicyExpenseChat + iouReport = isPolicyExpenseChat ? ReportUtils.buildOptimisticExpenseReport(chatReport.reportID, chatReport.policyID, payeeEmail, amount, currency) : ReportUtils.buildOptimisticIOUReport(payeeEmail, payerEmail, amount, chatReport.reportID, currency); } - const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(amount, currency, moneyRequestReport.reportID, comment); - const optimisticTransactionData = { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, - value: optimisticTransaction, - }; - const transactionSuccessData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, - value: { - pendingAction: null, - }, - }; - const transactionFailureData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, - value: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, - }, - }; + // STEP 3: Build optimistic transaction + const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(amount, currency, iouReport.reportID, comment); - // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat - const optimisticCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail); - const optimisticReportAction = ReportUtils.buildOptimisticIOUReportAction( + // STEP 4: Build optimistic reportActions. We need a CREATED action for each report and an IOU action for the IOU report + // Note: The CREATED action for the IOU report must be optimistically generated before the IOU action so there's no chance that it appears after the IOU action in the chat + const optimisticCreatedActionForChat = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail); + const optimisticCreatedActionForIOU = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail); + const optimisticIOUAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount, currency, @@ -121,112 +305,22 @@ function requestMoney(report, amount, currency, payeeEmail, participant, comment [participant], optimisticTransaction.transactionID, '', - moneyRequestReport.reportID, + iouReport.reportID, ); - // First, add data that will be used in all cases - const optimisticChatReportData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, - value: { - ...chatReport, - lastReadTime: DateUtils.getDBTime(), - lastMessageText: optimisticReportAction.message[0].text, - lastMessageHtml: optimisticReportAction.message[0].html, - hasOutstandingIOU: moneyRequestReport.total !== 0, - iouReportID: moneyRequestReport.reportID, - }, - }; - - const optimisticIOUReportData = { - onyxMethod: chatReport.hasOutstandingIOU ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport.reportID}`, - value: moneyRequestReport, - }; - - const optimisticReportActionsData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, - value: { - [optimisticReportAction.reportActionID]: optimisticReportAction, - }, - }; - - let chatReportSuccessData = {}; - const reportActionsSuccessData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, - value: { - [optimisticReportAction.reportActionID]: { - pendingAction: null, - }, - }, - }; - - const chatReportFailureData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, - value: { - hasOutstandingIOU: chatReport.hasOutstandingIOU, - }, - }; - - const reportActionsFailureData = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, - value: { - [optimisticReportAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, - }, - }, - }; - - // Now, let's add the data we need just when we are creating a new chat report - if (isNewChat) { - // Change the method to set for new reports because it doesn't exist yet, is faster, - // and we need the data to be available when we navigate to the chat page - optimisticChatReportData.onyxMethod = Onyx.METHOD.SET; - optimisticIOUReportData.onyxMethod = Onyx.METHOD.SET; - optimisticReportActionsData.onyxMethod = Onyx.METHOD.SET; - - // Then add and clear pending fields from the chat report - optimisticChatReportData.value.pendingFields = {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; - chatReportSuccessData = { - onyxMethod: Onyx.METHOD.MERGE, - key: optimisticChatReportData.key, - value: { - pendingFields: null, - errorFields: null, - }, - }; - chatReportFailureData.value.pendingFields = {createChat: null}; - delete chatReportFailureData.value.hasOutstandingIOU; - chatReportFailureData.value.errorFields = { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, - }; - - // Then add an optimistic created action - optimisticReportActionsData.value[optimisticCreatedAction.reportActionID] = optimisticCreatedAction; - reportActionsSuccessData.value[optimisticCreatedAction.reportActionID] = {pendingAction: null}; - - // Failure data should feature red brick road - reportActionsFailureData.value[optimisticCreatedAction.reportActionID] = {pendingAction: null}; - reportActionsFailureData.value[optimisticReportAction.reportActionID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; - } - - const optimisticData = [optimisticChatReportData, optimisticIOUReportData, optimisticReportActionsData, optimisticTransactionData]; - - const successData = [reportActionsSuccessData, transactionSuccessData]; - if (!_.isEmpty(chatReportSuccessData)) { - successData.push(chatReportSuccessData); - } - - const failureData = [chatReportFailureData, reportActionsFailureData, transactionFailureData]; + // STEP 5: Build Onyx Data + const [optimisticData, successData, failureData] = buildOnyxDataForMoneyRequest( + chatReport, + iouReport, + optimisticTransaction, + optimisticCreatedActionForChat, + optimisticCreatedActionForIOU, + optimisticIOUAction, + isNewChatReport, + isNewIOUReport, + ); + // STEP 6: Make the request const parsedComment = ReportUtils.getParsedComment(comment); API.write( 'RequestMoney', @@ -235,11 +329,12 @@ function requestMoney(report, amount, currency, payeeEmail, participant, comment amount, currency, comment: parsedComment, - iouReportID: moneyRequestReport.reportID, + iouReportID: iouReport.reportID, chatReportID: chatReport.reportID, transactionID: optimisticTransaction.transactionID, - reportActionID: optimisticReportAction.reportActionID, - createdReportActionID: isNewChat ? optimisticCreatedAction.reportActionID : 0, + reportActionID: optimisticIOUAction.reportActionID, + createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : 0, + createdIOUReportActionID: isNewIOUReport ? optimisticCreatedActionForIOU.reportActionID : 0, }, {optimisticData, successData, failureData}, ); @@ -400,10 +495,10 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment const existingOneOnOneChatReport = !hasMultipleParticipants && !existingGroupChatReportID ? groupChatReport : ReportUtils.getChatByParticipants([email]); const oneOnOneChatReport = existingOneOnOneChatReport || ReportUtils.buildOptimisticChatReport([email]); let oneOnOneIOUReport; - let existingIOUReport = null; + let existingOneOnOneIOUReport = null; if (oneOnOneChatReport.iouReportID) { - existingIOUReport = iouReports[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`]; - oneOnOneIOUReport = IOUUtils.updateIOUOwnerAndTotal(existingIOUReport, currentUserEmail, splitAmount, currency); + existingOneOnOneIOUReport = iouReports[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`]; + oneOnOneIOUReport = IOUUtils.updateIOUOwnerAndTotal(existingOneOnOneIOUReport, currentUserEmail, splitAmount, currency); oneOnOneChatReport.hasOutstandingIOU = oneOnOneIOUReport.total !== 0; } else { oneOnOneIOUReport = ReportUtils.buildOptimisticIOUReport(currentUserEmail, email, splitAmount, oneOnOneChatReport.reportID, currency); @@ -421,8 +516,9 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment ); // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat - const oneOnOneCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail); - const oneOnOneIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( + const oneOnOneCreatedActionForChat = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail); + const oneOnOneCreatedActionForIOU = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail); + const oneOnOneIOUAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.CREATE, splitAmount, currency, @@ -433,27 +529,26 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment oneOnOneIOUReport.reportID, ); - oneOnOneChatReport.lastMessageText = oneOnOneIOUReportAction.message[0].text; - oneOnOneChatReport.lastMessageHtml = oneOnOneIOUReportAction.message[0].html; - - if (!existingOneOnOneChatReport) { - oneOnOneChatReport.pendingFields = { - createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }; - } - optimisticData.push( { onyxMethod: existingOneOnOneChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.reportID}`, - value: oneOnOneChatReport, + value: { + ...oneOnOneChatReport, + lastReadTime: DateUtils.getDBTime(), + hasOutstandingIOU: oneOnOneIOUReport.total !== 0, + iouReportID: oneOnOneIOUReport.reportID, + ...(existingOneOnOneChatReport ? {} : {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}), + }, }, { - onyxMethod: existingOneOnOneChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneChatReport.reportID}`, + onyxMethod: existingOneOnOneIOUReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneIOUReport.reportID}`, value: { - ...(existingOneOnOneChatReport ? {} : {[oneOnOneCreatedReportAction.reportActionID]: oneOnOneCreatedReportAction}), - [oneOnOneIOUReportAction.reportActionID]: oneOnOneIOUReportAction, + ...oneOnOneIOUReport, + lastMessageText: oneOnOneIOUAction.message[0].text, + lastMessageHtml: oneOnOneIOUAction.message[0].html, + ...(existingOneOnOneIOUReport ? {} : {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}), }, }, { @@ -461,44 +556,121 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment key: `${ONYXKEYS.COLLECTION.TRANSACTION}${oneOnOneTransaction.transactionID}`, value: oneOnOneTransaction, }, - ); - - successData.push( + ...(existingOneOnOneChatReport + ? [] + : [ + { + onyxMethod: existingOneOnOneChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneChatReport.reportID}`, + value: { + [oneOnOneCreatedActionForChat.reportActionID]: oneOnOneCreatedActionForChat, + }, + }, + ]), { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneChatReport.reportID}`, + onyxMethod: existingOneOnOneIOUReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneIOUReport.reportID}`, value: { - ...(existingOneOnOneChatReport ? {} : {[oneOnOneCreatedReportAction.reportActionID]: {pendingAction: null}}), - [oneOnOneIOUReportAction.reportActionID]: {pendingAction: null}, + ...(existingOneOnOneIOUReport ? {} : {[oneOnOneCreatedActionForIOU.reportActionID]: oneOnOneCreatedActionForIOU}), + [oneOnOneIOUAction.reportActionID]: oneOnOneIOUAction, }, }, + ); + + successData.push( + ...(existingOneOnOneChatReport + ? [] + : [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneChatReport.reportID}`, + value: { + [oneOnOneCreatedActionForChat.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ]), + ...(existingOneOnOneIOUReport + ? [] + : [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneIOUReport.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + ]), { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${oneOnOneTransaction.transactionID}`, value: {pendingAction: null}, }, - ); - - if (!existingOneOnOneChatReport) { - successData.push({ + { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.reportID}`, - value: {pendingFields: {createChat: null}}, - }); - } + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneIOUReport.reportID}`, + value: { + ...(existingOneOnOneIOUReport + ? {} + : { + [oneOnOneCreatedActionForIOU.reportActionID]: { + pendingAction: null, + errors: null, + }, + }), + [oneOnOneIOUAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ); failureData.push( { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneChatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.reportID}`, value: { - [oneOnOneIOUReportAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, - }, + hasOutstandingIOU: oneOnOneChatReport.hasOutstandingIOU, + lastReadTime: oneOnOneChatReport.lastReadTime, + iouReportID: oneOnOneChatReport.iouReportID, + ...(existingOneOnOneChatReport + ? {} + : { + errorFields: { + createChat: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), + }, + }, + }), }, }, + ...(existingOneOnOneIOUReport + ? [] + : [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneIOUReport.reportID}`, + value: { + errorFields: { + createChat: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), + }, + }, + }, + }, + ]), { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${oneOnOneTransaction.transactionID}`, @@ -508,23 +680,43 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment }, }, }, - ); - - if (!existingOneOnOneChatReport) { - failureData.push({ + ...(existingOneOnOneChatReport + ? [] + : [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneChatReport.reportID}`, + value: { + [oneOnOneCreatedActionForChat.reportActionID]: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, + }, + }, + ]), + { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneIOUReport.reportID}`, value: { - hasOutstandingIOU: existingOneOnOneChatReport ? existingOneOnOneChatReport.hasOutstandingIOU : false, - iouReportID: existingOneOnOneChatReport ? existingOneOnOneChatReport.iouReportID : null, - errorFields: { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, - }, + ...(existingOneOnOneIOUReport + ? { + [oneOnOneIOUAction.reportActionID]: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, + } + : { + [oneOnOneCreatedActionForIOU.reportActionID]: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, + }), }, - }); - } + }, + ); // Regardless of the number of participants, we always want to push the iouReport update to onyxData optimisticData.push({ @@ -539,7 +731,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment failureData.push({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneIOUReport.reportID}`, - value: existingIOUReport || oneOnOneIOUReport, + value: existingOneOnOneIOUReport || oneOnOneIOUReport, }); const splitData = { @@ -548,11 +740,13 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment iouReportID: oneOnOneIOUReport.reportID, chatReportID: oneOnOneChatReport.reportID, transactionID: oneOnOneTransaction.transactionID, - reportActionID: oneOnOneIOUReportAction.reportActionID, + createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID, + createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID, + reportActionID: oneOnOneIOUAction.reportActionID, }; - if (!_.isEmpty(oneOnOneCreatedReportAction)) { - splitData.createdReportActionID = oneOnOneCreatedReportAction.reportActionID; + if (!_.isEmpty(oneOnOneCreatedActionForChat)) { + splitData.createdReportActionID = oneOnOneCreatedActionForChat.reportActionID; } splits.push(splitData); @@ -644,12 +838,9 @@ function deleteMoneyRequest(chatReportID, iouReportID, moneyRequestAction, shoul const iouReport = iouReports[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; const transactionID = moneyRequestAction.originalMessage.IOUTransactionID; - // Make a copy of the chat report so we don't mutate the original one when updating its values - const chatReport = {...chatReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`]}; - // Get the amount we are deleting const amount = moneyRequestAction.originalMessage.amount; - const optimisticReportAction = ReportUtils.buildOptimisticIOUReportAction( + const optimisticIOUAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.DELETE, amount, moneyRequestAction.originalMessage.currency, @@ -660,28 +851,31 @@ function deleteMoneyRequest(chatReportID, iouReportID, moneyRequestAction, shoul iouReportID, ); - const currentUserEmail = optimisticReportAction.actorEmail; - const updatedIOUReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, currentUserEmail, amount, moneyRequestAction.originalMessage.currency, CONST.IOU.REPORT_ACTION_TYPE.DELETE); - chatReport.lastMessageText = optimisticReportAction.message[0].text; - chatReport.lastMessageHtml = optimisticReportAction.message[0].html; - chatReport.hasOutstandingIOU = updatedIOUReport.total !== 0; + const currentUserEmail = optimisticIOUAction.actorEmail; + let updatedIOUReport = {}; + if (ReportUtils.isExpenseReport(iouReportID)) { + updatedIOUReport = {...iouReport}; + + // Because of the Expense reports are stored as negative values, we add the total from the amount + updatedIOUReport.total += amount; + } else { + updatedIOUReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, currentUserEmail, amount, moneyRequestAction.originalMessage.currency, CONST.IOU.REPORT_ACTION_TYPE.DELETE); + } + updatedIOUReport.lastMessageText = optimisticIOUAction.message[0].text; + updatedIOUReport.lastMessageHtml = optimisticIOUAction.message[0].html; + updatedIOUReport.hasOutstandingIOU = updatedIOUReport.total !== 0; const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, value: { - [optimisticReportAction.reportActionID]: { - ...optimisticReportAction, + [optimisticIOUAction.reportActionID]: { + ...optimisticIOUAction, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, - value: chatReport, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, @@ -696,9 +890,9 @@ function deleteMoneyRequest(chatReportID, iouReportID, moneyRequestAction, shoul const successData = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, value: { - [optimisticReportAction.reportActionID]: { + [optimisticIOUAction.reportActionID]: { pendingAction: null, }, }, @@ -707,24 +901,15 @@ function deleteMoneyRequest(chatReportID, iouReportID, moneyRequestAction, shoul const failureData = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, value: { - [optimisticReportAction.reportActionID]: { + [optimisticIOUAction.reportActionID]: { errors: { [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericDeleteFailureMessage'), }, }, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, - value: { - lastMessageText: chatReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`].lastMessageText, - lastMessageHtml: chatReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`].lastMessageHtml, - hasOutstandingIOU: iouReport.total !== 0, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, @@ -742,14 +927,14 @@ function deleteMoneyRequest(chatReportID, iouReportID, moneyRequestAction, shoul { transactionID, chatReportID, - reportActionID: optimisticReportAction.reportActionID, + reportActionID: optimisticIOUAction.reportActionID, iouReportID: updatedIOUReport.reportID, }, {optimisticData, successData, failureData}, ); if (shouldCloseOnDelete) { - Navigation.navigate(ROUTES.getReportRoute(chatReportID)); + Navigation.navigate(ROUTES.getReportRoute(iouReportID)); } } @@ -841,18 +1026,20 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType ...chatReport, lastReadTime: DateUtils.getDBTime(), lastVisibleActionCreated: optimisticIOUReportAction.created, - lastMessageText: optimisticIOUReportAction.message[0].text, - lastMessageHtml: optimisticIOUReportAction.message[0].html, }, }; const optimisticIOUReportData = { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticIOUReport.reportID}`, - value: optimisticIOUReport, + value: { + ...optimisticIOUReport, + lastMessageText: optimisticIOUReportAction.message[0].text, + lastMessageHtml: optimisticIOUReportAction.message[0].html, + }, }; const optimisticReportActionsData = { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { ...optimisticIOUReportAction, @@ -864,7 +1051,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType const successData = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { pendingAction: null, @@ -881,7 +1068,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType const failureData = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { errors: { @@ -894,7 +1081,6 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, value: { - pendingAction: null, errors: { [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.other'), }, @@ -983,15 +1169,13 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho ...chatReport, lastReadTime: DateUtils.getDBTime(), lastVisibleActionCreated: optimisticIOUReportAction.created, - lastMessageText: optimisticIOUReportAction.message[0].text, - lastMessageHtml: optimisticIOUReportAction.message[0].html, hasOutstandingIOU: false, iouReportID: null, }, }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { ...optimisticIOUReportAction, @@ -1004,6 +1188,8 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, value: { ...iouReport, + lastMessageText: optimisticIOUReportAction.message[0].text, + lastMessageHtml: optimisticIOUReportAction.message[0].html, hasOutstandingIOU: false, stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, }, @@ -1018,7 +1204,7 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho const successData = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { pendingAction: null, @@ -1037,10 +1223,9 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho const failureData = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { - pendingAction: null, errors: { [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.other'), }, @@ -1051,7 +1236,6 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, value: { - pendingAction: null, errors: { [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), }, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 6e8e5e0f962b..268a052d4a4e 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -21,6 +21,7 @@ import * as ReportActionsUtils from '../ReportActionsUtils'; import * as OptionsListUtils from '../OptionsListUtils'; import * as Localize from '../Localize'; import * as CollectionUtils from '../CollectionUtils'; +import * as EmojiUtils from '../EmojiUtils'; let currentUserEmail; let currentUserAccountID; @@ -41,13 +42,7 @@ let preferredSkinTone; Onyx.connect({ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, callback: (val) => { - // the preferred skin tone is sometimes still "default", although it - // was changed that "default" has become -1. - if (!_.isNull(val) && Number.isInteger(Number(val))) { - preferredSkinTone = val; - } else { - preferredSkinTone = -1; - } + preferredSkinTone = EmojiUtils.getPreferredSkinToneIndex(val); }, }); @@ -218,7 +213,7 @@ function addActions(reportID, text = '', file) { const optimisticReport = { lastVisibleActionCreated: currentTime, - lastMessageText: Str.htmlDecode(lastCommentText), + lastMessageText: lastCommentText, lastActorEmail: currentUserEmail, lastReadTime: currentTime, }; @@ -404,6 +399,20 @@ function openReport(reportID, participantList = [], newReportObject = {}, parent // Add the createdReportActionID parameter to the API call params.createdReportActionID = optimisticCreatedAction.reportActionID; } + + // If we are creating a thread, ensure the report action has childReportID property added + if (newReportObject.parentReportID && parentReportActionID) { + onyxData.optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${newReportObject.parentReportID}`, + value: {[parentReportActionID]: {childReportID: reportID}}, + }); + onyxData.failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${newReportObject.parentReportID}`, + value: {[parentReportActionID]: {childReportID: '0'}}, + }); + } } API.write('OpenReport', params, onyxData); @@ -1018,7 +1027,7 @@ function editReportComment(reportID, originalReportAction, textForNewComment) { const reportComment = parser.htmlToText(htmlForNewComment); const lastMessageText = ReportUtils.formatReportLastMessageText(reportComment); const optimisticReport = { - lastMessageText: Str.htmlDecode(lastMessageText), + lastMessageText, }; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index 3c21a44c35f5..9f8a70a0cd47 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -347,6 +347,13 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON isLoading: false, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CREDENTIALS, + value: { + validateCode, + }, + }, ]; const failureData = [ @@ -367,8 +374,8 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON }; // Conditionally pass a password or validateCode to command since we temporarily allow both flows - if (validateCode) { - params.validateCode = validateCode; + if (validateCode || twoFactorAuthCode) { + params.validateCode = validateCode || credentials.validateCode; } else { params.password = password; } diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index bad023813e8c..0de8dd3bb30c 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -7,6 +7,7 @@ import * as ReportUtils from '../ReportUtils'; import * as Report from './Report'; import Navigation from '../Navigation/Navigation'; import ROUTES from '../../ROUTES'; +import CONST from '../../CONST'; import DateUtils from '../DateUtils'; /** @@ -38,12 +39,12 @@ function createTaskAndNavigate(currentUserEmail, parentReportID, title, descript const taskReportID = optimisticTaskReport.reportID; let optimisticAssigneeAddComment; if (assigneeChatReportID && assigneeChatReportID !== parentReportID) { - optimisticAssigneeAddComment = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assignee, `Assigned a task to you: ${title}`); + optimisticAssigneeAddComment = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assignee, `Assigned a task to you: ${title}`, parentReportID); } // Create the CreatedReportAction on the task const optimisticTaskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(optimisticTaskReport.reportID); - const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assignee, `Created a task: ${title}`); + const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assignee, `Created a task: ${title}`, parentReportID); const currentTime = DateUtils.getDBTime(); @@ -151,6 +152,124 @@ function createTaskAndNavigate(currentUserEmail, parentReportID, title, descript Navigation.navigate(ROUTES.getReportRoute(optimisticTaskReport.reportID)); } +function completeTask(taskReportID, parentReportID, taskTitle) { + const message = `Completed task: ${taskTitle}`; + const completedTaskReportAction = ReportUtils.buildOptimisticTaskReportAction(taskReportID, CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED, message); + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS.APPROVED, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, + value: { + lastVisibleActionCreated: completedTaskReportAction.created, + lastMessageText: message, + lastActorEmail: completedTaskReportAction.actorEmail, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + value: {[completedTaskReportAction.reportActionID]: completedTaskReportAction}, + }, + ]; + + const successData = []; + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS.OPEN, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + value: {[completedTaskReportAction.reportActionID]: {pendingAction: null}}, + }, + ]; + + API.write( + 'CompleteTask', + { + taskReportID, + completedTaskReportActionID: completedTaskReportAction.reportActionID, + }, + {optimisticData, successData, failureData}, + ); +} + +/** + * Reopens a closed task + * @param {string} taskReportID ReportID of the task + * @param {string} parentReportID ReportID of the linked parent report of the task so we can add the action + * @param {string} taskTitle Title of the task + */ +function reopenTask(taskReportID, parentReportID, taskTitle) { + const message = `Reopened task: ${taskTitle}`; + const reopenedTaskReportAction = ReportUtils.buildOptimisticTaskReportAction(taskReportID, CONST.REPORT.ACTIONS.TYPE.TASKREOPENED, message); + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS.OPEN, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, + value: { + lastVisibleActionCreated: reopenedTaskReportAction.created, + lastMessageText: message, + lastActorEmail: reopenedTaskReportAction.actorEmail, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + value: {[reopenedTaskReportAction.reportActionID]: reopenedTaskReportAction}, + }, + ]; + + const successData = []; + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS.APPROVED, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + value: {[reopenedTaskReportAction.reportActionID]: {pendingAction: null}}, + }, + ]; + + API.write( + 'ReopenTask', + { + taskReportID, + reopenedTaskReportActionID: reopenedTaskReportAction.reportActionID, + }, + {optimisticData, successData, failureData}, + ); +} + /** * @function editTask * @param {object} report @@ -172,7 +291,7 @@ function editTaskAndNavigate(report, ownerEmail, title, description, assignee) { // If we make a change to the assignee, we want to add a comment to the assignee's chat let optimisticAssigneeAddComment; let assigneeChatReportID; - if (assignee && assignee !== report.assignee) { + if (assignee && assignee !== report.managerEmail) { assigneeChatReportID = ReportUtils.getChatByParticipants([assignee]).reportID; optimisticAssigneeAddComment = ReportUtils.buildOptimisticTaskCommentReportAction(report.reportID, reportName, assignee, `Assigned a task to you: ${reportName}`); } @@ -189,7 +308,7 @@ function editTaskAndNavigate(report, ownerEmail, title, description, assignee) { value: { reportName, description: description || report.description, - assignee: assignee || report.assignee, + managerEmail: assignee || report.managerEmail, }, }, ]; @@ -383,6 +502,71 @@ function getShareDestination(reportID, reports, personalDetails) { }; } +/** + * Cancels a task by setting the report state to SUBMITTED and status to CLOSED + * @param {string} taskReportID + * @param {string} parentReportID + * @param {string} taskTitle + * @param {number} originalStateNum + * @param {number} originalStatusNum + */ +function cancelTask(taskReportID, parentReportID, taskTitle, originalStateNum, originalStatusNum) { + const message = `Canceled task: ${taskTitle}`; + const optimisticCancelReportAction = ReportUtils.buildOptimisticTaskReportAction(taskReportID, CONST.REPORT.ACTIONS.TYPE.TASKCANCELED, message); + const optimisticReportActionID = optimisticCancelReportAction.reportActionID; + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS.CLOSED, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, + value: { + lastVisibleActionCreated: optimisticCancelReportAction.created, + lastMessageText: message, + lastActorEmail: optimisticCancelReportAction.actorEmail, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + value: { + [optimisticReportActionID]: optimisticCancelReportAction, + }, + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, + value: { + stateNum: originalStateNum, + statusNum: originalStatusNum, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + value: { + [optimisticReportActionID]: null, + }, + }, + ]; + + API.write('CancelTask', {taskReportID, optimisticReportActionID}, {optimisticData, failureData}); +} + +function isTaskCanceled(taskReport) { + return taskReport.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && taskReport.statusNum === CONST.REPORT.STATUS.CLOSED; +} + export { createTaskAndNavigate, editTaskAndNavigate, @@ -393,7 +577,11 @@ export { setAssigneeValue, setShareDestinationValue, clearOutTaskInfo, + reopenTask, + completeTask, clearOutTaskInfoAndNavigate, getAssignee, getShareDestination, + cancelTask, + isTaskCanceled, }; diff --git a/src/libs/isReportMessageAttachment.js b/src/libs/isReportMessageAttachment.js index e524d5f79f64..db7df0df6c89 100644 --- a/src/libs/isReportMessageAttachment.js +++ b/src/libs/isReportMessageAttachment.js @@ -8,5 +8,10 @@ import CONST from '../CONST'; * @returns {Boolean} */ export default function isReportMessageAttachment({text, html}) { - return text === CONST.ATTACHMENT_MESSAGE_TEXT && html !== CONST.ATTACHMENT_MESSAGE_TEXT; + if (!text || !html) { + return false; + } + + const regex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)"`, 'i'); + return text === CONST.ATTACHMENT_MESSAGE_TEXT && !!html.match(regex); } diff --git a/src/pages/ReimbursementAccount/BankAccountManualStep.js b/src/pages/ReimbursementAccount/BankAccountManualStep.js index 2e49deecb3bb..0cb6a706abf8 100644 --- a/src/pages/ReimbursementAccount/BankAccountManualStep.js +++ b/src/pages/ReimbursementAccount/BankAccountManualStep.js @@ -117,13 +117,7 @@ class BankAccountManualStep extends React.Component { LabelComponent={() => ( {this.props.translate('common.iAcceptThe')} - e.preventDefault()} - > - {this.props.translate('common.expensifyTermsOfService')} - + {this.props.translate('common.expensifyTermsOfService')} )} defaultValue={this.props.getDefaultStateForField('acceptTerms', false)} diff --git a/src/pages/ReimbursementAccount/BankAccountPlaidStep.js b/src/pages/ReimbursementAccount/BankAccountPlaidStep.js index 65b7dea3e395..aade78afd54a 100644 --- a/src/pages/ReimbursementAccount/BankAccountPlaidStep.js +++ b/src/pages/ReimbursementAccount/BankAccountPlaidStep.js @@ -11,6 +11,9 @@ import withLocalize from '../../components/withLocalize'; import compose from '../../libs/compose'; import ONYXKEYS from '../../ONYXKEYS'; import AddPlaidBankAccount from '../../components/AddPlaidBankAccount'; +import CheckboxWithLabel from '../../components/CheckboxWithLabel'; +import TextLink from '../../components/TextLink'; +import Text from '../../components/Text'; import * as ReimbursementAccount from '../../libs/actions/ReimbursementAccount'; import Form from '../../components/Form'; import styles from '../../styles/styles'; @@ -40,9 +43,19 @@ const defaultProps = { class BankAccountPlaidStep extends React.Component { constructor(props) { super(props); + this.validate = this.validate.bind(this); this.submit = this.submit.bind(this); } + validate(values) { + const errorFields = {}; + if (!values.acceptTerms) { + errorFields.acceptTerms = this.props.translate('common.error.acceptTerms'); + } + + return errorFields; + } + submit() { const selectedPlaidBankAccount = _.findWhere(lodashGet(this.props.plaidData, 'bankAccounts', []), { plaidAccountID: lodashGet(this.props.reimbursementAccountDraft, 'plaidAccountID', ''), @@ -83,7 +96,7 @@ class BankAccountPlaidStep extends React.Component { />
({})} + validate={this.validate} onSubmit={this.submit} scrollContextEnabled submitButtonText={this.props.translate('common.saveAndContinue')} @@ -103,6 +116,20 @@ class BankAccountPlaidStep extends React.Component { bankAccountID={bankAccountID} selectedPlaidAccountID={selectedPlaidAccountID} /> + {Boolean(selectedPlaidAccountID) && !_.isEmpty(lodashGet(this.props.plaidData, 'bankAccounts')) && ( + ( + + {this.props.translate('common.iAcceptThe')} + {this.props.translate('common.expensifyTermsOfService')} + + )} + defaultValue={this.props.getDefaultStateForField('acceptTerms', false)} + shouldSaveDraft + /> + )}
); diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js index 8ce21ccabd65..8fbfabe9f524 100644 --- a/src/pages/ShareCodePage.js +++ b/src/pages/ShareCodePage.js @@ -16,6 +16,7 @@ import Clipboard from '../libs/Clipboard'; import * as Expensicons from '../components/Icon/Expensicons'; import getPlatform from '../libs/getPlatform'; import CONST from '../CONST'; +import ContextMenuItem from '../components/ContextMenuItem'; const propTypes = { /** The report currently being looked at */ @@ -63,17 +64,18 @@ class ShareCodePage extends React.Component {
- Clipboard.setString(url)} /> {isNative && ( this.qrCodeRef.current?.download()} diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 8ae4a56d4093..47d62048b980 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -26,6 +26,7 @@ import colors from '../../styles/colors'; import reportPropTypes from '../reportPropTypes'; import ONYXKEYS from '../../ONYXKEYS'; import ThreeDotsMenu from '../../components/ThreeDotsMenu'; +import * as Task from '../../libs/actions/Task'; import reportActionPropTypes from './report/reportActionPropTypes'; const propTypes = { @@ -75,9 +76,9 @@ const HeaderView = (props) => { const isChatRoom = ReportUtils.isChatRoom(props.report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.report); const isTaskReport = ReportUtils.isTaskReport(props.report); - const reportHeaderData = isTaskReport && !_.isEmpty(props.parentReport) && (!isThread || isTaskReport) ? props.parentReport : props.report; + const reportHeaderData = (isTaskReport || !isThread) && !_.isEmpty(props.parentReport) ? props.parentReport : props.report; const title = ReportUtils.getReportName(reportHeaderData); - const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); + const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData, props.parentReport); const isConcierge = participants.length === 1 && _.contains(participants, CONST.EMAIL.CONCIERGE); const isAutomatedExpensifyAccount = participants.length === 1 && ReportUtils.hasAutomatedExpensifyEmails(participants); const guideCalendarLink = lodashGet(props.account, 'guideCalendarLink'); @@ -85,17 +86,13 @@ const HeaderView = (props) => { // We hide the button when we are chatting with an automated Expensify account since it's not possible to contact // these users via alternative means. It is possible to request a call with Concierge so we leave the option for them. const shouldShowCallButton = (isConcierge && guideCalendarLink) || (!isAutomatedExpensifyAccount && !isTaskReport); - const shouldShowThreeDotsButton = isTaskReport; const threeDotMenuItems = []; - - if (shouldShowThreeDotsButton) { + if (isTaskReport) { if (props.report.stateNum === CONST.REPORT.STATE_NUM.OPEN && props.report.statusNum === CONST.REPORT.STATUS.OPEN) { threeDotMenuItems.push({ icon: Expensicons.Checkmark, - text: props.translate('newTaskPage.markAsComplete'), - - // Implementing in https://github.com/Expensify/App/issues/16858 - onSelected: () => {}, + text: props.translate('newTaskPage.markAsDone'), + onSelected: () => Task.completeTask(props.report.reportID, props.report.parentReportID, title), }); } @@ -104,9 +101,7 @@ const HeaderView = (props) => { threeDotMenuItems.push({ icon: Expensicons.Checkmark, text: props.translate('newTaskPage.markAsIncomplete'), - - // Implementing in https://github.com/Expensify/App/issues/16858 - onSelected: () => {}, + onSelected: () => Task.reopenTask(props.report.reportID, props.report.parentReportID, title), }); } @@ -115,12 +110,11 @@ const HeaderView = (props) => { threeDotMenuItems.push({ icon: Expensicons.Trashcan, text: props.translate('common.cancel'), - - // Implementing in https://github.com/Expensify/App/issues/16857 - onSelected: () => {}, + onSelected: () => Task.cancelTask(props.report.reportID, props.report.parentReportID, props.report.reportName, props.report.stateNum, props.report.statusNum), }); } } + const shouldShowThreeDotsButton = !!threeDotMenuItems.length; const avatarTooltip = isChatRoom ? undefined : _.pluck(displayNamesWithTooltips, 'tooltip'); const shouldShowSubscript = isPolicyExpenseChat && !props.report.isOwnPolicyExpenseChat && !ReportUtils.isArchivedRoom(props.report) && !isTaskReport; @@ -128,7 +122,7 @@ const HeaderView = (props) => { const brickRoadIndicator = ReportUtils.hasReportNameError(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; return ( @@ -234,7 +228,11 @@ export default compose( withOnyx({ account: { key: ONYXKEYS.ACCOUNT, - selector: (account) => account && {guideCalendarLink: account.guideCalendarLink}, + selector: (account) => + account && { + guideCalendarLink: account.guideCalendarLink, + primaryLogin: account.primaryLogin, + }, }, parentReportActions: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index e006c568cf66..4b300085bf99 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -38,7 +38,7 @@ import withNavigationFocus from '../../components/withNavigationFocus'; import getIsReportFullyVisible from '../../libs/getIsReportFullyVisible'; import EmojiPicker from '../../components/EmojiPicker/EmojiPicker'; import * as EmojiPickerAction from '../../libs/actions/EmojiPickerAction'; -import TaskHeaderView from './TaskHeaderView'; +import TaskHeader from '../../components/TaskHeader'; import MoneyRequestHeader from '../../components/MoneyRequestHeader'; import * as ComposerActions from '../../libs/actions/Composer'; @@ -231,7 +231,6 @@ class ReportScreen extends React.Component { const addWorkspaceRoomOrChatPendingAction = lodashGet(this.props.report, 'pendingFields.addWorkspaceRoom') || lodashGet(this.props.report, 'pendingFields.createChat'); const addWorkspaceRoomOrChatErrors = lodashGet(this.props.report, 'errorFields.addWorkspaceRoom') || lodashGet(this.props.report, 'errorFields.createChat'); const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: this.props.viewportOffsetTop}]; - const isTaskReport = ReportUtils.isTaskReport(this.props.report); // There are no reportActions at all to display and we are still in the process of loading the next set of actions. const isLoadingInitialReportActions = _.isEmpty(this.props.reportActions) && this.props.report.isLoadingReportActions; @@ -271,7 +270,7 @@ class ReportScreen extends React.Component { } > )} + + {ReportUtils.isTaskReport(this.props.report) && ( + + )} {Boolean(this.props.accountManagerReportID) && ReportUtils.isConciergeChatReport(this.props.report) && this.state.isBannerVisible && ( )} - {isTaskReport && } )} { - TaskUtils.clearOutTaskInfo(); - TaskUtils.setTaskReport(props.report); - if (!props.report.assignee) { - return; - } - const assigneeDetails = lodashGet(props.personalDetails, props.report.assignee); - const displayDetails = TaskUtils.getAssignee(assigneeDetails); - setAssignee(displayDetails); - }, [props]); - return ( - <> - {props.report.assignee ? ( - - Navigation.navigate(ROUTES.getTaskReportAssigneeRoute(props.report.reportID))} - label="common.to" - isNewTask={false} - /> - - ) : ( - Navigation.navigate(ROUTES.getTaskReportAssigneeRoute(props.report.reportID))} - /> - )} - Navigation.navigate(ROUTES.getTaskReportTitleRoute(props.report.reportID))} - /> - Navigation.navigate(ROUTES.getTaskReportDescriptionRoute(props.report.reportID))} - /> - - ); -} - -TaskHeaderView.defaultProps = defaultProps; -TaskHeaderView.propTypes = propTypes; -TaskHeaderView.displayName = 'TaskHeaderView'; - -export default compose( - withLocalize, - withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS, - }, - }), -)(TaskHeaderView); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js index 8467cc574370..d307d06b7984 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js @@ -90,7 +90,7 @@ class BaseReportActionContextMenu extends React.Component { return ( type === CONTEXT_MENU_TYPES.REPORT_ACTION && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU && + reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.TASKCANCELED && + reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED && + reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.TASKREOPENED && !ReportActionUtils.isCreatedTaskReportAction(reportAction) && !ReportUtils.isReportMessageAttachment(_.last(lodashGet(reportAction, ['message'], [{}]))), @@ -193,7 +196,8 @@ export default [ successIcon: Expensicons.Checkmark, successTextTranslateKey: 'reportActionContextMenu.copied', shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget) => { - const isAttachment = ReportUtils.isReportMessageAttachment(_.last(lodashGet(reportAction, ['message'], [{}]))); + const message = _.last(lodashGet(reportAction, 'message', [{}])); + const isAttachment = _.has(reportAction, 'isAttachment') ? reportAction.isAttachment : ReportUtils.isReportMessageAttachment(message); // Only hide the copylink menu item when context menu is opened over img element. const isAttachmentTarget = lodashGet(menuTarget, 'tagName') === 'IMG' && isAttachment; @@ -243,7 +247,7 @@ export default [ getDescription: () => {}, }, { - textTranslateKey: 'reportActionContextMenu.deleteComment', + textTranslateKey: 'reportActionContextMenu.deleteAction', icon: Expensicons.Trashcan, shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport) => // Until deleting parent threads is supported in FE, we will prevent the user from deleting a thread parent diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 708e534d71c8..dfdf559e79c7 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -8,6 +8,8 @@ import PopoverWithMeasuredContent from '../../../../components/PopoverWithMeasur import BaseReportActionContextMenu from './BaseReportActionContextMenu'; import ConfirmModal from '../../../../components/ConfirmModal'; import CONST from '../../../../CONST'; +import * as ReportActionsUtils from '../../../../libs/ReportActionsUtils'; +import * as IOU from '../../../../libs/actions/IOU'; const propTypes = { ...withLocalizePropTypes, @@ -249,7 +251,12 @@ class PopoverReportActionContextMenu extends React.Component { confirmDeleteAndHideModal() { this.callbackWhenDeleteModalHide = () => (this.onComfirmDeleteModal = this.runAndResetCallback(this.onComfirmDeleteModal)); - Report.deleteReportComment(this.state.reportID, this.state.reportAction); + + if (ReportActionsUtils.isMoneyRequestAction(this.state.reportAction)) { + IOU.deleteMoneyRequest(this.state.reportID, this.state.reportAction.originalMessage.IOUReportID, this.state.reportAction, true); + } else { + Report.deleteReportComment(this.state.reportID, this.state.reportAction); + } this.setState({isDeleteCommentConfirmModalVisible: false}); } @@ -257,7 +264,6 @@ class PopoverReportActionContextMenu extends React.Component { this.callbackWhenDeleteModalHide = () => (this.onCancelDeleteModal = this.runAndResetCallback(this.onCancelDeleteModal)); this.setState({ reportID: '0', - reportAction: {}, isDeleteCommentConfirmModalVisible: false, shouldSetModalVisibilityForDeleteConfirmation: true, isArchivedRoom: false, @@ -313,13 +319,13 @@ class PopoverReportActionContextMenu extends React.Component { /> { // EmojiRowCount is number of emoji suggestions. For small screen we can fit 3 items and for large we show up to 5 items const emojiRowCount = isAutoSuggestionPickerLarge - ? Math.max(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_ITEMS) - : Math.max(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_ITEMS); + ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_ITEMS) + : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_ITEMS); // -1 because we start at 0 return emojiRowCount - 1; @@ -584,6 +585,12 @@ class ReportActionCompose extends React.Component { const commentAfterColonWithEmojiNameRemoved = this.state.value.slice(this.state.selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); this.updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); + // In some Android phones keyboard, the text to search for the emoji is not cleared + // will be added after the user starts typing again on the keyboard. This package is + // a workaround to reset the keyboard natively. + if (RNTextInputReset) { + RNTextInputReset.resetKeyboardInput(findNodeHandle(this.textInput)); + } this.setState((prevState) => ({ selection: { start: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, @@ -591,7 +598,8 @@ class ReportActionCompose extends React.Component { }, suggestedEmojis: [], })); - EmojiUtils.addToFrequentlyUsedEmojis(emojiObject); + const frequentEmojiList = EmojiUtils.getFrequentlyUsedEmojis(emojiObject); + User.updateFrequentlyUsedEmojis(frequentEmojiList); } /** @@ -624,13 +632,13 @@ class ReportActionCompose extends React.Component { * @param {String} emoji */ addEmojiToTextBox(emoji) { + this.updateComment(ComposerUtils.insertText(this.comment, this.state.selection, emoji)); this.setState((prevState) => ({ selection: { start: prevState.selection.start + emoji.length, end: prevState.selection.start + emoji.length, }, })); - this.updateComment(ComposerUtils.insertText(this.comment, this.state.selection, emoji)); } /** @@ -683,7 +691,12 @@ class ReportActionCompose extends React.Component { * @param {Boolean} shouldDebounceSaveComment */ updateComment(comment, shouldDebounceSaveComment) { - const newComment = EmojiUtils.replaceEmojis(comment, this.props.isSmallScreenWidth, this.props.preferredSkinTone); + const {text: newComment = '', emojis = []} = EmojiUtils.replaceEmojis(comment, this.props.isSmallScreenWidth, this.props.preferredSkinTone); + + if (!_.isEmpty(emojis)) { + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(emojis)); + } + this.setState((prevState) => { const newState = { isCommentEmpty: !!newComment.match(/^(\s)*$/), @@ -857,6 +870,7 @@ class ReportActionCompose extends React.Component { const inputPlaceholder = this.getInputPlaceholder(); const shouldUseFocusedColor = !isBlockedFromConcierge && !this.props.disabled && (this.state.isFocused || this.state.isDraggingOver); const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength; + const isFullComposerAvailable = this.state.isFullComposerAvailable && !_.isEmpty(this.state.value); return ( {this.props.isComposerFullSize && ( @@ -918,7 +932,7 @@ class ReportActionCompose extends React.Component { )} - {!this.props.isComposerFullSize && this.state.isFullComposerAvailable && ( + {!this.props.isComposerFullSize && isFullComposerAvailable && ( { @@ -1029,7 +1043,7 @@ class ReportActionCompose extends React.Component { isDisabled={isBlockedFromConcierge || this.props.disabled} selection={this.state.selection} onSelectionChange={this.onSelectionChange} - isFullComposerAvailable={this.state.isFullComposerAvailable} + isFullComposerAvailable={isFullComposerAvailable} setIsFullComposerAvailable={this.setIsFullComposerAvailable} isComposerFullSize={this.props.isComposerFullSize} value={this.state.value} @@ -1182,6 +1196,7 @@ export default compose( }, preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + selector: EmojiUtils.getPreferredSkinToneIndex, }, reports: { key: ONYXKEYS.COLLECTION.REPORT, diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index c27d76ec149d..5c0140e1b3de 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -34,6 +34,7 @@ import * as User from '../../../libs/actions/User'; import * as ReportUtils from '../../../libs/ReportUtils'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import * as ReportActions from '../../../libs/actions/ReportActions'; +import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; import reportPropTypes from '../../reportPropTypes'; import {ShowContextMenuContext} from '../../../components/ShowContextMenuContext'; import focusTextInputAfterAnimation from '../../../libs/focusTextInputAfterAnimation'; @@ -48,7 +49,7 @@ import DisplayNames from '../../../components/DisplayNames'; import personalDetailsPropType from '../../personalDetailsPropType'; import ReportActionItemDraft from './ReportActionItemDraft'; import TaskPreview from '../../../components/ReportActionItem/TaskPreview'; -import * as ReportActionUtils from '../../../libs/ReportActionsUtils'; +import TaskAction from '../../../components/ReportActionItem/TaskAction'; import Permissions from '../../../libs/Permissions'; const propTypes = { @@ -203,7 +204,19 @@ class ReportActionItem extends Component { checkIfContextMenuActive={this.checkIfContextMenuActive} /> ); - } else if (ReportActionUtils.isCreatedTaskReportAction(this.props.action)) { + } else if ( + this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || + this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELED || + this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED + ) { + children = ( + + ); + } else if (ReportActionsUtils.isCreatedTaskReportAction(this.props.action)) { children = ( )} @@ -358,7 +372,7 @@ class ReportActionItem extends Component { pendingAction={this.props.draftMessage ? null : this.props.action.pendingAction} errors={this.props.action.errors} errorRowStyles={[styles.ml10, styles.mr2]} - needsOffscreenAlphaCompositing={this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU} + needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(this.props.action)} > {isWhisper && ( diff --git a/src/pages/home/report/ReportActionItemCreated.js b/src/pages/home/report/ReportActionItemCreated.js index 5508b983bc82..5fcda0903e75 100644 --- a/src/pages/home/report/ReportActionItemCreated.js +++ b/src/pages/home/report/ReportActionItemCreated.js @@ -36,12 +36,12 @@ const defaultProps = { }; const ReportActionItemCreated = (props) => { - const icons = ReportUtils.getIcons(props.report, props.personalDetails); - - if (ReportUtils.isMoneyRequestReport(props.report.reportID) || ReportUtils.isTaskReport(props.report)) { + if (!ReportUtils.isChatReport(props.report)) { return null; } + const icons = ReportUtils.getIcons(props.report, props.personalDetails); + return ( { const newState = {draft: newDraft}; if (draft !== newDraft) { diff --git a/src/pages/home/report/ReportActionItemParentAction.js b/src/pages/home/report/ReportActionItemParentAction.js index 0714265a7899..cc50b62a1774 100644 --- a/src/pages/home/report/ReportActionItemParentAction.js +++ b/src/pages/home/report/ReportActionItemParentAction.js @@ -14,6 +14,7 @@ import compose from '../../../libs/compose'; import withLocalize from '../../../components/withLocalize'; import ReportActionItem from './ReportActionItem'; import reportActionPropTypes from './reportActionPropTypes'; +import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; const propTypes = { /** The id of the report */ @@ -41,6 +42,11 @@ const defaultProps = { const ReportActionItemParentAction = (props) => { const parentReportAction = props.parentReportActions[`${props.report.parentReportActionID}`]; + + // In case of transaction threads, we do not want to render the parent report action. + if (ReportActionsUtils.isTransactionThread(parentReportAction)) { + return null; + } return ( { {props.shouldShowSubscriptAvatar ? ( ) : ( diff --git a/src/pages/home/report/ReportActionItemThread.js b/src/pages/home/report/ReportActionItemThread.js index 9292c4b01c37..07ab422b0586 100644 --- a/src/pages/home/report/ReportActionItemThread.js +++ b/src/pages/home/report/ReportActionItemThread.js @@ -5,11 +5,11 @@ import _ from 'underscore'; import styles from '../../../styles/styles'; import * as Report from '../../../libs/actions/Report'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import CONST from '../../../CONST'; import avatarPropTypes from '../../../components/avatarPropTypes'; import MultipleAvatars from '../../../components/MultipleAvatars'; -import Navigation from '../../../libs/Navigation/Navigation'; -import ROUTES from '../../../ROUTES'; +import compose from '../../../libs/compose'; const propTypes = { /** List of participant icons for the thread */ @@ -24,43 +24,55 @@ const propTypes = { /** ID of child thread report */ childReportID: PropTypes.string.isRequired, - /** localization props */ + /** Whether the thread item / message is being hovered */ + isHovered: PropTypes.bool.isRequired, + ...withLocalizePropTypes, + ...windowDimensionsPropTypes, }; -const ReportActionItemThread = (props) => ( - - { - Report.openReport(props.childReportID); - Navigation.navigate(ROUTES.getReportRoute(props.childReportID)); - }} - > - - icon.name)} - /> - - - {`${props.numberOfReplies} ${props.numberOfReplies === 1 ? props.translate('threads.reply') : props.translate('threads.replies')}`} - - {`${props.translate('threads.lastReply')} ${props.datetimeToCalendarTime(props.mostRecentReply)}`} +const ReportActionItemThread = (props) => { + const numberOfRepliesText = props.numberOfReplies > CONST.MAX_THREAD_REPLIES_PREVIEW ? `${CONST.MAX_THREAD_REPLIES_PREVIEW}+` : `${props.numberOfReplies}`; + const replyText = props.numberOfReplies === 1 ? props.translate('threads.reply') : props.translate('threads.replies'); + + const timeStamp = props.datetimeToCalendarTime(props.mostRecentReply, false, true); + + return ( + + { + Report.navigateToAndOpenChildReport(props.childReportID); + }} + > + + icon.name)} + isHovered={props.isHovered} + isInReportAction + /> + + + {`${numberOfRepliesText} ${replyText}`} + + {`${props.translate('threads.lastReply')} ${timeStamp}`} + - - - -); + + + ); +}; ReportActionItemThread.propTypes = propTypes; ReportActionItemThread.displayName = 'ReportActionItemThread'; -export default withLocalize(ReportActionItemThread); +export default compose(withLocalize, withWindowDimensions)(ReportActionItemThread); diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 15b89cb45082..92811fa7b0fc 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -65,7 +65,7 @@ class ReportFooter extends React.Component { render() { const isArchivedRoom = ReportUtils.isArchivedRoom(this.props.report); - const hideComposer = isArchivedRoom || !_.isEmpty(this.props.errors) || ReportUtils.isTaskReport(this.props.report); + const hideComposer = isArchivedRoom || !_.isEmpty(this.props.errors); return ( <> diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 411626d8a5c3..95e81854a671 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -14,7 +14,6 @@ import compose from '../../../libs/compose'; import Navigation from '../../../libs/Navigation/Navigation'; import ROUTES from '../../../ROUTES'; import Icon from '../../../components/Icon'; -import Header from '../../../components/Header'; import * as Expensicons from '../../../components/Icon/Expensicons'; import AvatarWithIndicator from '../../../components/AvatarWithIndicator'; import Tooltip from '../../../components/Tooltip'; @@ -32,7 +31,11 @@ import reportPropTypes from '../../reportPropTypes'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import withNavigationFocus from '../../../components/withNavigationFocus'; import withCurrentReportId from '../../../components/withCurrentReportId'; +import Header from '../../../components/Header'; +import defaultTheme from '../../../styles/themes/default'; import OptionsListSkeletonView from '../../../components/OptionsListSkeletonView'; +import variables from '../../../styles/variables'; +import LogoComponent from '../../../../assets/images/expensify-wordmark.svg'; const propTypes = { /** Toggles the navigation menu open and closed */ @@ -151,11 +154,15 @@ class SidebarLinks extends React.Component { nativeID="drag-area" >
+ } accessibilityRole="text" shouldShowEnvironmentBadge - textStyles={[styles.textHeadline]} /> currency.currencyCode === this.props.iou.selectedCurrencyCode), 'keyForList', )} + shouldHaveOptionSeparator /> )} diff --git a/src/pages/iou/IOUDetailsModal.js b/src/pages/iou/IOUDetailsModal.js index 3403bf83fb23..f81fe53fb492 100644 --- a/src/pages/iou/IOUDetailsModal.js +++ b/src/pages/iou/IOUDetailsModal.js @@ -186,7 +186,7 @@ class IOUDetailsModal extends Component { 1} + isBillSplit={lodashGet(this.props.chatReport, 'participants', []).length > 1} isIOUAction={false} pendingAction={pendingAction} /> diff --git a/src/pages/settings/Payments/AddDebitCardPage.js b/src/pages/settings/Payments/AddDebitCardPage.js index 08f051e251d2..8b127e65e092 100644 --- a/src/pages/settings/Payments/AddDebitCardPage.js +++ b/src/pages/settings/Payments/AddDebitCardPage.js @@ -192,7 +192,7 @@ class DebitCardPage extends Component { LabelComponent={() => ( {`${this.props.translate('common.iAcceptThe')}`} - {`${this.props.translate('common.expensifyTermsOfService')}`} + {`${this.props.translate('common.expensifyTermsOfService')}`} )} style={[styles.mt4]} diff --git a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js index 3f68ef4dee32..2389d3a3de6b 100644 --- a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js +++ b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js @@ -32,6 +32,7 @@ import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import ConfirmContent from '../../../../components/ConfirmContent'; import Button from '../../../../components/Button'; import themeColors from '../../../../styles/themes/default'; +import variables from '../../../../styles/variables'; class BasePaymentsPage extends React.Component { constructor(props) { @@ -150,11 +151,11 @@ class BasePaymentsPage extends React.Component { */ setPositionAddPaymentMenu(position) { this.setState({ - anchorPositionTop: position.top + position.height, + anchorPositionTop: position.top + position.height + variables.addPaymentPopoverTopSpacing, anchorPositionBottom: this.props.windowHeight - position.top, // We want the position to be 13px to the right of the left border - anchorPositionRight: this.props.windowWidth - position.right + 13, + anchorPositionRight: this.props.windowWidth - position.right + variables.addPaymentPopoverRightSpacing, }); } diff --git a/src/pages/settings/Preferences/PreferencesPage.js b/src/pages/settings/Preferences/PreferencesPage.js index 4fdcef95d07e..03b79d2dcae2 100755 --- a/src/pages/settings/Preferences/PreferencesPage.js +++ b/src/pages/settings/Preferences/PreferencesPage.js @@ -71,6 +71,7 @@ const PreferencesPage = (props) => {
diff --git a/src/pages/settings/Profile/TimezoneInitialPage.js b/src/pages/settings/Profile/TimezoneInitialPage.js index 7c5e68106d5d..1cf7a06f7e95 100644 --- a/src/pages/settings/Profile/TimezoneInitialPage.js +++ b/src/pages/settings/Profile/TimezoneInitialPage.js @@ -55,6 +55,7 @@ const TimezoneInitialPage = (props) => { {props.translate('timezonePage.getLocationAutomatically')} diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 7e3fe92c86c8..83b709ab55bf 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -161,24 +161,25 @@ class BaseValidateCodeForm extends React.Component { validateAndSubmitForm() { const requiresTwoFactorAuth = this.props.account.requiresTwoFactorAuth; - if (!this.state.validateCode.trim()) { - this.setState({formError: {validateCode: 'validateCodeForm.error.pleaseFillMagicCode'}}); - return; - } - - if (!ValidationUtils.isValidValidateCode(this.state.validateCode)) { - this.setState({formError: {validateCode: 'validateCodeForm.error.incorrectMagicCode'}}); - return; - } + if (requiresTwoFactorAuth) { + if (!this.state.twoFactorAuthCode.trim()) { + this.setState({formError: {twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'}}); + return; + } - if (requiresTwoFactorAuth && !this.state.twoFactorAuthCode.trim()) { - this.setState({formError: {twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'}}); - return; - } - - if (requiresTwoFactorAuth && !ValidationUtils.isValidTwoFactorCode(this.state.twoFactorAuthCode)) { - this.setState({formError: {twoFactorAuthCode: 'passwordForm.error.incorrect2fa'}}); - return; + if (!ValidationUtils.isValidTwoFactorCode(this.state.twoFactorAuthCode)) { + this.setState({formError: {twoFactorAuthCode: 'passwordForm.error.incorrect2fa'}}); + return; + } + } else { + if (!this.state.validateCode.trim()) { + this.setState({formError: {validateCode: 'validateCodeForm.error.pleaseFillMagicCode'}}); + return; + } + if (!ValidationUtils.isValidValidateCode(this.state.validateCode)) { + this.setState({formError: {validateCode: 'validateCodeForm.error.incorrectMagicCode'}}); + return; + } } this.setState({ diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 2d439efc9ef4..1ba01ddeae34 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -96,6 +96,12 @@ class WorkspaceMembersPage extends React.Component { this.validate(); } + if (prevProps.policyMemberList !== this.props.policyMemberList) { + this.setState((prevState) => ({ + selectedEmployees: _.intersection(prevState.selectedEmployees, _.keys(this.props.policyMemberList)), + })); + } + const isReconnecting = prevProps.network.isOffline && !this.props.network.isOffline; if (!isReconnecting) { return; diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index 663ca22cc26d..f6627d1558bf 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -33,7 +33,7 @@ const workspaceColorOptions = [ const avatarBorderSizes = { [CONST.AVATAR_SIZE.SMALL_SUBSCRIPT]: variables.componentBorderRadiusSmall, [CONST.AVATAR_SIZE.MID_SUBSCRIPT]: variables.componentBorderRadiusSmall, - [CONST.AVATAR_SIZE.SUBSCRIPT]: variables.componentBorderRadiusSmall, + [CONST.AVATAR_SIZE.SUBSCRIPT]: variables.componentBorderRadiusMedium, [CONST.AVATAR_SIZE.SMALLER]: variables.componentBorderRadiusMedium, [CONST.AVATAR_SIZE.SMALL]: variables.componentBorderRadiusMedium, [CONST.AVATAR_SIZE.HEADER]: variables.componentBorderRadiusMedium, @@ -85,7 +85,7 @@ function getAvatarStyle(size) { /** * Get Font size of '+1' text on avatar overlay * @param {String} size - * @returns {Number} + * @returns {Object} */ function getAvatarExtraFontSizeStyle(size) { const AVATAR_SIZES = { @@ -107,21 +107,23 @@ function getAvatarExtraFontSizeStyle(size) { /** * Get Bordersize of Avatar based on avatar size * @param {String} size - * @returns {Number} + * @returns {Object} */ function getAvatarBorderWidth(size) { const AVATAR_SIZES = { [CONST.AVATAR_SIZE.DEFAULT]: 3, - [CONST.AVATAR_SIZE.SMALL_SUBSCRIPT]: 2, + [CONST.AVATAR_SIZE.SMALL_SUBSCRIPT]: 1, [CONST.AVATAR_SIZE.MID_SUBSCRIPT]: 2, [CONST.AVATAR_SIZE.SUBSCRIPT]: 2, - [CONST.AVATAR_SIZE.SMALL]: 3, + [CONST.AVATAR_SIZE.SMALL]: 2, [CONST.AVATAR_SIZE.SMALLER]: 2, [CONST.AVATAR_SIZE.LARGE]: 4, [CONST.AVATAR_SIZE.MEDIUM]: 3, [CONST.AVATAR_SIZE.LARGE_BORDERED]: 4, }; - return AVATAR_SIZES[size]; + return { + borderWidth: AVATAR_SIZES[size], + }; } /** @@ -323,6 +325,18 @@ function getBackgroundColorStyle(backgroundColor) { }; } +/** + * Returns a style with the specified borderColor + * + * @param {String} borderColor + * @returns {Object} + */ +function getBorderColorStyle(borderColor) { + return { + borderColor, + }; +} + /** * Returns the width style for the wordmark logo on the sign in page * @@ -781,17 +795,18 @@ function getKeyboardShortcutsModalWidth(isSmallScreenWidth) { /** * @param {Boolean} isHovered * @param {Boolean} isPressed + * @param {Boolean} isInReportAction * @returns {Object} */ -function getHorizontalStackedAvatarBorderStyle(isHovered, isPressed) { +function getHorizontalStackedAvatarBorderStyle(isHovered, isPressed, isInReportAction = false) { let backgroundColor = themeColors.appBG; if (isHovered) { - backgroundColor = themeColors.border; + backgroundColor = isInReportAction ? themeColors.highlightBG : themeColors.border; } if (isPressed) { - backgroundColor = themeColors.buttonPressedBG; + backgroundColor = isInReportAction ? themeColors.highlightBG : themeColors.buttonPressedBG; } return { @@ -983,7 +998,7 @@ function getEmojiReactionBubbleStyle(isHovered, hasUserReacted, isContextMenu = } if (hasUserReacted) { - backgroundColor = themeColors.reactionActive; + backgroundColor = themeColors.reactionActiveBackground; } if (isContextMenu) { @@ -1017,7 +1032,7 @@ function getEmojiReactionBubbleTextStyle(isContextMenu = false) { function getEmojiReactionCounterTextStyle(hasUserReacted) { if (hasUserReacted) { - return {color: themeColors.link}; + return {color: themeColors.reactionActiveText}; } return {color: themeColors.textLight}; @@ -1102,6 +1117,7 @@ export { getAutoGrowHeightInputStyle, getBackgroundAndBorderStyle, getBackgroundColorStyle, + getBorderColorStyle, getBackgroundColorWithOpacityStyle, getBadgeColorStyle, getButtonBackgroundColorStyle, diff --git a/src/styles/getModalStyles/getBaseModalStyles.js b/src/styles/getModalStyles/getBaseModalStyles.js index ddc926b3ce09..b7a3317963ca 100644 --- a/src/styles/getModalStyles/getBaseModalStyles.js +++ b/src/styles/getModalStyles/getBaseModalStyles.js @@ -3,9 +3,9 @@ import variables from '../variables'; import themeColors from '../themes/default'; import styles from '../styles'; -const getCenteredModalStyles = (windowWidth, isSmallScreenWidth) => ({ - borderWidth: styles.centeredModalStyles(isSmallScreenWidth).borderWidth, - width: isSmallScreenWidth ? '100%' : windowWidth - styles.centeredModalStyles(isSmallScreenWidth).marginHorizontal * 2, +const getCenteredModalStyles = (windowWidth, isSmallScreenWidth, isFullScreenWhenSmall = false) => ({ + borderWidth: styles.centeredModalStyles(isSmallScreenWidth, isFullScreenWhenSmall).borderWidth, + width: isSmallScreenWidth ? '100%' : windowWidth - styles.centeredModalStyles(isSmallScreenWidth, isFullScreenWhenSmall).marginHorizontal * 2, }); export default (type, windowDimensions, popoverAnchorPosition = {}, innerContainerStyle = {}, outerStyle = {}) => { @@ -118,7 +118,7 @@ export default (type, windowDimensions, popoverAnchorPosition = {}, innerContain marginBottom: isSmallScreenWidth ? 0 : 20, borderRadius: isSmallScreenWidth ? 0 : 12, overflow: 'hidden', - ...getCenteredModalStyles(windowWidth, isSmallScreenWidth), + ...getCenteredModalStyles(windowWidth, isSmallScreenWidth, true), }; swipeDirection = undefined; animationIn = isSmallScreenWidth ? 'slideInRight' : 'fadeIn'; diff --git a/src/styles/styles.js b/src/styles/styles.js index 066bb3157e34..6fdd5feceb35 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -1797,20 +1797,14 @@ const styles = { secondAvatarSubscript: { position: 'absolute', - right: -4, - bottom: -2, - borderWidth: 2, - borderRadius: 18, - borderColor: 'transparent', + right: -6, + bottom: -6, }, secondAvatarSubscriptCompact: { position: 'absolute', bottom: -1, right: -1, - borderWidth: 1, - borderRadius: 18, - borderColor: 'transparent', }, leftSideLargeAvatar: { @@ -1996,8 +1990,8 @@ const styles = { backgroundColor: themeColors.modalBackdrop, }, - centeredModalStyles: (isSmallScreenWidth) => ({ - borderWidth: isSmallScreenWidth ? 1 : 0, + centeredModalStyles: (isSmallScreenWidth, isFullScreenWhenSmall) => ({ + borderWidth: isSmallScreenWidth && !isFullScreenWhenSmall ? 1 : 0, marginHorizontal: isSmallScreenWidth ? 0 : 20, }), @@ -2363,6 +2357,10 @@ const styles = { lineHeight: variables.inputHeight, }, + magicCodeInputTransparent: { + color: 'transparent', + }, + iouAmountText: { ...headlineFont, fontSize: variables.iouAmountTextSize, @@ -3271,6 +3269,21 @@ const styles = { borderWidth: 2, backgroundColor: themeColors.highlightBG, }, + + headerEnvBadge: { + marginLeft: 0, + marginBottom: 2, + height: 12, + paddingLeft: 4, + paddingRight: 4, + alignItems: 'center', + }, + + headerEnvBadgeText: { + fontSize: 7, + fontWeight: fontWeightBold, + lineHeight: undefined, + }, }; export default styles; diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js index 0917d1d5b794..86a89b38e695 100644 --- a/src/styles/themes/default.js +++ b/src/styles/themes/default.js @@ -66,7 +66,8 @@ const darkTheme = { pickerOptionsTextColor: colors.white, imageCropBackgroundColor: colors.greenIcons, fallbackIconColor: colors.green700, - reactionActive: '#003C73', + reactionActiveBackground: colors.green600, + reactionActiveText: colors.green100, badgeAdHoc: colors.pink600, badgeAdHocHover: colors.pink700, mentionText: colors.blue100, diff --git a/src/styles/variables.js b/src/styles/variables.js index b44d846e5f09..a2c5305f7246 100644 --- a/src/styles/variables.js +++ b/src/styles/variables.js @@ -84,7 +84,7 @@ export default { popoverMenuShadow: '0px 4px 12px 0px rgba(0, 0, 0, 0.06)', optionRowHeight: 64, optionRowHeightCompact: 52, - optionsListSectionHeaderHeight: getValueUsingPixelRatio(54, 60), + optionsListSectionHeaderHeight: getValueUsingPixelRatio(32, 38), overlayOpacity: 0.6, lineHeightSmall: getValueUsingPixelRatio(14, 16), lineHeightNormal: getValueUsingPixelRatio(16, 21), @@ -127,10 +127,14 @@ export default { signInLogoWidth: 120, signInLogoWidthLargeScreen: 144, signInLogoWidthPill: 132, + lhnLogoWidth: 108, + lhnLogoHeight: 28, signInLogoWidthLargeScreenPill: 162, modalContentMaxWidth: 360, listItemHeightNormal: 64, popoverWidth: 375, + addPaymentPopoverTopSpacing: 8, + addPaymentPopoverRightSpacing: 13, // The height of the empty list is 14px (2px for borders and 12px for vertical padding) // This is calculated based on the values specified in the 'getGoogleListViewStyle' function of the 'StyleUtils' utility diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 7d18f5fe77d5..aee969e865a1 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -32,7 +32,6 @@ describe('actions/IOU', () => { it('creates new chat if needed', () => { const amount = 10000; const comment = 'Giv money plz'; - let chatReportID; let iouReportID; let createdAction; let iouAction; @@ -55,7 +54,6 @@ describe('actions/IOU', () => { expect(_.size(chatReports)).toBe(1); expect(_.size(iouReports)).toBe(1); const chatReport = chatReports[0]; - chatReportID = chatReport.reportID; const iouReport = iouReports[0]; iouReportID = iouReport.reportID; @@ -73,15 +71,15 @@ describe('actions/IOU', () => { () => new Promise((resolve) => { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, waitForCollectionCallback: true, - callback: (reportActionsForChatReport) => { + callback: (reportActionsForIOUReport) => { Onyx.disconnect(connectionID); - // The chat report should have a CREATED action and IOU action - expect(_.size(reportActionsForChatReport)).toBe(2); - const createdActions = _.filter(reportActionsForChatReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const iouActions = _.filter(reportActionsForChatReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + // The IOU report should have a CREATED action and IOU action + expect(_.size(reportActionsForIOUReport)).toBe(2); + const createdActions = _.filter(reportActionsForIOUReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + const iouActions = _.filter(reportActionsForIOUReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); expect(_.size(createdActions)).toBe(1); expect(_.size(iouActions)).toBe(1); createdAction = createdActions[0]; @@ -152,12 +150,12 @@ describe('actions/IOU', () => { () => new Promise((resolve) => { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, waitForCollectionCallback: true, - callback: (reportActionsForChatReport) => { + callback: (reportActionsForIOUReport) => { Onyx.disconnect(connectionID); - expect(_.size(reportActionsForChatReport)).toBe(2); - _.each(reportActionsForChatReport, (reportAction) => expect(reportAction.pendingAction).toBeFalsy()); + expect(_.size(reportActionsForIOUReport)).toBe(2); + _.each(reportActionsForIOUReport, (reportAction) => expect(reportAction.pendingAction).toBeFalsy()); resolve(); }, }); @@ -236,14 +234,14 @@ describe('actions/IOU', () => { () => new Promise((resolve) => { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, waitForCollectionCallback: true, - callback: (allReportActions) => { + callback: (allIOUReportActions) => { Onyx.disconnect(connectionID); // The chat report should have a CREATED and an IOU action - expect(_.size(allReportActions)).toBe(2); - iouAction = _.find(allReportActions, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(_.size(allIOUReportActions)).toBe(2); + iouAction = _.find(allIOUReportActions, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); // The CREATED action should not be created after the IOU action expect(Date.parse(createdAction.created)).toBeLessThanOrEqual(Date.parse(iouAction.created)); @@ -309,12 +307,12 @@ describe('actions/IOU', () => { () => new Promise((resolve) => { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, waitForCollectionCallback: true, - callback: (reportActionsForChatReport) => { + callback: (reportActionsForIOUReport) => { Onyx.disconnect(connectionID); - expect(_.size(reportActionsForChatReport)).toBe(2); - _.each(reportActionsForChatReport, (reportAction) => expect(reportAction.pendingAction).toBeFalsy()); + expect(_.size(reportActionsForIOUReport)).toBe(2); + _.each(reportActionsForIOUReport, (reportAction) => expect(reportAction.pendingAction).toBeFalsy()); resolve(); }, }); @@ -388,7 +386,7 @@ describe('actions/IOU', () => { return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport) .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, iouReport)) .then(() => - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { [createdAction.reportActionID]: createdAction, [iouAction.reportActionID]: iouAction, }), @@ -427,14 +425,14 @@ describe('actions/IOU', () => { () => new Promise((resolve) => { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, waitForCollectionCallback: true, - callback: (reportActionsForChatReport) => { + callback: (reportActionsForIOUReport) => { Onyx.disconnect(connectionID); - expect(_.size(reportActionsForChatReport)).toBe(3); + expect(_.size(reportActionsForIOUReport)).toBe(3); newIOUAction = _.find( - reportActionsForChatReport, + reportActionsForIOUReport, (reportAction) => reportAction.reportActionID !== createdAction.reportActionID && reportAction.reportActionID !== iouAction.reportActionID, ); @@ -491,12 +489,12 @@ describe('actions/IOU', () => { () => new Promise((resolve) => { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, waitForCollectionCallback: true, - callback: (reportActionsForChatReport) => { + callback: (reportActionsForIOUReport) => { Onyx.disconnect(connectionID); - expect(_.size(reportActionsForChatReport)).toBe(3); - _.each(reportActionsForChatReport, (reportAction) => expect(reportAction.pendingAction).toBeFalsy()); + expect(_.size(reportActionsForIOUReport)).toBe(3); + _.each(reportActionsForIOUReport, (reportAction) => expect(reportAction.pendingAction).toBeFalsy()); resolve(); }, }); @@ -563,15 +561,15 @@ describe('actions/IOU', () => { () => new Promise((resolve) => { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, waitForCollectionCallback: true, - callback: (reportActionsForChatReport) => { + callback: (reportActionsForIOUReport) => { Onyx.disconnect(connectionID); // The chat report should have a CREATED action and IOU action - expect(_.size(reportActionsForChatReport)).toBe(2); - const createdActions = _.filter(reportActionsForChatReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const iouActions = _.filter(reportActionsForChatReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(_.size(reportActionsForIOUReport)).toBe(2); + const createdActions = _.filter(reportActionsForIOUReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + const iouActions = _.filter(reportActionsForIOUReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); expect(_.size(createdActions)).toBe(1); expect(_.size(iouActions)).toBe(1); createdAction = createdActions[0]; @@ -637,12 +635,12 @@ describe('actions/IOU', () => { () => new Promise((resolve) => { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, waitForCollectionCallback: true, - callback: (reportActionsForChatReport) => { + callback: (reportActionsForIOUReport) => { Onyx.disconnect(connectionID); - expect(_.size(reportActionsForChatReport)).toBe(2); - iouAction = _.find(reportActionsForChatReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(_.size(reportActionsForIOUReport)).toBe(2); + iouAction = _.find(reportActionsForIOUReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); expect(iouAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); resolve(); }, @@ -670,7 +668,7 @@ describe('actions/IOU', () => { .then( () => new Promise((resolve) => { - ReportActions.clearReportActionErrors(chatReportID, iouAction); + ReportActions.clearReportActionErrors(iouReportID, iouAction); resolve(); }), ) @@ -801,6 +799,11 @@ describe('actions/IOU', () => { iouReportID: julesIOUReportID, participants: [JULES_EMAIL], }; + const julesChatCreatedAction = { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + }; const julesCreatedAction = { reportActionID: NumberUtils.rand64(), actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, @@ -866,6 +869,9 @@ describe('actions/IOU', () => { [carlosCreatedAction.reportActionID]: carlosCreatedAction, }, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesChatReport.reportID}`]: { + [julesChatCreatedAction.reportActionID]: julesChatCreatedAction, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesIOUReport.reportID}`]: { [julesCreatedAction.reportActionID]: julesCreatedAction, [julesExistingIOUAction.reportActionID]: julesExistingIOUAction, }, @@ -962,17 +968,16 @@ describe('actions/IOU', () => { callback: (allReportActions) => { Onyx.disconnect(connectionID); - // There should be reportActions on all 4 chat reports - expect(_.size(allReportActions)).toBe(4); + // There should be reportActions on all 4 chat reports + 3 IOU reports in each 1:1 chat + expect(_.size(allReportActions)).toBe(7); - const carlosReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${carlosChatReport.reportID}`]; - const julesReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesChatReport.reportID}`]; - const vitReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${vitChatReport.reportID}`]; + const carlosReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${carlosChatReport.iouReportID}`]; + const julesReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesChatReport.iouReportID}`]; + const vitReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${vitChatReport.iouReportID}`]; const groupReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChat.reportID}`]; - // Carlos DM should have two reportActions – the existing CREATED action and an pending IOU action + // Carlos DM should have two reportActions – the existing CREATED action and a pending IOU action expect(_.size(carlosReportActions)).toBe(2); - expect(carlosReportActions[carlosCreatedAction.reportActionID]).toStrictEqual(carlosCreatedAction); carlosIOUAction = _.find(carlosReportActions, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); expect(carlosIOUAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); expect(carlosIOUAction.originalMessage.IOUReportID).toBe(carlosIOUReport.reportID); @@ -1193,9 +1198,9 @@ describe('actions/IOU', () => { callback: (allReportActions) => { Onyx.disconnect(connectionID); - const reportActionsForChatReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`]; + const reportActionsForIOUReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.iouReportID}`]; - createIOUAction = _.find(reportActionsForChatReport, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + createIOUAction = _.find(reportActionsForIOUReport, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); expect(createIOUAction).toBeTruthy(); expect(createIOUAction.originalMessage.IOUReportID).toBe(iouReport.reportID); @@ -1263,11 +1268,11 @@ describe('actions/IOU', () => { callback: (allReportActions) => { Onyx.disconnect(connectionID); - const reportActionsForChatReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`]; - expect(_.size(reportActionsForChatReport)).toBe(3); + const reportActionsForIOUReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`]; + expect(_.size(reportActionsForIOUReport)).toBe(3); payIOUAction = _.find( - reportActionsForChatReport, + reportActionsForIOUReport, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && ra.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY, ); expect(payIOUAction).toBeTruthy(); @@ -1314,11 +1319,11 @@ describe('actions/IOU', () => { callback: (allReportActions) => { Onyx.disconnect(connectionID); - const reportActionsForChatReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`]; - expect(_.size(reportActionsForChatReport)).toBe(3); + const reportActionsForIOUReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`]; + expect(_.size(reportActionsForIOUReport)).toBe(3); payIOUAction = _.find( - reportActionsForChatReport, + reportActionsForIOUReport, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && ra.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY, ); expect(payIOUAction).toBeTruthy(); diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js index 89c0b146e765..8ac94782fd4b 100644 --- a/tests/ui/UnreadIndicatorsTest.js +++ b/tests/ui/UnreadIndicatorsTest.js @@ -153,6 +153,7 @@ function signInAndGetAppWithUnreadChat() { lastVisibleActionCreated: reportAction9CreatedDate, lastMessageText: 'Test', participants: [USER_B_EMAIL], + type: CONST.REPORT.TYPE.CHAT, }); const createdReportActionID = NumberUtils.rand64(); Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { diff --git a/tests/unit/CurrencyUtilsTest.js b/tests/unit/CurrencyUtilsTest.js index c70ec9fb18f5..937bf95044cf 100644 --- a/tests/unit/CurrencyUtilsTest.js +++ b/tests/unit/CurrencyUtilsTest.js @@ -88,11 +88,19 @@ describe('CurrencyUtils', () => { describe('convertToSmallestUnit', () => { test.each([ [CONST.CURRENCY.USD, 25, 2500], + [CONST.CURRENCY.USD, 25.25, 2525], [CONST.CURRENCY.USD, 25.5, 2550], - [CONST.CURRENCY.USD, 25.5, 2550], + [CONST.CURRENCY.USD, 2500, 250000], + [CONST.CURRENCY.USD, 80.6, 8060], + [CONST.CURRENCY.USD, 80.9, 8090], + [CONST.CURRENCY.USD, 80.99, 8099], ['JPY', 25, 25], + ['JPY', 25.25, 25], + ['JPY', 25.5, 26], ['JPY', 2500, 2500], - ['JPY', 25.5, 25], + ['JPY', 80.6, 81], + ['JPY', 80.9, 81], + ['JPY', 80.99, 81], ])('Correctly converts %s to amount in smallest units', (currency, amount, expectedResult) => { expect(CurrencyUtils.convertToSmallestUnit(currency, amount)).toBe(expectedResult); }); diff --git a/tests/unit/EmojiTest.js b/tests/unit/EmojiTest.js index 66a21d124cb3..8fc592ab0fc4 100644 --- a/tests/unit/EmojiTest.js +++ b/tests/unit/EmojiTest.js @@ -1,6 +1,7 @@ import _ from 'underscore'; import moment from 'moment'; import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; import Emoji from '../../assets/emojis'; import * as EmojiUtils from '../../src/libs/EmojiUtils'; import ONYXKEYS from '../../src/ONYXKEYS'; @@ -102,22 +103,22 @@ describe('EmojiTest', () => { it('replaces an emoji code with an emoji and a space on mobile', () => { const text = 'Hi :smile:'; - expect(EmojiUtils.replaceEmojis(text, true)).toBe('Hi 😄 '); + expect(lodashGet(EmojiUtils.replaceEmojis(text, true), 'text')).toBe('Hi 😄 '); }); it('will not add a space after the last emoji if there is text after it', () => { const text = 'Hi :smile::wave:no space after last emoji'; - expect(EmojiUtils.replaceEmojis(text)).toBe('Hi 😄👋no space after last emoji'); + expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋no space after last emoji'); }); it('will not add a space after the last emoji when there is text after it on mobile', () => { const text = 'Hi :smile::wave:no space after last emoji'; - expect(EmojiUtils.replaceEmojis(text, true)).toBe('Hi 😄👋no space after last emoji'); + expect(lodashGet(EmojiUtils.replaceEmojis(text, true), 'text')).toBe('Hi 😄👋no space after last emoji'); }); it("will not add a space after the last emoji if we're not on mobile", () => { const text = 'Hi :smile:'; - expect(EmojiUtils.replaceEmojis(text)).toBe('Hi 😄'); + expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄'); }); it('suggests emojis when typing emojis prefix after colon', () => { @@ -197,7 +198,7 @@ describe('EmojiTest', () => { const currentTime = moment().unix(); const smileEmoji = {code: '😄', name: 'smile'}; const newEmoji = [smileEmoji]; - EmojiUtils.addToFrequentlyUsedEmojis(newEmoji); + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji)); // Then the new emoji should be at the last item of the list const expectedSmileEmoji = {...smileEmoji, count: 1, lastUpdatedAt: currentTime}; @@ -242,7 +243,7 @@ describe('EmojiTest', () => { // When add an emoji that exists in the list const currentTime = moment().unix(); const newEmoji = [smileEmoji]; - EmojiUtils.addToFrequentlyUsedEmojis(newEmoji); + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji)); // Then the count should be increased and put into the very front of the other emoji within the same count const expectedFrequentlyEmojisList = [frequentlyEmojisList[0], {...smileEmoji, count: 2, lastUpdatedAt: currentTime}, ...frequentlyEmojisList.slice(1, -1)]; @@ -283,7 +284,7 @@ describe('EmojiTest', () => { // When add multiple emojis that either exist or not exist in the list const currentTime = moment().unix(); const newEmoji = [smileEmoji, zzzEmoji, impEmoji]; - EmojiUtils.addToFrequentlyUsedEmojis(newEmoji); + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji)); // Then the count should be increased for existing emoji and sorted descending by count and lastUpdatedAt const expectedFrequentlyEmojisList = [ @@ -452,7 +453,7 @@ describe('EmojiTest', () => { // When add new emojis const currentTime = moment().unix(); const newEmoji = [bookEmoji, smileEmoji, zzzEmoji, impEmoji, smileEmoji]; - EmojiUtils.addToFrequentlyUsedEmojis(newEmoji); + User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji)); // Then the last emojis from the list should be replaced with the most recent new emoji (smile) const expectedFrequentlyEmojisList = [ diff --git a/tests/unit/isReportMessageAttachmentTest.js b/tests/unit/isReportMessageAttachmentTest.js new file mode 100644 index 000000000000..8338513a7e7e --- /dev/null +++ b/tests/unit/isReportMessageAttachmentTest.js @@ -0,0 +1,22 @@ +import isReportMessageAttachment from '../../src/libs/isReportMessageAttachment'; + +describe('isReportMessageAttachment', () => { + it('returns true if a report action is attachment', () => { + const message = { + text: '[Attachment]', + html: '
', + }; + expect(isReportMessageAttachment(message)).toBe(true); + }); + + it('returns false if a report action is not attachment', () => { + let message = {text: '[Attachment]', html: '[Attachment]'}; + expect(isReportMessageAttachment(message)).toBe(false); + + message = {text: '[Attachment]', html: '[Attachment]'}; + expect(isReportMessageAttachment(message)).toBe(false); + + message = {text: '[Attachment]', html: '[Attachment]'}; + expect(isReportMessageAttachment(message)).toBe(false); + }); +});