diff --git a/Gemfile.lock b/Gemfile.lock index ed190579d306..beb2c1762936 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,7 +8,7 @@ GEM i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - addressable (2.8.5) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) @@ -18,20 +18,20 @@ GEM artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.857.0) - aws-sdk-core (3.188.0) - aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (1.883.0) + aws-sdk-core (3.190.3) + aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.73.0) + aws-sdk-kms (1.76.0) aws-sdk-core (~> 3, >= 3.188.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.140.0) - aws-sdk-core (~> 3, >= 3.188.0) + aws-sdk-s3 (1.142.0) + aws-sdk-core (~> 3, >= 3.189.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.6) - aws-sigv4 (1.7.0) + aws-sigv4 (~> 1.8) + aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) claide (1.1.0) @@ -80,14 +80,13 @@ GEM declarative (0.0.20) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) - excon (0.104.0) + excon (0.109.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -116,8 +115,8 @@ GEM faraday-retry (1.0.3) faraday_middleware (1.2.0) faraday (~> 1.0) - fastimage (2.2.7) - fastlane (2.217.0) + fastimage (2.3.0) + fastlane (2.219.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -136,6 +135,7 @@ GEM gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) @@ -144,7 +144,7 @@ GEM mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) naturally (~> 2.2) - optparse (~> 0.1.1) + optparse (>= 0.1.1) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.3) @@ -165,9 +165,9 @@ GEM fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.53.0) + google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.2) + google-apis-core (0.11.3) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -175,24 +175,23 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.a) rexml - webrick google-apis-iamcredentials_v1 (0.17.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.29.0) + google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) + google-cloud-core (1.6.1) + google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) google-cloud-errors (1.3.1) - google-cloud-storage (1.45.0) + google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.29.0) + google-apis-storage_v1 (~> 0.31.0) google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) @@ -209,7 +208,7 @@ GEM i18n (1.14.1) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.6.3) + json (2.7.1) jwt (2.7.1) mime-types (3.5.1) mime-types-data (~> 3.2015) @@ -224,9 +223,9 @@ GEM nap (1.1.0) naturally (2.2.1) netrc (0.11.0) - optparse (0.1.1) + optparse (0.4.0) os (1.1.4) - plist (3.7.0) + plist (3.7.1) public_suffix (4.0.7) rake (13.1.0) representable (3.2.0) @@ -253,7 +252,7 @@ GEM unicode-display_width (>= 1.1.1, < 3) trailblazer-option (0.1.2) tty-cursor (0.7.1) - tty-screen (0.8.1) + tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) typhoeus (1.4.1) @@ -261,11 +260,7 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.9.1) unicode-display_width (2.5.0) - webrick (1.8.1) word_wrap (1.0.0) xcodeproj (1.23.0) CFPropertyList (>= 2.3.3, < 4.0) @@ -297,4 +292,4 @@ RUBY VERSION ruby 2.6.10p210 BUNDLED WITH - 2.4.7 + 2.4.19 diff --git a/android/app/build.gradle b/android/app/build.gradle index 071823fdea97..49f1b017d5e0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001042900 - versionName "1.4.29-0" + versionCode 1001043102 + versionName "1.4.31-2" } flavorDimensions "default" diff --git a/assets/images/scan.svg b/assets/images/scan.svg new file mode 100644 index 000000000000..629dc3823a12 --- /dev/null +++ b/assets/images/scan.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/desktop/main.js b/desktop/main.js index c9d614d3de15..e53f03530b57 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -512,10 +512,10 @@ const mainWindow = () => { }); browserWindow.on('swipe', (e, direction) => { - if (direction === 'right') { + if (direction === 'left') { browserWindow.webContents.goBack(); } - if (direction === 'left') { + if (direction === 'right') { browserWindow.webContents.goForward(); } }); diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index e5ee52210866..454857981834 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 68492e0fef1f..34341662d137 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.29 + 1.4.31 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.29.0 + 1.4.31.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 48d1576c6d21..38073f64d814 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.29 + 1.4.31 CFBundleSignature ???? CFBundleVersion - 1.4.29.0 + 1.4.31.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index dfff67d38e21..8550e23db7b1 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -3,9 +3,9 @@ CFBundleShortVersionString - 1.4.29 + 1.4.31 CFBundleVersion - 1.4.29.0 + 1.4.31.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4985b0f7edc9..776dcb544ee6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1158,7 +1158,7 @@ PODS: - react-native-airship (15.3.1): - AirshipFrameworkProxy (= 2.1.1) - React-Core - - react-native-blob-util (0.19.6): + - react-native-blob-util (0.19.4): - React-Core - react-native-cameraroll (5.4.0): - React-Core @@ -1901,7 +1901,7 @@ SPEC CHECKSUMS: React-logger: 66b168e2b2bee57bd8ce9e69f739d805732a5570 React-Mapbuffer: 9ee041e1d7be96da6d76a251f92e72b711c651d6 react-native-airship: 6ded22e4ca54f2f80db80b7b911c2b9b696d9335 - react-native-blob-util: d8fa1a7f726867907a8e43163fdd8b441d4489ea + react-native-blob-util: 30a6c9fd067aadf9177e61a998f2c7efb670598d react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151 react-native-config: 7cd105e71d903104e8919261480858940a6b9c0e react-native-document-picker: 69ca2094d8780cfc1e7e613894d15290fdc54bba diff --git a/package-lock.json b/package-lock.json index 433e4dcf4de2..f05837e853ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.29-0", + "version": "1.4.31-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.29-0", + "version": "1.4.31-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -76,7 +76,7 @@ "react-map-gl": "^7.1.3", "react-native": "0.73.2", "react-native-android-location-enabler": "^1.2.2", - "react-native-blob-util": "^0.19.6", + "react-native-blob-util": "0.19.4", "react-native-collapsible": "^1.6.1", "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", @@ -44743,90 +44743,32 @@ } }, "node_modules/react-native-blob-util": { - "version": "0.19.6", - "resolved": "https://registry.npmjs.org/react-native-blob-util/-/react-native-blob-util-0.19.6.tgz", - "integrity": "sha512-62yMJdgOMEu2Ir9V177FbvOYZskFu92XkT/hUWMMdu3G7UhqnetwtyVwqo4jfNrr+Y3Lp0gXYx60vxjMaov1pQ==", + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/react-native-blob-util/-/react-native-blob-util-0.19.4.tgz", + "integrity": "sha512-qQ2l3ZmclxA8Ht9NyI4qVJ94j8R4wIUaNP40ZMGjDMXelzvk3tTBeuEYkx7a2R84TGpaXCpKDQmee7+x9Bmo7A==", "dependencies": { "base-64": "0.1.0", - "glob": "^10.3.10" + "glob": "^7.2.3" }, "peerDependencies": { "react": "*", "react-native": "*" } }, - "node_modules/react-native-blob-util/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/react-native-blob-util/node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/react-native-blob-util/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/react-native-blob-util/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dependencies": { - "brace-expansion": "^2.0.1" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/react-native-blob-util/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/react-native-blob-util/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -85794,60 +85736,26 @@ } }, "react-native-blob-util": { - "version": "0.19.6", - "resolved": "https://registry.npmjs.org/react-native-blob-util/-/react-native-blob-util-0.19.6.tgz", - "integrity": "sha512-62yMJdgOMEu2Ir9V177FbvOYZskFu92XkT/hUWMMdu3G7UhqnetwtyVwqo4jfNrr+Y3Lp0gXYx60vxjMaov1pQ==", + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/react-native-blob-util/-/react-native-blob-util-0.19.4.tgz", + "integrity": "sha512-qQ2l3ZmclxA8Ht9NyI4qVJ94j8R4wIUaNP40ZMGjDMXelzvk3tTBeuEYkx7a2R84TGpaXCpKDQmee7+x9Bmo7A==", "requires": { "base-64": "0.1.0", - "glob": "^10.3.10" + "glob": "^7.2.3" }, "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "requires": { - "balanced-match": "^1.0.0" - } - }, - "foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "requires": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - } - }, "glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "requires": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - } - }, - "minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "requires": { - "brace-expansion": "^2.0.1" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" } - }, - "minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==" - }, - "signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" } } }, diff --git a/package.json b/package.json index 2cb65adaf67b..2ba358b438e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.29-0", + "version": "1.4.31-2", "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.", @@ -124,7 +124,7 @@ "react-map-gl": "^7.1.3", "react-native": "0.73.2", "react-native-android-location-enabler": "^1.2.2", - "react-native-blob-util": "^0.19.6", + "react-native-blob-util": "0.19.4", "react-native-collapsible": "^1.6.1", "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", diff --git a/src/CONST.ts b/src/CONST.ts index 0b10e5767328..dbf80c6929de 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -440,6 +440,14 @@ const CONST = { }, CURRENCY: { USD: 'USD', + AUD: 'AUD', + CAD: 'CAD', + GBP: 'GBP', + NZD: 'NZD', + EUR: 'EUR', + }, + get DIRECT_REIMBURSEMENT_CURRENCIES() { + return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.NZD, this.CURRENCY.EUR]; }, EXAMPLE_PHONE_NUMBER: '+15005550006', CONCIERGE_CHAT_NAME: 'Concierge', @@ -967,6 +975,7 @@ const CONST = { SMALL_EMOJI_PICKER_SIZE: { WIDTH: '100%', }, + MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM: 83, NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 300, NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT_WEB: 200, EMOJI_PICKER_ITEM_HEIGHT: 32, @@ -977,7 +986,6 @@ const CONST = { SUGGESTER_INNER_PADDING: 8, SUGGESTION_ROW_HEIGHT: 40, SMALL_CONTAINER_HEIGHT_FACTOR: 2.5, - MIN_AMOUNT_OF_SUGGESTIONS: 3, MAX_AMOUNT_OF_SUGGESTIONS: 20, MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER: 5, HERE_TEXT: '@here', @@ -1298,6 +1306,11 @@ const CONST = { CUSTOM_UNIT_RATE_BASE_OFFSET: 100, OWNER_EMAIL_FAKE: '_FAKE_', OWNER_ACCOUNT_ID_FAKE: 0, + REIMBURSEMENT_CHOICES: { + REIMBURSEMENT_YES: 'reimburseYes', + REIMBURSEMENT_NO: 'reimburseNo', + REIMBURSEMENT_MANUAL: 'reimburseManual', + }, ID_FAKE: '_FAKE_', }, @@ -3131,7 +3144,20 @@ const CONST = { REPORT: 'REPORT', }, + INTRO_CHOICES: { + TRACK: 'newDotTrack', + SUBMIT: 'newDotSubmit', + MANAGE_TEAM: 'newDotManageTeam', + CHAT_SPLIT: 'newDotSplitChat', + }, + MINI_CONTEXT_MENU_MAX_ITEMS: 4, + + REPORT_FIELD_TITLE_FIELD_ID: 'text_title', } as const; +type Country = keyof typeof CONST.ALL_COUNTRIES; + +export type {Country}; + export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 40a43d8195de..26424af8056c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -110,6 +110,12 @@ const ONYXKEYS = { /** This NVP holds to most recent waypoints that a person has used when creating a distance request */ NVP_RECENT_WAYPOINTS: 'expensify_recentWaypoints', + /** This NVP will be `true` if the user has ever dismissed the engagement modal on either OldDot or NewDot. If it becomes true it should stay true forever. */ + NVP_HAS_DISMISSED_IDLE_PANEL: 'hasDismissedIdlePanel', + + /** This NVP contains the choice that the user made on the engagement modal */ + NVP_INTRO_SELECTED: 'introSelected', + /** Does this user have push notifications enabled for this device? */ PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -350,6 +356,8 @@ const ONYXKEYS = { REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm', GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', + POLICY_REPORT_FIELD_EDIT_FORM: 'policyReportFieldEditForm', + POLICY_REPORT_FIELD_EDIT_FORM_DRAFT: 'policyReportFieldEditFormDraft', }, } as const; @@ -393,6 +401,8 @@ type OnyxValues = { [ONYXKEYS.FOCUS_MODE_NOTIFICATION]: boolean; [ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: Record; [ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[]; + [ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL]: boolean; + [ONYXKEYS.NVP_INTRO_SELECTED]: OnyxTypes.IntroSelected; [ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED]: boolean; [ONYXKEYS.PLAID_DATA]: OnyxTypes.PlaidData; [ONYXKEYS.IS_PLAID_DISABLED]: boolean; @@ -442,7 +452,7 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; - [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportField; + [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record; @@ -461,7 +471,7 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; [ONYXKEYS.COLLECTION.SELECTED_TAB]: string; - [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations; + [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolation[]; [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; [ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStep; @@ -528,6 +538,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form | undefined; + [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form | undefined; }; type OnyxKeyValue = OnyxEntry; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 37003a09a0cd..42123aa9b4a4 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -29,6 +29,10 @@ const ROUTES = { route: 'a/:accountID', getRoute: (accountID: string | number, backTo?: string) => getUrlWithBackToParam(`a/${accountID}`, backTo), }, + PROFILE_AVATAR: { + route: 'a/:accountID/avatar', + getRoute: (accountID: string) => `a/${accountID}/avatar` as const, + }, TRANSITION_BETWEEN_APPS: 'transition', VALIDATE_LOGIN: 'v/:accountID/:validateCode', @@ -140,6 +144,7 @@ const ROUTES = { getRoute: (backTo?: string) => getUrlWithBackToParam('settings/security/two-factor-auth', backTo), }, SETTINGS_STATUS: 'settings/profile/status', + SETTINGS_STATUS_CLEAR_AFTER: 'settings/profile/status/clear-after', SETTINGS_STATUS_CLEAR_AFTER_DATE: 'settings/profile/status/clear-after/date', SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time', @@ -155,6 +160,10 @@ const ROUTES = { route: 'r/:reportID?/:reportActionID?', getRoute: (reportID: string) => `r/${reportID}` as const, }, + REPORT_AVATAR: { + route: 'r/:reportID/avatar', + getRoute: (reportID: string) => `r/${reportID}/avatar` as const, + }, EDIT_REQUEST: { route: 'r/:threadReportID/edit/:field', getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}` as const, @@ -163,6 +172,10 @@ const ROUTES = { route: 'r/:threadReportID/edit/currency', getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}` as const, }, + EDIT_REPORT_FIELD_REQUEST: { + route: 'r/:reportID/edit/policyField/:policyID/:fieldID', + getRoute: (reportID: string, policyID: string, fieldID: string) => `r/${reportID}/edit/policyField/${policyID}/${fieldID}` as const, + }, REPORT_WITH_ID_DETAILS_SHARE_CODE: { route: 'r/:reportID/details/shareCode', getRoute: (reportID: string) => `r/${reportID}/details/shareCode` as const, @@ -434,6 +447,10 @@ const ROUTES = { route: 'workspace/:policyID/settings', getRoute: (policyID: string) => `workspace/${policyID}/settings` as const, }, + WORKSPACE_AVATAR: { + route: 'workspace/:policyID/avatar', + getRoute: (policyID: string) => `workspace/${policyID}/avatar` as const, + }, WORKSPACE_SETTINGS_CURRENCY: { route: 'workspace/:policyID/settings/currency', getRoute: (policyID: string) => `workspace/${policyID}/settings/currency` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 703cb309d641..960991eb277b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -13,6 +13,9 @@ const PROTECTED_SCREENS = { const SCREENS = { ...PROTECTED_SCREENS, REPORT: 'Report', + PROFILE_AVATAR: 'ProfileAvatar', + WORKSPACE_AVATAR: 'WorkspaceAvatar', + REPORT_AVATAR: 'ReportAvatar', NOT_FOUND: 'not-found', TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps', VALIDATE_LOGIN: 'ValidateLogin', @@ -208,6 +211,7 @@ const SCREENS = { EDIT_REQUEST: { ROOT: 'EditRequest_Root', CURRENCY: 'EditRequest_Currency', + REPORT_FIELD: 'EditRequest_ReportField', }, NEW_CHAT: { diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index bd8d535e540f..4988c33ed8ce 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -22,17 +22,21 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import useNativeDriver from '@libs/useNativeDriver'; import reportPropTypes from '@pages/reportPropTypes'; +import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import AttachmentCarousel from './Attachments/AttachmentCarousel'; import AttachmentView from './Attachments/AttachmentView'; +import BlockingView from './BlockingViews/BlockingView'; import Button from './Button'; import ConfirmModal from './ConfirmModal'; +import FullScreenLoadingIndicator from './FullscreenLoadingIndicator'; import HeaderGap from './HeaderGap'; import HeaderWithBackButton from './HeaderWithBackButton'; import * as Expensicons from './Icon/Expensicons'; +import * as Illustrations from './Icon/Illustrations'; import sourcePropTypes from './Image/sourcePropTypes'; import Modal from './Modal'; import SafeAreaConsumer from './SafeAreaConsumer'; @@ -61,6 +65,9 @@ const propTypes = { /** Optional callback to fire when we want to do something after modal hide. */ onModalHide: PropTypes.func, + /** Trigger when we explicity click close button in ProfileAttachment modal */ + onModalClose: PropTypes.func, + /** Optional callback to fire when we want to do something after attachment carousel changes. */ onCarouselAttachmentChange: PropTypes.func, @@ -85,6 +92,12 @@ const propTypes = { /** The transaction associated with the receipt attachment, if any */ transaction: transactionPropTypes, + /** The data is loading or not */ + isLoading: PropTypes.bool, + + /** Should display not found page or not */ + shouldShowNotFoundPage: PropTypes.bool, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -114,6 +127,9 @@ const defaultProps = { onModalHide: () => {}, onCarouselAttachmentChange: () => {}, isWorkspaceAvatar: false, + onModalClose: () => {}, + isLoading: false, + shouldShowNotFoundPage: false, isReceiptAttachment: false, canEditReceipt: false, }; @@ -352,7 +368,13 @@ function AttachmentModal(props) { */ const closeModal = useCallback(() => { setIsModalOpen(false); - }, []); + + if (typeof props.onModalClose === 'function') { + props.onModalClose(); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.onModalClose]); /** * open the modal @@ -458,9 +480,22 @@ function AttachmentModal(props) { shouldShowThreeDotsButton={shouldShowThreeDotsButton} threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)} threeDotsMenuItems={threeDotsMenuItems} - shouldOverlay + shouldOverlayDots /> + {props.isLoading && } + {props.shouldShowNotFoundPage && !props.isLoading && ( + Navigation.dismissModal()} + /> + )} {!_.isEmpty(props.report) && !props.isReceiptAttachment ? ( ) : ( Boolean(sourceForAttachmentView) && - shouldLoadAttachment && ( + shouldLoadAttachment && + !props.isLoading && + !props.shouldShowNotFoundPage && ( { + if (typeof onViewPhotoPress !== 'function') { + show(); + return; + } + onViewPhotoPress(); + }, }); } diff --git a/src/components/Composer/index.android.tsx b/src/components/Composer/index.android.tsx index d60a41e0f263..ade1513c8613 100644 --- a/src/components/Composer/index.android.tsx +++ b/src/components/Composer/index.android.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {TextInput} from 'react-native'; import {StyleSheet} from 'react-native'; import RNTextInput from '@components/RNTextInput'; +import useResetComposerFocus from '@hooks/useResetComposerFocus'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ComposerUtils from '@libs/ComposerUtils'; @@ -28,6 +29,7 @@ function Composer( ref: ForwardedRef, ) { const textInput = useRef(null); + const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); const styles = useThemeStyles(); const theme = useTheme(); @@ -89,6 +91,12 @@ function Composer( /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} readOnly={isDisabled} + onBlur={(e) => { + if (!isFocused) { + shouldResetFocus.current = true; // detect the input is blurred when the page is hidden + } + props?.onBlur?.(e); + }} /> ); } diff --git a/src/components/Composer/index.ios.tsx b/src/components/Composer/index.ios.tsx index b1357fef9a46..07736e5ddcba 100644 --- a/src/components/Composer/index.ios.tsx +++ b/src/components/Composer/index.ios.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {TextInput} from 'react-native'; import {StyleSheet} from 'react-native'; import RNTextInput from '@components/RNTextInput'; +import useResetComposerFocus from '@hooks/useResetComposerFocus'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ComposerUtils from '@libs/ComposerUtils'; @@ -28,7 +29,7 @@ function Composer( ref: ForwardedRef, ) { const textInput = useRef(null); - + const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); const styles = useThemeStyles(); const theme = useTheme(); @@ -84,6 +85,12 @@ function Composer( /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} readOnly={isDisabled} + onBlur={(e) => { + if (!isFocused) { + shouldResetFocus.current = true; // detect the input is blurred when the page is hidden + } + props?.onBlur?.(e); + }} /> ); } diff --git a/src/components/CountrySelector.js b/src/components/CountrySelector.tsx similarity index 62% rename from src/components/CountrySelector.js rename to src/components/CountrySelector.tsx index 68a6486bce48..589530cd7879 100644 --- a/src/components/CountrySelector.js +++ b/src/components/CountrySelector.tsx @@ -1,39 +1,30 @@ -import PropTypes from 'prop-types'; -import React, {useEffect} from 'react'; +import React, {forwardRef, useEffect} from 'react'; +import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import type {Country} from '@src/CONST'; import ROUTES from '@src/ROUTES'; import FormHelpMessage from './FormHelpMessage'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; -import refPropTypes from './refPropTypes'; -const propTypes = { +type CountrySelectorProps = { /** Form error text. e.g when no country is selected */ - errorText: PropTypes.string, + errorText?: string; /** Callback called when the country changes. */ - onInputChange: PropTypes.func.isRequired, + onInputChange: (value?: string) => void; /** Current selected country */ - value: PropTypes.string, + value?: Country; /** inputID used by the Form component */ // eslint-disable-next-line react/no-unused-prop-types - inputID: PropTypes.string.isRequired, - - /** React ref being forwarded to the MenuItemWithTopDescription */ - forwardedRef: refPropTypes, -}; - -const defaultProps = { - errorText: '', - value: undefined, - forwardedRef: () => {}, + inputID: string; }; -function CountrySelector({errorText, value: countryCode, onInputChange, forwardedRef}) { +function CountrySelector({errorText = '', value: countryCode, onInputChange}: CountrySelectorProps, ref: ForwardedRef) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -51,12 +42,12 @@ function CountrySelector({errorText, value: countryCode, onInputChange, forwarde { const activeRoute = Navigation.getActiveRouteWithoutParams(); - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.getRoute(countryCode, activeRoute)); + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); }} /> @@ -66,18 +57,6 @@ function CountrySelector({errorText, value: countryCode, onInputChange, forwarde ); } -CountrySelector.propTypes = propTypes; -CountrySelector.defaultProps = defaultProps; CountrySelector.displayName = 'CountrySelector'; -const CountrySelectorWithRef = React.forwardRef((props, ref) => ( - -)); - -CountrySelectorWithRef.displayName = 'CountrySelectorWithRef'; - -export default CountrySelectorWithRef; +export default forwardRef(CountrySelector); diff --git a/src/components/DisplayNames/types.ts b/src/components/DisplayNames/types.ts index b0959d43aa96..2e6f36d5cc07 100644 --- a/src/components/DisplayNames/types.ts +++ b/src/components/DisplayNames/types.ts @@ -12,7 +12,7 @@ type DisplayNameWithTooltip = { login?: string; /** The avatar for the tooltip fallback */ - avatar: AvatarSource; + avatar?: AvatarSource; }; type DisplayNamesProps = { diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index e627119270dd..832715e3214c 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -11,6 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import getButtonState from '@libs/getButtonState'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; +import CONST from '@src/CONST'; const propTypes = { /** Flag to disable the emoji picker button */ @@ -22,6 +23,9 @@ const propTypes = { /** Unique id for emoji picker */ emojiPickerID: PropTypes.string, + /** Emoji popup anchor offset shift vertical */ + shiftVertical: PropTypes.number, + ...withLocalizePropTypes, }; @@ -29,6 +33,7 @@ const defaultProps = { isDisabled: false, id: '', emojiPickerID: '', + shiftVertical: 0, }; function EmojiPickerButton(props) { @@ -49,7 +54,18 @@ function EmojiPickerButton(props) { return; } if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { - EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor, undefined, () => {}, props.emojiPickerID); + EmojiPickerAction.showEmojiPicker( + props.onModalHide, + props.onEmojiSelected, + emojiPopoverAnchor, + { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + shiftVertical: props.shiftVertical, + }, + () => {}, + props.emojiPickerID, + ); } else { EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); } diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 6cbfde0645de..a0f24b06db7f 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {Keyboard, View} from 'react-native'; +import {Keyboard, StyleSheet, View} from 'react-native'; import AvatarWithDisplayName from '@components/AvatarWithDisplayName'; import Header from '@components/Header'; import Icon from '@components/Icon'; @@ -52,6 +52,7 @@ function HeaderWithBackButton({ threeDotsMenuItems = [], shouldEnableDetailPageNavigation = false, children = null, + shouldOverlayDots = false, shouldOverlay = false, singleExecution = (func) => func, shouldNavigateToTopMostReport = false, @@ -69,7 +70,7 @@ function HeaderWithBackButton({ // Hover on some part of close icons will not work on Electron if dragArea is true // https://github.com/Expensify/App/issues/29598 dataSet={{dragArea: false}} - style={[styles.headerBar, shouldShowBorderBottom && styles.borderBottom, shouldShowBackButton && styles.pl2]} + style={[styles.headerBar, shouldShowBorderBottom && styles.borderBottom, shouldShowBackButton && styles.pl2, shouldOverlay && StyleSheet.absoluteFillObject]} > {shouldShowBackButton && ( @@ -163,7 +164,7 @@ function HeaderWithBackButton({ menuItems={threeDotsMenuItems} onIconPress={onThreeDotsButtonPress} anchorPosition={threeDotsAnchorPosition} - shouldOverlay={shouldOverlay} + shouldOverlay={shouldOverlayDots} /> )} {shouldShowCloseButton && ( diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 8ac21698c32b..725d14e041a7 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -108,6 +108,9 @@ type HeaderWithBackButtonProps = Partial & { /** Whether we should enable detail page navigation */ shouldEnableDetailPageNavigation?: boolean; + + /** Whether we should overlay the 3 dots menu */ + shouldOverlayDots?: boolean; }; export type {ThreeDotsMenuItem}; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 797e6f34fc75..364fb03a2055 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -107,6 +107,7 @@ import ReceiptSearch from '@assets/images/receipt-search.svg'; import Receipt from '@assets/images/receipt.svg'; import Rotate from '@assets/images/rotate-image.svg'; import RotateLeft from '@assets/images/rotate-left.svg'; +import Scan from '@assets/images/scan.svg'; import Send from '@assets/images/send.svg'; import Shield from '@assets/images/shield.svg'; import AppleLogo from '@assets/images/signIn/apple-logo.svg'; @@ -243,6 +244,7 @@ export { ReceiptSearch, Rotate, RotateLeft, + Scan, Send, Shield, Sync, diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 04c8397bc33b..89cceadc0fb0 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -1,7 +1,6 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; -import type {SyntheticEvent} from 'react'; import {Dimensions} from 'react-native'; -import type {EmitterSubscription, NativeTouchEvent} from 'react-native'; +import type {EmitterSubscription, GestureResponderEvent} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; @@ -68,8 +67,8 @@ function KYCWall({ walletTerms, shouldShowPersonalBankAccountOption = false, }: BaseKYCWallProps) { - const anchorRef = useRef(null); - const transferBalanceButtonRef = useRef(null); + const anchorRef = useRef(null); + const transferBalanceButtonRef = useRef(null); const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false); @@ -146,7 +145,7 @@ function KYCWall({ * */ const continueAction = useCallback( - (event?: SyntheticEvent, iouPaymentType?: TransferMethod) => { + (event?: GestureResponderEvent | KeyboardEvent, iouPaymentType?: TransferMethod) => { const currentSource = walletTerms?.source ?? source; /** @@ -161,7 +160,7 @@ function KYCWall({ } // Use event target as fallback if anchorRef is null for safety - const targetElement = anchorRef.current ?? (event?.nativeEvent.target as HTMLDivElement); + const targetElement = anchorRef.current ?? (event?.currentTarget as HTMLElement); transferBalanceButtonRef.current = targetElement; @@ -203,7 +202,7 @@ function KYCWall({ Log.info('[KYC Wallet] User has valid payment method and passed KYC checks or did not need them'); - onSuccessfulKYC(currentSource, iouPaymentType); + onSuccessfulKYC(iouPaymentType, currentSource); }, [ bankAccountList, diff --git a/src/components/KYCWall/types.ts b/src/components/KYCWall/types.ts index aee5b569cc46..1fe16d4a17c3 100644 --- a/src/components/KYCWall/types.ts +++ b/src/components/KYCWall/types.ts @@ -1,5 +1,5 @@ -import type {ForwardedRef, SyntheticEvent} from 'react'; -import type {NativeTouchEvent} from 'react-native'; +import type {ForwardedRef} from 'react'; +import type {GestureResponderEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; @@ -64,10 +64,10 @@ type KYCWallProps = { shouldShowPersonalBankAccountOption?: boolean; /** Callback for the end of the onContinue trigger on option selection */ - onSuccessfulKYC: (currentSource?: Source, iouPaymentType?: TransferMethod) => void; + onSuccessfulKYC: (iouPaymentType?: TransferMethod, currentSource?: Source) => void; /** Children to build the KYC */ - children: (continueAction: (event: SyntheticEvent, method: TransferMethod) => void, anchorRef: ForwardedRef) => void; + children: (continueAction: (event: GestureResponderEvent | KeyboardEvent | undefined, method: TransferMethod) => void, anchorRef: ForwardedRef) => void; }; export type {AnchorPosition, KYCWallProps, PaymentMethod, TransferMethod, DomRect}; diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js deleted file mode 100644 index 4c8b0e1102b9..000000000000 --- a/src/components/LHNOptionsList/LHNOptionsList.js +++ /dev/null @@ -1,222 +0,0 @@ -import {FlashList} from '@shopify/flash-list'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useCallback} from 'react'; -import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import participantPropTypes from '@components/participantPropTypes'; -import transactionPropTypes from '@components/transactionPropTypes'; -import withCurrentReportID, {withCurrentReportIDDefaultProps, withCurrentReportIDPropTypes} from '@components/withCurrentReportID'; -import usePermissions from '@hooks/usePermissions'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import {transactionViolationsPropType} from '@libs/Violations/propTypes'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; -import reportPropTypes from '@pages/reportPropTypes'; -import stylePropTypes from '@styles/stylePropTypes'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import OptionRowLHNData from './OptionRowLHNData'; - -const propTypes = { - /** Wrapper style for the section list */ - style: stylePropTypes, - - /** Extra styles for the section list container */ - contentContainerStyles: stylePropTypes.isRequired, - - /** Sections for the section list */ - data: PropTypes.arrayOf(PropTypes.string).isRequired, - - /** Callback to fire when a row is selected */ - onSelectRow: PropTypes.func.isRequired, - - /** Toggle between compact and default view of the option */ - optionMode: PropTypes.oneOf(_.values(CONST.OPTION_MODE)).isRequired, - - /** Whether to allow option focus or not */ - shouldDisableFocusOptions: PropTypes.bool, - - /** The policy which the user has access to and which the report could be tied to */ - policy: PropTypes.shape({ - /** The ID of the policy */ - id: PropTypes.string, - /** Name of the policy */ - name: PropTypes.string, - /** Avatar of the policy */ - avatar: PropTypes.string, - }), - - /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes), - - /** Array of report actions for this report */ - reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), - - /** Indicates which locale the user currently has selected */ - preferredLocale: PropTypes.string, - - /** List of users' personal details */ - personalDetails: PropTypes.objectOf(participantPropTypes), - - /** The transaction from the parent report action */ - transactions: PropTypes.objectOf(transactionPropTypes), - - /** List of draft comments */ - draftComments: PropTypes.objectOf(PropTypes.string), - - /** The list of transaction violations */ - transactionViolations: transactionViolationsPropType, - - ...withCurrentReportIDPropTypes, -}; - -const defaultProps = { - style: undefined, - shouldDisableFocusOptions: false, - reportActions: {}, - reports: {}, - policy: {}, - preferredLocale: CONST.LOCALES.DEFAULT, - personalDetails: {}, - transactions: {}, - draftComments: {}, - transactionViolations: {}, - ...withCurrentReportIDDefaultProps, -}; - -const keyExtractor = (item) => `report_${item}`; - -function LHNOptionsList({ - style, - contentContainerStyles, - data, - onSelectRow, - optionMode, - shouldDisableFocusOptions, - reports, - reportActions, - policy, - preferredLocale, - personalDetails, - transactions, - draftComments, - currentReportID, - transactionViolations, -}) { - const styles = useThemeStyles(); - const {canUseViolations} = usePermissions(); - /** - * Function which renders a row in the list - * - * @param {Object} params - * @param {Object} params.item - * - * @return {Component} - */ - const renderItem = useCallback( - ({item: reportID}) => { - const itemFullReport = reports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] || {}; - const itemReportActions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]; - const itemParentReportActions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${itemFullReport.parentReportID}`] || {}; - const itemParentReportAction = itemParentReportActions[itemFullReport.parentReportActionID] || {}; - const itemPolicy = policy[`${ONYXKEYS.COLLECTION.POLICY}${itemFullReport.policyID}`] || {}; - const transactionID = lodashGet(itemParentReportAction, ['originalMessage', 'IOUTransactionID'], ''); - const itemTransaction = transactionID ? transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] : {}; - const itemComment = draftComments[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] || ''; - const participants = [...ReportUtils.getParticipantsIDs(itemFullReport), itemFullReport.ownerAccountID, itemParentReportAction.actorAccountID]; - - const participantsPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, personalDetails); - - return ( - - ); - }, - [ - currentReportID, - draftComments, - onSelectRow, - optionMode, - personalDetails, - policy, - preferredLocale, - reportActions, - reports, - shouldDisableFocusOptions, - transactions, - transactionViolations, - canUseViolations, - ], - ); - - return ( - - - - ); -} - -LHNOptionsList.propTypes = propTypes; -LHNOptionsList.defaultProps = defaultProps; -LHNOptionsList.displayName = 'LHNOptionsList'; - -export default compose( - withCurrentReportID, - withOnyx({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - reportActions: { - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - }, - policy: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - preferredLocale: { - key: ONYXKEYS.NVP_PREFERRED_LOCALE, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - transactions: { - key: ONYXKEYS.COLLECTION.TRANSACTION, - }, - draftComments: { - key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, - }, - transactionViolations: { - key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, - }, - }), -)(LHNOptionsList); diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx new file mode 100644 index 000000000000..ecf320807b48 --- /dev/null +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -0,0 +1,141 @@ +import {FlashList} from '@shopify/flash-list'; +import type {ReactElement} from 'react'; +import React, {useCallback} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import withCurrentReportID from '@components/withCurrentReportID'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import OptionRowLHNData from './OptionRowLHNData'; +import type {LHNOptionsListOnyxProps, LHNOptionsListProps, RenderItemProps} from './types'; + +const keyExtractor = (item: string) => `report_${item}`; + +function LHNOptionsList({ + style, + contentContainerStyles, + data, + onSelectRow, + optionMode, + shouldDisableFocusOptions = false, + reports = {}, + reportActions = {}, + policy = {}, + preferredLocale = CONST.LOCALES.DEFAULT, + personalDetails = {}, + transactions = {}, + currentReportID = '', + draftComments = {}, + transactionViolations = {}, +}: LHNOptionsListProps) { + const styles = useThemeStyles(); + const {canUseViolations} = usePermissions(); + /** + * Function which renders a row in the list + */ + const renderItem = useCallback( + ({item: reportID}: RenderItemProps): ReactElement => { + const itemFullReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; + const itemReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? null; + const itemParentReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${itemFullReport?.parentReportID}`] ?? null; + const itemParentReportAction = itemParentReportActions?.[itemFullReport?.parentReportActionID ?? ''] ?? null; + const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${itemFullReport?.policyID}`] ?? null; + const transactionID = itemParentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? itemParentReportAction.originalMessage.IOUTransactionID ?? '' : ''; + const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? null; + const itemComment = draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] ?? ''; + const participants = [...ReportUtils.getParticipantsIDs(itemFullReport), itemFullReport?.ownerAccountID, itemParentReportAction?.actorAccountID]; + const participantsPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, personalDetails); + + return ( + + ); + }, + [ + currentReportID, + draftComments, + onSelectRow, + optionMode, + personalDetails, + policy, + preferredLocale, + reportActions, + reports, + shouldDisableFocusOptions, + transactions, + transactionViolations, + canUseViolations, + ], + ); + + return ( + + + + ); +} + +LHNOptionsList.displayName = 'LHNOptionsList'; + +export default withCurrentReportID( + withOnyx({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + reportActions: { + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + }, + policy: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + preferredLocale: { + key: ONYXKEYS.NVP_PREFERRED_LOCALE, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + transactions: { + key: ONYXKEYS.COLLECTION.TRANSACTION, + }, + draftComments: { + key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, + }, + transactionViolations: { + key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, + }, + })(LHNOptionsList), +); + +export type {LHNOptionsListProps}; diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.tsx similarity index 66% rename from src/components/LHNOptionsList/OptionRowLHN.js rename to src/components/LHNOptionsList/OptionRowLHN.tsx index fc4f05eefd22..b085625c2914 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -1,9 +1,7 @@ import {useFocusEffect} from '@react-navigation/native'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback, useRef, useState} from 'react'; +import type {GestureResponderEvent, ViewStyle} from 'react-native'; import {StyleSheet, View} from 'react-native'; -import _ from 'underscore'; import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; @@ -27,51 +25,18 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import * as ReportUtils from '@libs/ReportUtils'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {OptionRowLHNProps} from './types'; -const propTypes = { - /** Style for hovered state */ - // eslint-disable-next-line react/forbid-prop-types - hoverStyle: PropTypes.object, - - /** The ID of the report that the option is for */ - reportID: PropTypes.string.isRequired, - - /** Whether this option is currently in focus so we can modify its style */ - isFocused: PropTypes.bool, - - /** A function that is called when an option is selected. Selected option is passed as a param */ - onSelectRow: PropTypes.func, - - /** Toggle between compact and default view */ - viewMode: PropTypes.oneOf(_.values(CONST.OPTION_MODE)), - - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - - /** The item that should be rendered */ - // eslint-disable-next-line react/forbid-prop-types - optionItem: PropTypes.object, -}; - -const defaultProps = { - hoverStyle: undefined, - viewMode: 'default', - onSelectRow: () => {}, - style: null, - optionItem: null, - isFocused: false, -}; - -function OptionRowLHN(props) { +function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, optionItem, viewMode = 'default', style}: OptionRowLHNProps) { const theme = useTheme(); const styles = useThemeStyles(); + const popoverAnchor = useRef(null); const StyleUtils = useStyleUtils(); - const popoverAnchor = useRef(null); const isFocusedRef = useRef(true); const {isSmallScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); - - const optionItem = props.optionItem; const [isContextMenuActive, setIsContextMenuActive] = useState(false); useFocusEffect( @@ -87,42 +52,36 @@ function OptionRowLHN(props) { return null; } - const isHidden = optionItem.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; - if (isHidden && !props.isFocused && !optionItem.isPinned) { + const isHidden = optionItem?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + if (isHidden && !isFocused && !optionItem?.isPinned) { return null; } - const textStyle = props.isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; - const textUnreadStyle = optionItem.isUnread ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; - const displayNameStyle = StyleUtils.combineStyles([styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, ...textUnreadStyle], props.style); - const alternateTextStyle = StyleUtils.combineStyles( - props.viewMode === CONST.OPTION_MODE.COMPACT - ? [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2] - : [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting], - props.style, - ); - const contentContainerStyles = - props.viewMode === CONST.OPTION_MODE.COMPACT ? [styles.flex1, styles.flexRow, styles.overflowHidden, StyleUtils.getCompactContentContainerStyles()] : [styles.flex1]; - const sidebarInnerRowStyle = StyleSheet.flatten( - props.viewMode === CONST.OPTION_MODE.COMPACT + const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; + const textStyle = isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; + const textUnreadStyle = optionItem?.isUnread && optionItem.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; + const displayNameStyle = [styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, textUnreadStyle, style]; + const alternateTextStyle = isInFocusMode + ? [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2, style] + : [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, style]; + + const contentContainerStyles = isInFocusMode ? [styles.flex1, styles.flexRow, styles.overflowHidden, StyleUtils.getCompactContentContainerStyles()] : [styles.flex1]; + const sidebarInnerRowStyle = StyleSheet.flatten( + isInFocusMode ? [styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRowCompact, styles.justifyContentCenter] : [styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRow, styles.justifyContentCenter], ); - const hoveredBackgroundColor = - (props.hoverStyle || styles.sidebarLinkHover) && (props.hoverStyle || styles.sidebarLinkHover).backgroundColor - ? (props.hoverStyle || styles.sidebarLinkHover).backgroundColor - : theme.sidebar; + const hoveredBackgroundColor = !!styles.sidebarLinkHover && 'backgroundColor' in styles.sidebarLinkHover ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; const shouldShowGreenDotIndicator = !hasBrickError && ReportUtils.requiresAttentionFromCurrentUser(optionItem, optionItem.parentReportAction); - /** * Show the ReportActionContextMenu modal popover. * - * @param {Object} [event] - A press event. + * @param [event] - A press event. */ - const showPopover = (event) => { + const showPopover = (event: MouseEvent | GestureResponderEvent) => { if (!isFocusedRef.current && isSmallScreenWidth) { return; } @@ -131,33 +90,32 @@ function OptionRowLHN(props) { CONST.CONTEXT_MENU_TYPES.REPORT, event, '', - popoverAnchor, - props.reportID, + popoverAnchor.current, + reportID, '0', - props.reportID, + reportID, undefined, () => {}, () => setIsContextMenuActive(false), false, false, optionItem.isPinned, - optionItem.isUnread, + !!optionItem.isUnread, ); }; - const emojiCode = lodashGet(optionItem, 'status.emojiCode', ''); - const statusText = lodashGet(optionItem, 'status.text', ''); - const statusClearAfterDate = lodashGet(optionItem, 'status.clearAfter', ''); + const emojiCode = optionItem.status?.emojiCode ?? ''; + const statusText = optionItem.status?.text ?? ''; + const statusClearAfterDate = optionItem.status?.clearAfter ?? ''; const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate); const statusContent = formattedDate ? `${statusText ? `${statusText} ` : ''}(${formattedDate})` : statusText; - const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(ReportUtils.getReport(optionItem.reportID)); + const report = ReportUtils.getReport(optionItem.reportID ?? ''); + const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(!isEmptyObject(report) ? report : null); - const isGroupChat = - optionItem.type === CONST.REPORT.TYPE.CHAT && _.isEmpty(optionItem.chatType) && !optionItem.isThread && lodashGet(optionItem, 'displayNamesWithTooltips.length', 0) > 2; - const fullTitle = isGroupChat ? getGroupChatName(ReportUtils.getReport(optionItem.reportID)) : optionItem.text; - - const subscriptAvatarBorderColor = props.isFocused ? focusedBackgroundColor : theme.sidebar; + const isGroupChat = optionItem.type === CONST.REPORT.TYPE.CHAT && optionItem.chatType && !optionItem.isThread && (optionItem.displayNamesWithTooltips?.length ?? 0) > 2; + const fullTitle = isGroupChat ? getGroupChatName(!isEmptyObject(report) ? report : null) : optionItem.text; + const subscriptAvatarBorderColor = isFocused ? focusedBackgroundColor : theme.sidebar; return ( ( { - if (e) { - e.preventDefault(); - } + onPress={(event) => { + event?.preventDefault(); // Enable Composer to focus on clicking the same chat after opening the context menu. ReportActionComposeFocusManager.focus(); - props.onSelectRow(optionItem, popoverAnchor); + onSelectRow(optionItem, popoverAnchor); }} - onMouseDown={(e) => { + onMouseDown={(event) => { // Allow composer blur on right click - if (!e) { + if (!event) { return; } // Prevent composer blur on left click - e.preventDefault(); + event.preventDefault(); }} testID={optionItem.reportID} - onSecondaryInteraction={(e) => { - showPopover(e); + onSecondaryInteraction={(event) => { + showPopover(event); // Ensure that we blur the composer when opening context menu, so that only one component is focused at a time if (DomUtils.getActiveElement()) { - DomUtils.getActiveElement().blur(); + (DomUtils.getActiveElement() as HTMLElement | null)?.blur(); } }} withoutFocusOnSecondaryInteraction @@ -203,32 +159,32 @@ function OptionRowLHN(props) { styles.sidebarLink, styles.sidebarLinkInnerLHN, StyleUtils.getBackgroundColorStyle(theme.sidebar), - props.isFocused ? styles.sidebarLinkActive : null, - (hovered || isContextMenuActive) && !props.isFocused ? props.hoverStyle || styles.sidebarLinkHover : null, + isFocused ? styles.sidebarLinkActive : null, + (hovered || isContextMenuActive) && !isFocused ? styles.sidebarLinkHover : null, ]} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('accessibilityHints.navigatesToChat')} - needsOffscreenAlphaCompositing={props.optionItem.icons.length >= 2} + needsOffscreenAlphaCompositing={(optionItem?.icons?.length ?? 0) >= 2} > - {!_.isEmpty(optionItem.icons) && + {(optionItem.icons?.length ?? 0) > 0 && (optionItem.shouldShowSubscript ? ( ) : ( @@ -237,13 +193,17 @@ function OptionRowLHN(props) { {isStatusVisible && ( @@ -265,7 +225,7 @@ function OptionRowLHN(props) { ) : null} - {optionItem.descriptiveText ? ( + {optionItem?.descriptiveText ? ( {optionItem.descriptiveText} @@ -324,10 +284,6 @@ function OptionRowLHN(props) { ); } -OptionRowLHN.propTypes = propTypes; -OptionRowLHN.defaultProps = defaultProps; OptionRowLHN.displayName = 'OptionRowLHN'; export default React.memo(OptionRowLHN); - -export {propTypes, defaultProps}; diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.tsx similarity index 56% rename from src/components/LHNOptionsList/OptionRowLHNData.js rename to src/components/LHNOptionsList/OptionRowLHNData.tsx index 8bdf065a94fd..dca74e880169 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -1,65 +1,14 @@ import {deepEqual} from 'fast-equals'; -import PropTypes from 'prop-types'; import React, {useEffect, useMemo, useRef} from 'react'; -import _ from 'underscore'; -import participantPropTypes from '@components/participantPropTypes'; -import transactionPropTypes from '@components/transactionPropTypes'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; -import {transactionViolationsPropType} from '@libs/Violations/propTypes'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; -import OptionRowLHN, {defaultProps as baseDefaultProps, propTypes as basePropTypes} from './OptionRowLHN'; - -const propTypes = { - /** Whether row should be focused */ - isFocused: PropTypes.bool, - - /** List of users' personal details */ - personalDetails: PropTypes.objectOf(participantPropTypes), - - /** The preferred language for the app */ - preferredLocale: PropTypes.string, - - /** The full data of the report */ - // eslint-disable-next-line react/forbid-prop-types - fullReport: PropTypes.object, - - /** The policy which the user has access to and which the report could be tied to */ - policy: PropTypes.shape({ - /** The ID of the policy */ - id: PropTypes.string, - /** Name of the policy */ - name: PropTypes.string, - /** Avatar of the policy */ - avatar: PropTypes.string, - }), - - /** The action from the parent report */ - parentReportAction: PropTypes.shape(reportActionPropTypes), - - /** The transaction from the parent report action */ - transaction: transactionPropTypes, - - /** Any violations associated with the transaction */ - transactionViolations: transactionViolationsPropType, - - ...basePropTypes, -}; - -const defaultProps = { - isFocused: false, - personalDetails: {}, - fullReport: {}, - policy: {}, - parentReportAction: {}, - transaction: {}, - preferredLocale: CONST.LOCALES.DEFAULT, - ...baseDefaultProps, -}; +import type {OptionData} from '@src/libs/ReportUtils'; +import OptionRowLHN from './OptionRowLHN'; +import type {OptionRowLHNDataProps} from './types'; /* * This component gets the data from onyx for the actual @@ -68,11 +17,11 @@ const defaultProps = { * re-render if the data really changed. */ function OptionRowLHNData({ - isFocused, + isFocused = false, fullReport, reportActions, - personalDetails, - preferredLocale, + personalDetails = {}, + preferredLocale = CONST.LOCALES.DEFAULT, comment, policy, receiptTransactions, @@ -81,18 +30,18 @@ function OptionRowLHNData({ transactionViolations, canUseViolations, ...propsToForward -}) { +}: OptionRowLHNDataProps) { const reportID = propsToForward.reportID; - const optionItemRef = useRef(); + const optionItemRef = useRef(); const linkedTransaction = useMemo(() => { const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(reportActions); - const lastReportAction = _.first(sortedReportActions); + const lastReportAction = sortedReportActions[0]; return TransactionUtils.getLinkedTransaction(lastReportAction); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fullReport.reportID, receiptTransactions, reportActions]); + }, [fullReport?.reportID, receiptTransactions, reportActions]); - const hasViolations = canUseViolations && ReportUtils.doesTransactionThreadHaveViolations(fullReport, transactionViolations, parentReportAction); + const hasViolations = canUseViolations && ReportUtils.doesTransactionThreadHaveViolations(fullReport, transactionViolations, parentReportAction ?? null); const optionItem = useMemo(() => { // Note: ideally we'd have this as a dependent selector in onyx! @@ -100,15 +49,17 @@ function OptionRowLHNData({ report: fullReport, reportActions, personalDetails, - preferredLocale, + preferredLocale: preferredLocale ?? CONST.LOCALES.DEFAULT, policy, parentReportAction, - hasViolations, + hasViolations: !!hasViolations, }); if (deepEqual(item, optionItemRef.current)) { return optionItemRef.current; } + optionItemRef.current = item; + return item; // Listen parentReportAction to update title of thread report when parentReportAction changed // Listen to transaction to update title of transaction report when transaction changed @@ -116,7 +67,7 @@ function OptionRowLHNData({ }, [fullReport, linkedTransaction, reportActions, personalDetails, preferredLocale, policy, parentReportAction, transaction, transactionViolations, canUseViolations]); useEffect(() => { - if (!optionItem || optionItem.hasDraftComment || !comment || comment.length <= 0 || isFocused) { + if (!optionItem || !!optionItem.hasDraftComment || !comment || comment.length <= 0 || isFocused) { return; } Report.setReportWithDraft(reportID, true); @@ -133,8 +84,6 @@ function OptionRowLHNData({ ); } -OptionRowLHNData.propTypes = propTypes; -OptionRowLHNData.defaultProps = defaultProps; OptionRowLHNData.displayName = 'OptionRowLHNData'; /** diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts new file mode 100644 index 000000000000..24cebb8e3da2 --- /dev/null +++ b/src/components/LHNOptionsList/types.ts @@ -0,0 +1,124 @@ +import type {ContentStyle} from '@shopify/flash-list'; +import type {RefObject} from 'react'; +import type {StyleProp, TextStyle, View, ViewStyle} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {CurrentReportIDContextValue} from '@components/withCurrentReportID'; +import type CONST from '@src/CONST'; +import type {OptionData} from '@src/libs/ReportUtils'; +import type {Locale, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolation} from '@src/types/onyx'; + +type OptionMode = ValueOf; + +type LHNOptionsListOnyxProps = { + /** The policy which the user has access to and which the report could be tied to */ + policy: OnyxCollection; + + /** All reports shared with the user */ + reports: OnyxCollection; + + /** Array of report actions for this report */ + reportActions: OnyxCollection; + + /** Indicates which locale the user currently has selected */ + preferredLocale: OnyxEntry; + + /** List of users' personal details */ + personalDetails: OnyxEntry; + + /** The transaction from the parent report action */ + transactions: OnyxCollection; + + /** List of draft comments */ + draftComments: OnyxCollection; + + /** The list of transaction violations */ + transactionViolations: OnyxCollection; +}; + +type CustomLHNOptionsListProps = { + /** Wrapper style for the section list */ + style?: StyleProp; + + /** Extra styles for the section list container */ + contentContainerStyles?: StyleProp; + + /** Sections for the section list */ + data: string[]; + + /** Callback to fire when a row is selected */ + onSelectRow: (reportID: string) => void; + + /** Toggle between compact and default view of the option */ + optionMode: OptionMode; + + /** Whether to allow option focus or not */ + shouldDisableFocusOptions?: boolean; +}; + +type LHNOptionsListProps = CustomLHNOptionsListProps & CurrentReportIDContextValue & LHNOptionsListOnyxProps; + +type OptionRowLHNDataProps = { + /** Whether row should be focused */ + isFocused?: boolean; + + /** List of users' personal details */ + personalDetails?: PersonalDetailsList; + + /** The preferred language for the app */ + preferredLocale?: OnyxEntry; + + /** The full data of the report */ + fullReport: OnyxEntry; + + /** The policy which the user has access to and which the report could be tied to */ + policy?: OnyxEntry; + + /** The action from the parent report */ + parentReportAction?: OnyxEntry; + + /** The transaction from the parent report action */ + transaction: OnyxEntry; + + /** Comment added to report */ + comment: string; + + /** The receipt transaction from the parent report action */ + receiptTransactions: OnyxCollection; + + /** The reportID of the report */ + reportID: string; + + /** Array of report actions for this report */ + reportActions: OnyxEntry; + + /** List of transaction violation */ + transactionViolations: OnyxCollection; + + /** Whether the user can use violations */ + canUseViolations: boolean | undefined; +}; + +type OptionRowLHNProps = { + /** The ID of the report that the option is for */ + reportID: string; + + /** Whether this option is currently in focus so we can modify its style */ + isFocused?: boolean; + + /** A function that is called when an option is selected. Selected option is passed as a param */ + onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject) => void; + + /** Toggle between compact and default view */ + viewMode?: OptionMode; + + /** Additional style props */ + style?: StyleProp; + + /** The item that should be rendered */ + optionItem?: OptionData; +}; + +type RenderItemProps = {item: string}; + +export type {LHNOptionsListProps, OptionRowLHNDataProps, OptionRowLHNProps, LHNOptionsListOnyxProps, RenderItemProps}; diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 59465e34eec0..8614736d200f 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -61,6 +61,9 @@ const propTypes = { /** Last pressed digit on BigDigitPad */ lastPressedDigit: PropTypes.string, + + /** TestID for test */ + testID: PropTypes.string, }; const defaultProps = { @@ -77,6 +80,7 @@ const defaultProps = { maxLength: CONST.MAGIC_CODE_LENGTH, isDisableKeyboard: false, lastPressedDigit: '', + testID: '', }; /** @@ -394,6 +398,7 @@ function MagicCodeInput(props) { role={CONST.ACCESSIBILITY_ROLE.TEXT} style={[styles.inputTransparent]} textInputContainerStyles={[styles.borderNone]} + testID={props.testID} /> diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 6e5b4eddae9e..a250e21c0021 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -9,6 +9,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; +import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; import useNativeDriver from '@libs/useNativeDriver'; import variables from '@styles/variables'; import * as Modal from '@userActions/Modal'; @@ -38,6 +39,7 @@ function BaseModal( onLayout, avoidKeyboard = false, children, + shouldUseCustomBackdrop = false, }: BaseModalProps, ref: React.ForwardedRef, ) { @@ -185,7 +187,7 @@ function BaseModal( swipeDirection={swipeDirection} isVisible={isVisible} backdropColor={theme.overlay} - backdropOpacity={hideBackdrop ? 0 : variables.overlayOpacity} + backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity} backdropTransitionOutTiming={0} hasBackdrop={fullscreen} coverScreen={fullscreen} @@ -201,6 +203,7 @@ function BaseModal( statusBarTranslucent={statusBarTranslucent} onLayout={onLayout} avoidKeyboard={avoidKeyboard} + customBackdrop={shouldUseCustomBackdrop ? : undefined} > & { * See: https://github.com/react-native-modal/react-native-modal/pull/116 * */ hideModalContentWhileAnimating?: boolean; + + /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ + shouldUseCustomBackdrop?: boolean; }; export default BaseModalProps; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index afdc62218f95..7043173b3641 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -57,6 +57,7 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); const policyType = policy?.type; const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && policy?.role === CONST.POLICY.ROLE.ADMIN; + const isAutoReimbursable = ReportUtils.canBeAutoReimbursed(moneyRequestReport, policy); const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicy(moneyRequestReport); const isManager = ReportUtils.isMoneyRequestReport(moneyRequestReport) && session?.accountID === moneyRequestReport.managerID; const isPayer = isPaidGroupPolicy @@ -65,8 +66,8 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager); const isDraft = ReportUtils.isDraftExpenseReport(moneyRequestReport); const shouldShowPayButton = useMemo( - () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport), - [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableTotal, chatReport], + () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport) && !isAutoReimbursable, + [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableTotal, chatReport, isAutoReimbursable], ); const shouldShowApproveButton = useMemo(() => { if (!isPaidGroupPolicy) { diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index f66e73a2ef02..590154b48bca 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -36,6 +36,7 @@ import Image from './Image'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import optionPropTypes from './optionPropTypes'; import OptionsSelector from './OptionsSelector'; +import ReceiptEmptyState from './ReceiptEmptyState'; import SettlementButton from './SettlementButton'; import ShowMoreButton from './ShowMoreButton'; import Switch from './Switch'; @@ -604,7 +605,7 @@ function MoneyRequestConfirmationList(props) { )} - {(receiptImage || receiptThumbnail) && ( + {receiptImage || receiptThumbnail ? ( + ) : ( + // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") + PolicyUtils.isPaidGroupPolicy(props.policy) && + !props.isDistanceRequest && + props.iouType === CONST.IOU.TYPE.REQUEST && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( + CONST.IOU.ACTION.CREATE, + props.iouType, + transaction.transactionID, + props.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ) + } + /> + ) )} {props.shouldShowSmartScanFields && ( )} - {(receiptImage || receiptThumbnail) && ( + {receiptImage || receiptThumbnail ? ( + ) : ( + // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") + PolicyUtils.isPaidGroupPolicy(policy) && + !isDistanceRequest && + iouType === CONST.IOU.TYPE.REQUEST && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ) + } + /> + ) )} {shouldShowSmartScanFields && ( diff --git a/src/components/Picker/BasePicker.tsx b/src/components/Picker/BasePicker.tsx index 678bb6f06403..1bee95532104 100644 --- a/src/components/Picker/BasePicker.tsx +++ b/src/components/Picker/BasePicker.tsx @@ -49,10 +49,6 @@ function BasePicker( // reference to @react-native-picker/picker const picker = useRef(null); - // Windows will reuse the text color of the select for each one of the options - // so we might need to color accordingly so it doesn't blend with the background. - const pickerPlaceholder = Object.keys(placeholder).length > 0 ? {...placeholder, color: theme.text} : {}; - useEffect(() => { if (!!value || !items || items.length !== 1 || !onInputChange) { return; @@ -152,6 +148,10 @@ function BasePicker( return theme.text; }, [theme]); + // Windows will reuse the text color of the select for each one of the options + // so we might need to color accordingly so it doesn't blend with the background. + const pickerPlaceholder = Object.keys(placeholder).length > 0 ? {...placeholder, color: itemColor} : {}; + const hasError = !!errorText; if (isDisabled) { diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index b1a6ebb0c5c0..a738d1f9798a 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -95,9 +95,9 @@ function PopoverContextProvider(props: PopoverContextProps) { closePopover(); }; - document.addEventListener('scroll', listener, true); + document.addEventListener('wheel', listener, true); return () => { - document.removeEventListener('scroll', listener, true); + document.removeEventListener('wheel', listener, true); }; }, [closePopover]); diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx index f41a6b389001..1df56093d6a6 100644 --- a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx @@ -68,7 +68,7 @@ function GenericPressable( if (shouldUseDisabledCursor) { return styles.cursorDisabled; } - if ([rest.accessibilityRole, rest.role].includes('text')) { + if ([rest.accessibilityRole, rest.role].includes(CONST.ROLE.PRESENTATION)) { return styles.cursorText; } return styles.cursorPointer; diff --git a/src/components/PurposeForUsingExpensifyModal.tsx b/src/components/PurposeForUsingExpensifyModal.tsx new file mode 100644 index 000000000000..a8cab171ffca --- /dev/null +++ b/src/components/PurposeForUsingExpensifyModal.tsx @@ -0,0 +1,178 @@ +import {useNavigation} from '@react-navigation/native'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {ScrollView, View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as Report from '@userActions/Report'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import HeaderWithBackButton from './HeaderWithBackButton'; +import * as Expensicons from './Icon/Expensicons'; +import Lottie from './Lottie'; +import LottieAnimations from './LottieAnimations'; +import type {MenuItemProps} from './MenuItem'; +import MenuItemList from './MenuItemList'; +import Modal from './Modal'; +import Text from './Text'; + +// This is not translated because it is a message coming from concierge, which only supports english +const messageCopy = { + [CONST.INTRO_CHOICES.TRACK]: + 'Great! To track your expenses, I suggest you create a workspace to keep everything contained:\n' + + '\n' + + '1. Press your avatar icon\n' + + '2. Choose Workspaces\n' + + '3. Choose New Workspace\n' + + '4. Name your workspace something meaningful (eg, "My Business Expenses")\n' + + '\n' + + 'Once you have your workspace set up, you can add expenses to it as follows:\n' + + '\n' + + '1. Choose My Business Expenses (or whatever you named it) in the list of chat rooms\n' + + '2. Choose the + button in the chat compose window\n' + + '3. Choose Request money\n' + + "4. Choose what kind of expense you'd like to log, whether a manual expense, scanned receipt, or tracked distance.\n" + + '\n' + + "That'll be stored in your My Business Expenses room for your later access. Thanks for asking, and let me know how it goes!", + [CONST.INTRO_CHOICES.SUBMIT]: + 'Hi there, to submit expenses for reimbursement, please:\n' + + '\n' + + '1. Press the big green + button\n' + + '2. Choose Request money\n' + + '3. Indicate how much to request, either manually, by scanning a receipt, or by tracking distance\n' + + '4. Enter the email address or phone number of your boss\n' + + '\n' + + "And we'll take it from there to get you paid back. Please give it a shot and let me know how it goes!", + [CONST.INTRO_CHOICES.MANAGE_TEAM]: + "Great! To manage your team's expenses, create a workspace to keep everything contained:\n" + + '\n' + + '1. Press your avatar icon\n' + + '2. Choose Workspaces\n' + + '3. Choose New Workspace\n' + + '4. Name your workspace something meaningful (eg, "Galaxy Food Inc.")\n' + + '\n' + + 'Once you have your workspace set up, you can invite your team to it via the Members pane and connect a business bank account to reimburse them!', + [CONST.INTRO_CHOICES.CHAT_SPLIT]: + 'Hi there, to split an expense such as with a friend, please:\n' + + '\n' + + 'Press the big green + button\n' + + 'Choose *Request money*\n' + + 'Indicate how much was spent, either manually, by scanning a receipt, or by tracking distance\n' + + 'Enter the email address or phone number of your friend\n' + + 'Press *Split* next to their name\n' + + 'Repeat as many times as you like for each of your friends\n' + + 'Press *Add to split* when done adding friends\n' + + 'Press Split to split the bill\n' + + '\n' + + "This will send a money request to each of your friends for however much they owe you, and we'll take care of getting you paid back. Thanks for asking, and let me know how it goes!", +}; + +const menuIcons = { + [CONST.INTRO_CHOICES.TRACK]: Expensicons.ReceiptSearch, + [CONST.INTRO_CHOICES.SUBMIT]: Expensicons.Scan, + [CONST.INTRO_CHOICES.MANAGE_TEAM]: Expensicons.MoneyBag, + [CONST.INTRO_CHOICES.CHAT_SPLIT]: Expensicons.Briefcase, +}; + +function PurposeForUsingExpensifyModal() { + const {translate} = useLocalize(); + const StyleUtils = useStyleUtils(); + const styles = useThemeStyles(); + const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); + const navigation = useNavigation(); + const [isModalOpen, setIsModalOpen] = useState(false); + const theme = useTheme(); + + useEffect(() => { + const navigationState = navigation.getState(); + const routes = navigationState.routes; + const currentRoute = routes[navigationState.index]; + if (currentRoute && NAVIGATORS.CENTRAL_PANE_NAVIGATOR !== currentRoute.name && currentRoute.name !== SCREENS.HOME) { + return; + } + + Welcome.show(routes, () => setIsModalOpen(true)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const closeModal = useCallback(() => { + Report.dismissEngagementModal(); + setIsModalOpen(false); + }, []); + + const completeModalAndClose = useCallback((message: string, choice: ValueOf) => { + Report.completeEngagementModal(message, choice); + setIsModalOpen(false); + Report.navigateToConciergeChat(); + }, []); + + const menuItems: MenuItemProps[] = useMemo( + () => + Object.values(CONST.INTRO_CHOICES).map((choice) => { + const translationKey = `purposeForExpensify.${choice}` as const; + return { + key: translationKey, + title: translate(translationKey), + icon: menuIcons[choice], + iconRight: Expensicons.ArrowRight, + onPress: () => completeModalAndClose(messageCopy[choice], choice), + shouldShowRightIcon: true, + numberOfLinesTitle: 2, + }; + }), + [completeModalAndClose, translate], + ); + + return ( + + + + + + + + + + {translate('purposeForExpensify.welcomeMessage')} + + {translate('purposeForExpensify.welcomeSubtitle')} + + + + + + ); +} + +PurposeForUsingExpensifyModal.displayName = 'PurposeForUsingExpensifyModal'; + +export default PurposeForUsingExpensifyModal; diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 4fcca3e518a5..3d1710de1432 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -14,9 +14,11 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import variables from '@styles/variables'; +import ROUTES from '@src/ROUTES'; import type {PolicyReportField, Report} from '@src/types/onyx'; type MoneyReportViewProps = { @@ -73,9 +75,9 @@ function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule}: {}} + onPress={() => Navigation.navigate(ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report.reportID, report.policyID ?? '', reportField.fieldID))} shouldShowRightIcon - disabled={false} + disabled={ReportUtils.isReportFieldOfTypeTitle(reportField)} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} shouldGreyOutWhenDisabled={false} numberOfLinesTitle={0} diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index 4c727439b876..3121328138ee 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -350,7 +350,7 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate isPayer && !isDraftExpenseReport && !iouSettled && !props.iouReport.isWaitingOnBankAccount && reimbursableSpend !== 0 && !iouCanceled, - [isPayer, isDraftExpenseReport, iouSettled, reimbursableSpend, iouCanceled, props.iouReport], + () => isPayer && !isDraftExpenseReport && !iouSettled && !props.iouReport.isWaitingOnBankAccount && reimbursableSpend !== 0 && !iouCanceled && !isAutoReimbursable, + [isPayer, isDraftExpenseReport, iouSettled, reimbursableSpend, iouCanceled, isAutoReimbursable, props.iouReport], ); const shouldShowApproveButton = useMemo(() => { if (!isPaidGroupPolicy) { diff --git a/src/components/RoomHeaderAvatars.js b/src/components/RoomHeaderAvatars.js index a3394fe71539..64cc9ac7abf3 100644 --- a/src/components/RoomHeaderAvatars.js +++ b/src/components/RoomHeaderAvatars.js @@ -4,9 +4,9 @@ import {View} from 'react-native'; import _ from 'underscore'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as UserUtils from '@libs/UserUtils'; +import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; -import AttachmentModal from './AttachmentModal'; +import ROUTES from '@src/ROUTES'; import Avatar from './Avatar'; import avatarPropTypes from './avatarPropTypes'; import PressableWithoutFocus from './Pressable/PressableWithoutFocus'; @@ -14,13 +14,23 @@ import Text from './Text'; const propTypes = { icons: PropTypes.arrayOf(avatarPropTypes), + reportID: PropTypes.string, }; const defaultProps = { icons: [], + reportID: '', }; function RoomHeaderAvatars(props) { + const navigateToAvatarPage = (icon) => { + if (icon.type === CONST.ICON_TYPE_WORKSPACE) { + Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(props.reportID)); + return; + } + Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(icon.id)); + }; + const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); if (!props.icons.length) { @@ -29,31 +39,21 @@ function RoomHeaderAvatars(props) { if (props.icons.length === 1) { return ( - navigateToAvatarPage(props.icons[0])} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + accessibilityLabel={props.icons[0].name} > - {({show}) => ( - - - - )} - + + ); } @@ -73,31 +73,21 @@ function RoomHeaderAvatars(props) { key={`${icon.id}${index}`} style={[styles.justifyContentCenter, styles.alignItemsCenter]} > - navigateToAvatarPage(icon)} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + accessibilityLabel={icon.name} > - {({show}) => ( - - - - )} - + + {index === CONST.REPORT.MAX_PREVIEW_AVATARS - 1 && props.icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS !== 0 && ( <> ({ }); type ScrollViewWithContextProps = { - onScroll: (event: NativeSyntheticEvent) => void; + onScroll?: (event: NativeSyntheticEvent) => void; children?: React.ReactNode; - scrollEventThrottle: number; -} & Partial; + scrollEventThrottle?: number; +} & Partial; /* * is a wrapper around that provides a ref to the . @@ -54,7 +54,7 @@ function ScrollViewWithContextWithRef({onScroll, scrollEventThrottle, children, {...restProps} ref={scrollViewRef} onScroll={setContextScrollPosition} - scrollEventThrottle={scrollEventThrottle || MIN_SMOOTH_SCROLL_EVENT_THROTTLE} + scrollEventThrottle={scrollEventThrottle ?? MIN_SMOOTH_SCROLL_EVENT_THROTTLE} > {children} diff --git a/src/components/SelectionList/BaseListItem.js b/src/components/SelectionList/BaseListItem.tsx similarity index 91% rename from src/components/SelectionList/BaseListItem.js rename to src/components/SelectionList/BaseListItem.tsx index 6a067ea0fe3d..71845931ba52 100644 --- a/src/components/SelectionList/BaseListItem.js +++ b/src/components/SelectionList/BaseListItem.tsx @@ -1,4 +1,3 @@ -import lodashGet from 'lodash/get'; import React from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; @@ -12,10 +11,10 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import RadioListItem from './RadioListItem'; -import {baseListItemPropTypes} from './selectionListPropTypes'; +import type {BaseListItemProps, RadioItem, User} from './types'; import UserListItem from './UserListItem'; -function BaseListItem({ +function BaseListItem({ item, isFocused = false, isDisabled = false, @@ -26,12 +25,12 @@ function BaseListItem({ onDismissError = () => {}, rightHandSideComponent, keyForList, -}) { +}: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const isUserItem = lodashGet(item, 'icons.length', 0) > 0; + const isUserItem = 'icons' in item && item?.icons?.length && item.icons.length > 0; const ListItem = isUserItem ? UserListItem : RadioListItem; const rightHandSideComponentRender = () => { @@ -49,8 +48,8 @@ function BaseListItem({ return ( onDismissError(item)} - pendingAction={item.pendingAction} - errors={item.errors} + pendingAction={isUserItem ? item.pendingAction : undefined} + errors={isUserItem ? item.errors : undefined} errorRowStyles={styles.ph5} > )} + onSelectRow(item)} showTooltip={showTooltip} /> + {!canSelectMultiple && item.isSelected && !rightHandSideComponent && ( - {Boolean(item.invitedSecondaryLogin) && ( + {isUserItem && item.invitedSecondaryLogin && ( {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} @@ -140,6 +141,5 @@ function BaseListItem({ } BaseListItem.displayName = 'BaseListItem'; -BaseListItem.propTypes = baseListItemPropTypes; export default BaseListItem; diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.tsx similarity index 76% rename from src/components/SelectionList/BaseSelectionList.js rename to src/components/SelectionList/BaseSelectionList.tsx index 960618808fd9..d97c47c84ee7 100644 --- a/src/components/SelectionList/BaseSelectionList.js +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -1,8 +1,8 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; -import lodashGet from 'lodash/get'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {ForwardedRef} from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListRenderItemInfo} from 'react-native'; import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; @@ -13,69 +13,61 @@ import SafeAreaConsumer from '@components/SafeAreaConsumer'; import SectionList from '@components/SectionList'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import withKeyboardState, {keyboardStatePropTypes} from '@components/withKeyboardState'; import useActiveElementRole from '@hooks/useActiveElementRole'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import BaseListItem from './BaseListItem'; -import {propTypes as selectionListPropTypes} from './selectionListPropTypes'; - -const propTypes = { - ...keyboardStatePropTypes, - ...selectionListPropTypes, -}; - -function BaseSelectionList({ - sections, - canSelectMultiple = false, - onSelectRow, - onSelectAll, - onDismissError, - textInputLabel = '', - textInputPlaceholder = '', - textInputValue = '', - textInputHint = '', - textInputMaxLength, - inputMode = CONST.INPUT_MODE.TEXT, - onChangeText, - initiallyFocusedOptionKey = '', - onScroll, - onScrollBeginDrag, - headerMessage = '', - confirmButtonText = '', - onConfirm, - headerContent, - footerContent, - showScrollIndicator = false, - showLoadingPlaceholder = false, - showConfirmButton = false, - shouldPreventDefaultFocusOnSelectRow = false, - isKeyboardShown = false, - containerStyle = [], - disableInitialFocusOptionStyle = false, - inputRef = null, - disableKeyboardShortcuts = false, - children, - shouldStopPropagation = false, - shouldShowTooltips = true, - shouldUseDynamicMaxToRenderPerBatch = false, - rightHandSideComponent, -}) { - const theme = useTheme(); +import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, RadioItem, Section, SectionListDataType, User} from './types'; + +function BaseSelectionList( + { + sections, + canSelectMultiple = false, + onSelectRow, + onSelectAll, + onDismissError, + textInputLabel = '', + textInputPlaceholder = '', + textInputValue = '', + textInputHint, + textInputMaxLength, + inputMode = CONST.INPUT_MODE.TEXT, + onChangeText, + initiallyFocusedOptionKey = '', + onScroll, + onScrollBeginDrag, + headerMessage = '', + confirmButtonText = '', + onConfirm = () => {}, + headerContent, + footerContent, + showScrollIndicator = false, + showLoadingPlaceholder = false, + showConfirmButton = false, + shouldPreventDefaultFocusOnSelectRow = false, + containerStyle, + isKeyboardShown = false, + disableKeyboardShortcuts = false, + children, + shouldStopPropagation = false, + shouldShowTooltips = true, + shouldUseDynamicMaxToRenderPerBatch = false, + rightHandSideComponent, + }: BaseSelectionListProps, + inputRef: ForwardedRef, +) { const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const listRef = useRef(null); - const textInputRef = useRef(null); - const focusTimeoutRef = useRef(null); - const shouldShowTextInput = Boolean(textInputLabel); - const shouldShowSelectAll = Boolean(onSelectAll); + const listRef = useRef>>(null); + const textInputRef = useRef(null); + const focusTimeoutRef = useRef(null); + const shouldShowTextInput = !!textInputLabel; + const shouldShowSelectAll = !!onSelectAll; const activeElementRole = useActiveElementRole(); const isFocused = useIsFocused(); const [maxToRenderPerBatch, setMaxToRenderPerBatch] = useState(shouldUseDynamicMaxToRenderPerBatch ? 0 : CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); @@ -87,26 +79,24 @@ function BaseSelectionList({ * - `disabledOptionsIndexes`: Contains the indexes of all the disabled items in the list, to be used by the ArrowKeyFocusManager * - `itemLayouts`: Contains the layout information for each item, header and footer in the list, * so we can calculate the position of any given item when scrolling programmatically - * - * @return {{itemLayouts: [{offset: number, length: number}], disabledOptionsIndexes: *[], allOptions: *[]}} */ - const flattenedSections = useMemo(() => { - const allOptions = []; + const flattenedSections = useMemo>(() => { + const allOptions: TItem[] = []; - const disabledOptionsIndexes = []; + const disabledOptionsIndexes: number[] = []; let disabledIndex = 0; let offset = 0; const itemLayouts = [{length: 0, offset}]; - const selectedOptions = []; + const selectedOptions: TItem[] = []; - _.each(sections, (section, sectionIndex) => { + sections.forEach((section, sectionIndex) => { const sectionHeaderHeight = variables.optionsListSectionHeaderHeight; itemLayouts.push({length: sectionHeaderHeight, offset}); offset += sectionHeaderHeight; - _.each(section.data, (item, optionIndex) => { + section.data?.forEach((item, optionIndex) => { // Add item to the general flattened array allOptions.push({ ...item, @@ -115,7 +105,7 @@ function BaseSelectionList({ }); // If disabled, add to the disabled indexes array - if (section.isDisabled || item.isDisabled) { + if (!!section.isDisabled || item.isDisabled) { disabledOptionsIndexes.push(disabledIndex); } disabledIndex += 1; @@ -155,34 +145,34 @@ function BaseSelectionList({ }, [canSelectMultiple, sections]); // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member - const [focusedIndex, setFocusedIndex] = useState(() => _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey)); + const [focusedIndex, setFocusedIndex] = useState(() => flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey)); // Disable `Enter` shortcut if the active element is a button or checkbox - const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX].includes(activeElementRole); + const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX].includes(activeElementRole as ButtonOrCheckBoxRoles); /** * Scrolls to the desired item index in the section list * - * @param {Number} index - the index of the item to scroll to - * @param {Boolean} animated - whether to animate the scroll + * @param index - the index of the item to scroll to + * @param animated - whether to animate the scroll */ const scrollToIndex = useCallback( - (index, animated = true) => { + (index: number, animated = true) => { const item = flattenedSections.allOptions[index]; if (!listRef.current || !item) { return; } - const itemIndex = item.index; - const sectionIndex = item.sectionIndex; + const itemIndex = item.index ?? -1; + const sectionIndex = item.sectionIndex ?? -1; // Note: react-native's SectionList automatically strips out any empty sections. // So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to. // Otherwise, it will cause an index-out-of-bounds error and crash the app. let adjustedSectionIndex = sectionIndex; for (let i = 0; i < sectionIndex; i++) { - if (_.isEmpty(lodashGet(sections, `[${i}].data`))) { + if (sections[i].data) { adjustedSectionIndex--; } } @@ -197,10 +187,10 @@ function BaseSelectionList({ /** * Logic to run when a row is selected, either with click/press or keyboard hotkeys. * - * @param {Object} item - the list item - * @param {Boolean} shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) + * @param item - the list item + * @param shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) */ - const selectRow = (item, shouldUnfocusRow = false) => { + const selectRow = (item: TItem, shouldUnfocusRow = false) => { // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item if (canSelectMultiple) { if (sections.length > 1) { @@ -233,15 +223,15 @@ function BaseSelectionList({ }; const selectAllRow = () => { - onSelectAll(); + onSelectAll?.(); + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && textInputRef.current) { textInputRef.current.focus(); } }; - const selectFocusedOption = (e) => { - const focusedItemKey = lodashGet(e, ['target', 'attributes', 'id', 'value']); - const focusedOption = focusedItemKey ? _.find(flattenedSections.allOptions, (option) => option.keyForList === focusedItemKey) : flattenedSections.allOptions[focusedIndex]; + const selectFocusedOption = () => { + const focusedOption = flattenedSections.allOptions[focusedIndex]; if (!focusedOption || focusedOption.isDisabled) { return; @@ -254,8 +244,8 @@ function BaseSelectionList({ * This function is used to compute the layout of any given item in our list. * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList. * - * @param {Array} data - This is the same as the data we pass into the component - * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks: + * @param data - This is the same as the data we pass into the component + * @param flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks: * * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those. * 2. Each section includes a header, even if we don't provide/render one. @@ -263,10 +253,8 @@ function BaseSelectionList({ * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this: * * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}] - * - * @returns {Object} */ - const getItemLayout = (data, flatDataArrayIndex) => { + const getItemLayout = (data: Array> | null, flatDataArrayIndex: number) => { const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex]; if (!targetItem) { @@ -284,8 +272,8 @@ function BaseSelectionList({ }; }; - const renderSectionHeader = ({section}) => { - if (!section.title || _.isEmpty(section.data)) { + const renderSectionHeader = ({section}: {section: SectionListDataType}) => { + if (!section.title || isEmptyObject(section.data)) { return null; } @@ -300,9 +288,10 @@ function BaseSelectionList({ ); }; - const renderItem = ({item, index, section}) => { - const normalizedIndex = index + lodashGet(section, 'indexOffset', 0); - const isDisabled = section.isDisabled || item.isDisabled; + const renderItem = ({item, index, section}: SectionListRenderItemInfo>) => { + const indexOffset = section.indexOffset ? section.indexOffset : 0; + const normalizedIndex = index + indexOffset; + const isDisabled = !!section.isDisabled || item.isDisabled; const isItemFocused = !isDisabled && focusedIndex === normalizedIndex; // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const showTooltip = shouldShowTooltips && normalizedIndex < 10; @@ -312,11 +301,9 @@ function BaseSelectionList({ item={item} isFocused={isItemFocused} isDisabled={isDisabled} - isHide={!maxToRenderPerBatch} showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={() => selectRow(item, true)} - disableIsFocusStyle={disableInitialFocusOptionStyle} onDismissError={onDismissError} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} rightHandSideComponent={rightHandSideComponent} @@ -326,11 +313,10 @@ function BaseSelectionList({ }; const scrollToFocusedIndexOnFirstRender = useCallback( - ({nativeEvent}) => { + (nativeEvent: LayoutChangeEvent) => { if (shouldUseDynamicMaxToRenderPerBatch) { - const listHeight = lodashGet(nativeEvent, 'layout.height', 0); - const itemHeight = lodashGet(nativeEvent, 'layout.y', 0); - + const listHeight = nativeEvent.nativeEvent.layout.height; + const itemHeight = nativeEvent.nativeEvent.layout.y; setMaxToRenderPerBatch((Math.ceil(listHeight / itemHeight) || 0) + CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); } @@ -344,7 +330,7 @@ function BaseSelectionList({ ); const updateAndScrollToFocusedIndex = useCallback( - (newFocusedIndex) => { + (newFocusedIndex: number) => { setFocusedIndex(newFocusedIndex); scrollToIndex(newFocusedIndex, true); }, @@ -355,7 +341,12 @@ function BaseSelectionList({ useFocusEffect( useCallback(() => { if (shouldShowTextInput) { - focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION); + focusTimeoutRef.current = setTimeout(() => { + if (!textInputRef.current) { + return; + } + textInputRef.current.focus(); + }, CONST.ANIMATED_TRANSITION); } return () => { if (!focusTimeoutRef.current) { @@ -382,7 +373,7 @@ function BaseSelectionList({ /** Selects row when pressing Enter */ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { captureOnInputs: true, - shouldBubble: () => !flattenedSections.allOptions[focusedIndex], + shouldBubble: !flattenedSections.allOptions[focusedIndex], shouldStopPropagation, isActive: !disableKeyboardShortcuts && !disableEnterShortcut && isFocused, }); @@ -390,8 +381,8 @@ function BaseSelectionList({ /** Calls confirm action when pressing CTRL (CMD) + Enter */ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm, { captureOnInputs: true, - shouldBubble: () => !flattenedSections.allOptions[focusedIndex], - isActive: !disableKeyboardShortcuts && Boolean(onConfirm) && isFocused, + shouldBubble: !flattenedSections.allOptions[focusedIndex], + isActive: !disableKeyboardShortcuts && !!onConfirm && isFocused, }); return ( @@ -401,19 +392,22 @@ function BaseSelectionList({ maxIndex={flattenedSections.allOptions.length - 1} onFocusedIndexChanged={updateAndScrollToFocusedIndex} > - {/* */} {({safeAreaPaddingBottomStyle}) => ( - + {shouldShowTextInput && ( { - if (inputRef) { - // eslint-disable-next-line no-param-reassign - inputRef.current = el; + ref={(element) => { + textInputRef.current = element as RNTextInput; + + if (!inputRef) { + return; + } + + if (typeof inputRef === 'function') { + inputRef(element as RNTextInput); } - textInputRef.current = el; }} label={textInputLabel} accessibilityLabel={textInputLabel} @@ -427,16 +421,16 @@ function BaseSelectionList({ selectTextOnFocus spellCheck={false} onSubmitEditing={selectFocusedOption} - blurOnSubmit={Boolean(flattenedSections.allOptions.length)} + blurOnSubmit={!!flattenedSections.allOptions.length} /> )} - {Boolean(headerMessage) && ( + {!!headerMessage && ( {headerMessage} )} - {Boolean(headerContent) && headerContent} + {!!headerContent && headerContent} {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? ( ) : ( @@ -474,7 +468,7 @@ function BaseSelectionList({ onScrollBeginDrag={onScrollBeginDrag} keyExtractor={(item) => item.keyForList} extraData={focusedIndex} - indicatorStyle={theme.white} + indicatorStyle="white" keyboardShouldPersistTaps="always" showsVerticalScrollIndicator={showScrollIndicator} initialNumToRender={12} @@ -500,7 +494,7 @@ function BaseSelectionList({ /> )} - {Boolean(footerContent) && {footerContent}} + {!!footerContent && {footerContent}} )} @@ -509,6 +503,5 @@ function BaseSelectionList({ } BaseSelectionList.displayName = 'BaseSelectionList'; -BaseSelectionList.propTypes = propTypes; -export default withKeyboardState(BaseSelectionList); +export default forwardRef(BaseSelectionList); diff --git a/src/components/SelectionList/RadioListItem.js b/src/components/SelectionList/RadioListItem.tsx similarity index 87% rename from src/components/SelectionList/RadioListItem.js rename to src/components/SelectionList/RadioListItem.tsx index 2de0c96932ea..769eaa80df4b 100644 --- a/src/components/SelectionList/RadioListItem.js +++ b/src/components/SelectionList/RadioListItem.tsx @@ -3,10 +3,11 @@ import {View} from 'react-native'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useThemeStyles from '@hooks/useThemeStyles'; -import {radioListItemPropTypes} from './selectionListPropTypes'; +import type {RadioListItemProps} from './types'; -function RadioListItem({item, showTooltip, textStyles, alternateTextStyles}) { +function RadioListItem({item, showTooltip, textStyles, alternateTextStyles}: RadioListItemProps) { const styles = useThemeStyles(); + return ( - {Boolean(item.alternateText) && ( + {!!item.alternateText && ( - {Boolean(item.icons) && ( + {!!item.icons && ( )} @@ -26,19 +23,19 @@ function UserListItem({item, textStyles, alternateTextStyles, showTooltip, style text={item.text} > {item.text} - {Boolean(item.alternateText) && ( + {!!item.alternateText && ( {item.alternateText} @@ -46,12 +43,11 @@ function UserListItem({item, textStyles, alternateTextStyles, showTooltip, style )} - {Boolean(item.rightElement) && item.rightElement} + {!!item.rightElement && item.rightElement} ); } UserListItem.displayName = 'UserListItem'; -UserListItem.propTypes = userListItemPropTypes; export default UserListItem; diff --git a/src/components/SelectionList/index.android.js b/src/components/SelectionList/index.android.js deleted file mode 100644 index 53d5b6bbce06..000000000000 --- a/src/components/SelectionList/index.android.js +++ /dev/null @@ -1,17 +0,0 @@ -import React, {forwardRef} from 'react'; -import {Keyboard} from 'react-native'; -import BaseSelectionList from './BaseSelectionList'; - -const SelectionList = forwardRef((props, ref) => ( - Keyboard.dismiss()} - /> -)); - -SelectionList.displayName = 'SelectionList'; - -export default SelectionList; diff --git a/src/components/SelectionList/index.android.tsx b/src/components/SelectionList/index.android.tsx new file mode 100644 index 000000000000..8487c6e2cc67 --- /dev/null +++ b/src/components/SelectionList/index.android.tsx @@ -0,0 +1,22 @@ +import React, {forwardRef} from 'react'; +import type {ForwardedRef} from 'react'; +import {Keyboard} from 'react-native'; +import type {TextInput} from 'react-native'; +import BaseSelectionList from './BaseSelectionList'; +import type {BaseSelectionListProps, RadioItem, User} from './types'; + +function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { + return ( + Keyboard.dismiss()} + /> + ); +} + +SelectionList.displayName = 'SelectionList'; + +export default forwardRef(SelectionList); diff --git a/src/components/SelectionList/index.ios.js b/src/components/SelectionList/index.ios.js deleted file mode 100644 index 7f2a282aeb89..000000000000 --- a/src/components/SelectionList/index.ios.js +++ /dev/null @@ -1,16 +0,0 @@ -import React, {forwardRef} from 'react'; -import {Keyboard} from 'react-native'; -import BaseSelectionList from './BaseSelectionList'; - -const SelectionList = forwardRef((props, ref) => ( - Keyboard.dismiss()} - /> -)); - -SelectionList.displayName = 'SelectionList'; - -export default SelectionList; diff --git a/src/components/SelectionList/index.ios.tsx b/src/components/SelectionList/index.ios.tsx new file mode 100644 index 000000000000..9c32d38314e2 --- /dev/null +++ b/src/components/SelectionList/index.ios.tsx @@ -0,0 +1,21 @@ +import React, {forwardRef} from 'react'; +import type {ForwardedRef} from 'react'; +import {Keyboard} from 'react-native'; +import type {TextInput} from 'react-native'; +import BaseSelectionList from './BaseSelectionList'; +import type {BaseSelectionListProps, RadioItem, User} from './types'; + +function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + ref={ref} + onScrollBeginDrag={() => Keyboard.dismiss()} + /> + ); +} + +SelectionList.displayName = 'SelectionList'; + +export default forwardRef(SelectionList); diff --git a/src/components/SelectionList/index.js b/src/components/SelectionList/index.tsx similarity index 82% rename from src/components/SelectionList/index.js rename to src/components/SelectionList/index.tsx index 24ea60d29be5..93754926cacb 100644 --- a/src/components/SelectionList/index.js +++ b/src/components/SelectionList/index.tsx @@ -1,9 +1,12 @@ import React, {forwardRef, useEffect, useState} from 'react'; +import type {ForwardedRef} from 'react'; import {Keyboard} from 'react-native'; +import type {TextInput} from 'react-native'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import BaseSelectionList from './BaseSelectionList'; +import type {BaseSelectionListProps, RadioItem, User} from './types'; -const SelectionList = forwardRef((props, ref) => { +function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { const [isScreenTouched, setIsScreenTouched] = useState(false); const touchStart = () => setIsScreenTouched(true); @@ -39,8 +42,8 @@ const SelectionList = forwardRef((props, ref) => { }} /> ); -}); +} SelectionList.displayName = 'SelectionList'; -export default SelectionList; +export default forwardRef(SelectionList); diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts new file mode 100644 index 000000000000..a82ddef6febb --- /dev/null +++ b/src/components/SelectionList/types.ts @@ -0,0 +1,265 @@ +import type {ReactElement, ReactNode} from 'react'; +import type {GestureResponderEvent, InputModeOptions, SectionListData, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type CommonListItemProps = { + /** Whether this item is focused (for arrow key controls) */ + isFocused?: boolean; + + /** Style to be applied to Text */ + textStyles?: StyleProp; + + /** Style to be applied on the alternate text */ + alternateTextStyles?: StyleProp; + + /** Whether this item is disabled */ + isDisabled?: boolean; + + /** Whether this item should show Tooltip */ + showTooltip: boolean; + + /** Whether to use the Checkbox (multiple selection) instead of the Checkmark (single selection) */ + canSelectMultiple?: boolean; + + /** Callback to fire when the item is pressed */ + onSelectRow: (item: TItem) => void; + + /** Callback to fire when an error is dismissed */ + onDismissError?: (item: TItem) => void; + + /** Component to display on the right side */ + rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null; +}; + +type User = { + /** Text to display */ + text: string; + + /** Alternate text to display */ + alternateText?: string; + + /** Key used internally by React */ + keyForList: string; + + /** Whether this option is selected */ + isSelected?: boolean; + + /** Whether this option is disabled for selection */ + isDisabled?: boolean; + + /** User accountID */ + accountID?: number; + + /** User login */ + login?: string; + + /** Element to show on the right side of the item */ + rightElement?: ReactElement; + + /** Icons for the user (can be multiple if it's a Workspace) */ + icons?: Icon[]; + + /** Errors that this user may contain */ + errors?: Errors; + + /** The type of action that's pending */ + pendingAction?: PendingAction; + + invitedSecondaryLogin?: string; + + /** Represents the index of the section it came from */ + sectionIndex?: number; + + /** Represents the index of the option within the section it came from */ + index?: number; +}; + +type UserListItemProps = CommonListItemProps & { + /** The section list item */ + item: User; + + /** Additional styles to apply to text */ + style?: StyleProp; +}; + +type RadioItem = { + /** Text to display */ + text: string; + + /** Alternate text to display */ + alternateText?: string; + + /** Key used internally by React */ + keyForList: string; + + /** Whether this option is selected */ + isSelected?: boolean; + + /** Whether this option is disabled for selection */ + isDisabled?: boolean; + + /** Represents the index of the section it came from */ + sectionIndex?: number; + + /** Represents the index of the option within the section it came from */ + index?: number; +}; + +type RadioListItemProps = CommonListItemProps & { + /** The section list item */ + item: RadioItem; +}; + +type BaseListItemProps = CommonListItemProps & { + item: TItem; + shouldPreventDefaultFocusOnSelectRow?: boolean; + keyForList?: string; +}; + +type Section = { + /** Title of the section */ + title?: string; + + /** The initial index of this section given the total number of options in each section's data array */ + indexOffset?: number; + + /** Array of options */ + data?: TItem[]; + + /** Whether this section items disabled for selection */ + isDisabled?: boolean; +}; + +type BaseSelectionListProps = Partial & { + /** Sections for the section list */ + sections: Array>>; + + /** Whether this is a multi-select list */ + canSelectMultiple?: boolean; + + /** Callback to fire when a row is pressed */ + onSelectRow: (item: TItem) => void; + + /** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */ + onSelectAll?: () => void; + + /** Callback to fire when an error is dismissed */ + onDismissError?: () => void; + + /** Label for the text input */ + textInputLabel?: string; + + /** Placeholder for the text input */ + textInputPlaceholder?: string; + + /** Hint for the text input */ + textInputHint?: string; + + /** Value for the text input */ + textInputValue?: string; + + /** Max length for the text input */ + textInputMaxLength?: number; + + /** Callback to fire when the text input changes */ + onChangeText?: (text: string) => void; + + /** Input mode for the text input */ + inputMode?: InputModeOptions; + + /** Item `keyForList` to focus initially */ + initiallyFocusedOptionKey?: string; + + /** Callback to fire when the list is scrolled */ + onScroll?: () => void; + + /** Callback to fire when the list is scrolled and the user begins dragging */ + onScrollBeginDrag?: () => void; + + /** Message to display at the top of the list */ + headerMessage?: string; + + /** Text to display on the confirm button */ + confirmButtonText?: string; + + /** Callback to fire when the confirm button is pressed */ + onConfirm?: (e?: GestureResponderEvent | KeyboardEvent | undefined) => void; + + /** Whether to show the vertical scroll indicator */ + showScrollIndicator?: boolean; + + /** Whether to show the loading placeholder */ + showLoadingPlaceholder?: boolean; + + /** Whether to show the default confirm button */ + showConfirmButton?: boolean; + + /** Whether tooltips should be shown */ + shouldShowTooltips?: boolean; + + /** Whether to stop automatic form submission on pressing enter key or not */ + shouldStopPropagation?: boolean; + + /** Whether to prevent default focusing of options and focus the textinput when selecting an option */ + shouldPreventDefaultFocusOnSelectRow?: boolean; + + /** Custom content to display in the header */ + headerContent?: ReactNode; + + /** Custom content to display in the footer */ + footerContent?: ReactNode; + + /** Whether to use dynamic maxToRenderPerBatch depending on the visible number of elements */ + shouldUseDynamicMaxToRenderPerBatch?: boolean; + + /** Whether keyboard shortcuts should be disabled */ + disableKeyboardShortcuts?: boolean; + + /** Whether to disable initial styling for focused option */ + disableInitialFocusOptionStyle?: boolean; + + /** Styles to apply to SelectionList container */ + containerStyle?: ViewStyle; + + /** Whether keyboard is visible on the screen */ + isKeyboardShown?: boolean; + + /** Whether focus event should be delayed */ + shouldDelayFocus?: boolean; + + /** Component to display on the right side of each child */ + rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null; +}; + +type ItemLayout = { + length: number; + offset: number; +}; + +type FlattenedSectionsReturn = { + allOptions: TItem[]; + selectedOptions: TItem[]; + disabledOptionsIndexes: number[]; + itemLayouts: ItemLayout[]; + allSelected: boolean; +}; + +type ButtonOrCheckBoxRoles = 'button' | 'checkbox'; + +type SectionListDataType = SectionListData>; + +export type { + BaseSelectionListProps, + CommonListItemProps, + UserListItemProps, + Section, + RadioListItemProps, + BaseListItemProps, + User, + RadioItem, + FlattenedSectionsReturn, + ItemLayout, + ButtonOrCheckBoxRoles, + SectionListDataType, +}; diff --git a/src/components/SubscriptAvatar.tsx b/src/components/SubscriptAvatar.tsx index 00cf248ad838..bcbca6e2958b 100644 --- a/src/components/SubscriptAvatar.tsx +++ b/src/components/SubscriptAvatar.tsx @@ -4,35 +4,17 @@ import type {ValueOf} from 'type-fest'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {AvatarSource} from '@libs/UserUtils'; import CONST from '@src/CONST'; -import type {AvatarType} from '@src/types/onyx/OnyxCommon'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; import Avatar from './Avatar'; import UserDetailsTooltip from './UserDetailsTooltip'; -type SubAvatar = { - /** Avatar source to display */ - source?: AvatarSource; - - /** Denotes whether it is an avatar or a workspace avatar */ - type?: AvatarType; - - /** Owner of the avatar. If user, displayName. If workspace, policy name */ - name?: string; - - /** Avatar id */ - id?: number | string; - - /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ - fallbackIcon?: AvatarSource; -}; - type SubscriptAvatarProps = { /** Avatar URL or icon */ - mainAvatar?: SubAvatar; + mainAvatar?: Icon; /** Subscript avatar URL or icon */ - secondaryAvatar?: SubAvatar; + secondaryAvatar?: Icon; /** Set the size of avatars */ size?: ValueOf; @@ -47,7 +29,7 @@ type SubscriptAvatarProps = { showTooltip?: boolean; }; -function SubscriptAvatar({mainAvatar = {}, secondaryAvatar = {}, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: SubscriptAvatarProps) { +function SubscriptAvatar({mainAvatar, secondaryAvatar, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: SubscriptAvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -59,23 +41,23 @@ function SubscriptAvatar({mainAvatar = {}, secondaryAvatar = {}, size = CONST.AV @@ -104,3 +86,4 @@ function SubscriptAvatar({mainAvatar = {}, secondaryAvatar = {}, size = CONST.AV SubscriptAvatar.displayName = 'SubscriptAvatar'; export default memo(SubscriptAvatar); +export type {SubscriptAvatarProps}; diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index 7c0b3a6e33e1..410bd5081e25 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -9,8 +9,7 @@ import * as Session from '@userActions/Session'; import * as User from '@userActions/User'; import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; -import type NetworkOnyx from '@src/types/onyx/Network'; -import type UserOnyx from '@src/types/onyx/User'; +import type {Network as NetworkOnyx, User as UserOnyx} from '@src/types/onyx'; import Button from './Button'; import {withNetwork} from './OnyxProvider'; import Switch from './Switch'; @@ -26,7 +25,6 @@ type TestToolMenuProps = TestToolMenuOnyxProps & { /** Network object in Onyx */ network: OnyxEntry; }; - const USER_DEFAULT: UserOnyx = {shouldUseStagingServer: undefined, isSubscribedToNewsletter: false, validated: false, isFromPublicDomain: false, isUsingExpensifyCard: false}; function TestToolMenu({user = USER_DEFAULT, network}: TestToolMenuProps) { diff --git a/src/components/TimePicker/TimePicker.js b/src/components/TimePicker/TimePicker.js index a9b4566a390c..4664251ca765 100644 --- a/src/components/TimePicker/TimePicker.js +++ b/src/components/TimePicker/TimePicker.js @@ -8,6 +8,7 @@ import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; import refPropTypes from '@components/refPropTypes'; import Text from '@components/Text'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -127,6 +128,8 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) { const hourInputRef = useRef(null); const minuteInputRef = useRef(null); + const {inputCallbackRef} = useAutoFocusInput(); + const focusMinuteInputOnFirstCharacter = useCallback(() => setCursorPosition(0, minuteInputRef, setSelectionMinute), []); const focusHourInputOnLastCharacter = useCallback(() => setCursorPosition(2, hourInputRef, setSelectionHour), []); @@ -492,6 +495,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) { minuteInputRef.current = {hourRef: hourInputRef.current, minuteInputRef: ref}; } minuteInputRef.current = ref; + inputCallbackRef(ref); }} onSelectionChange={(e) => { setSelectionMinute(e.nativeEvent.selection); diff --git a/src/components/UpdateAppModal/BaseUpdateAppModal.js b/src/components/UpdateAppModal/BaseUpdateAppModal.tsx similarity index 56% rename from src/components/UpdateAppModal/BaseUpdateAppModal.js rename to src/components/UpdateAppModal/BaseUpdateAppModal.tsx index 07b446172286..2f01f4ff72e0 100755 --- a/src/components/UpdateAppModal/BaseUpdateAppModal.js +++ b/src/components/UpdateAppModal/BaseUpdateAppModal.tsx @@ -1,24 +1,25 @@ -import React, {memo, useState} from 'react'; +import React, {useState} from 'react'; import ConfirmModal from '@components/ConfirmModal'; -import withLocalize from '@components/withLocalize'; -import {defaultProps, propTypes} from './updateAppModalPropTypes'; +import useLocalize from '@hooks/useLocalize'; +import type UpdateAppModalProps from './types'; -function BaseUpdateAppModal({translate, onSubmit}) { +function BaseUpdateAppModal({onSubmit}: UpdateAppModalProps) { const [isModalOpen, setIsModalOpen] = useState(true); + const {translate} = useLocalize(); /** * Execute the onSubmit callback and close the modal. */ - function submitAndClose() { - onSubmit(); + const submitAndClose = () => { + onSubmit?.(); setIsModalOpen(false); - } + }; return ( submitAndClose()} + onConfirm={submitAndClose} onCancel={() => setIsModalOpen(false)} prompt={translate('baseUpdateAppModal.updatePrompt')} confirmText={translate('baseUpdateAppModal.updateApp')} @@ -27,7 +28,6 @@ function BaseUpdateAppModal({translate, onSubmit}) { ); } -BaseUpdateAppModal.propTypes = propTypes; -BaseUpdateAppModal.defaultProps = defaultProps; +BaseUpdateAppModal.displayName = 'BaseUpdateAppModal'; -export default memo(withLocalize(BaseUpdateAppModal)); +export default React.memo(BaseUpdateAppModal); diff --git a/src/components/UpdateAppModal/index.desktop.js b/src/components/UpdateAppModal/index.desktop.tsx similarity index 66% rename from src/components/UpdateAppModal/index.desktop.js rename to src/components/UpdateAppModal/index.desktop.tsx index 397ce2c75ea3..4779e07a5df8 100644 --- a/src/components/UpdateAppModal/index.desktop.js +++ b/src/components/UpdateAppModal/index.desktop.tsx @@ -1,17 +1,16 @@ import React from 'react'; import ELECTRON_EVENTS from '../../../desktop/ELECTRON_EVENTS'; import BaseUpdateAppModal from './BaseUpdateAppModal'; -import {propTypes} from './updateAppModalPropTypes'; +import type UpdateAppModalProps from './types'; -function UpdateAppModal(props) { +function UpdateAppModal({onSubmit}: UpdateAppModalProps) { const updateApp = () => { - if (props.onSubmit) { - props.onSubmit(); - } + onSubmit?.(); window.electron.send(ELECTRON_EVENTS.START_UPDATE); }; return ; } -UpdateAppModal.propTypes = propTypes; + UpdateAppModal.displayName = 'UpdateAppModal'; + export default UpdateAppModal; diff --git a/src/components/UpdateAppModal/index.js b/src/components/UpdateAppModal/index.tsx similarity index 71% rename from src/components/UpdateAppModal/index.js rename to src/components/UpdateAppModal/index.tsx index 488f69f66385..d596ae0686fe 100644 --- a/src/components/UpdateAppModal/index.js +++ b/src/components/UpdateAppModal/index.tsx @@ -1,8 +1,8 @@ import React from 'react'; import BaseUpdateAppModal from './BaseUpdateAppModal'; -import {propTypes} from './updateAppModalPropTypes'; +import type UpdateAppModalProps from './types'; -function UpdateAppModal(props) { +function UpdateAppModal(props: UpdateAppModalProps) { return ( ); } -UpdateAppModal.propTypes = propTypes; + UpdateAppModal.displayName = 'UpdateAppModal'; + export default UpdateAppModal; diff --git a/src/components/UpdateAppModal/types.ts b/src/components/UpdateAppModal/types.ts new file mode 100644 index 000000000000..ae57de671db6 --- /dev/null +++ b/src/components/UpdateAppModal/types.ts @@ -0,0 +1,6 @@ +type UpdateAppModalProps = { + /** Callback to fire when we want to trigger the update. */ + onSubmit?: () => void; +}; + +export default UpdateAppModalProps; diff --git a/src/components/UpdateAppModal/updateAppModalPropTypes.js b/src/components/UpdateAppModal/updateAppModalPropTypes.js deleted file mode 100644 index 37c112bf7598..000000000000 --- a/src/components/UpdateAppModal/updateAppModalPropTypes.js +++ /dev/null @@ -1,12 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** Callback to fire when we want to trigger the update. */ - onSubmit: PropTypes.func, -}; - -const defaultProps = { - onSubmit: null, -}; - -export {propTypes, defaultProps}; diff --git a/src/hooks/useResetComposerFocus.ts b/src/hooks/useResetComposerFocus.ts new file mode 100644 index 000000000000..e9f88ed93346 --- /dev/null +++ b/src/hooks/useResetComposerFocus.ts @@ -0,0 +1,19 @@ +import {useIsFocused} from '@react-navigation/native'; +import type {MutableRefObject} from 'react'; +import {useEffect, useRef} from 'react'; +import type {TextInput} from 'react-native'; + +export default function useResetComposerFocus(inputRef: MutableRefObject) { + const isFocused = useIsFocused(); + const shouldResetFocus = useRef(false); + + useEffect(() => { + if (!isFocused || !shouldResetFocus.current) { + return; + } + inputRef.current?.focus(); // focus input again + shouldResetFocus.current = false; + }, [isFocused, inputRef]); + + return {isFocused, shouldResetFocus}; +} diff --git a/src/languages/en.ts b/src/languages/en.ts index b6da38df21a0..bb22c7a7856a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1,6 +1,7 @@ import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; import Str from 'expensify-common/lib/str'; import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; import type { AddressLineParams, AlreadySignedInParams, @@ -106,7 +107,7 @@ type StateValue = { type States = Record; -type AllCountries = Record; +type AllCountries = Record; /* eslint-disable max-len */ export default { @@ -2073,6 +2074,14 @@ export default { }, copyReferralLink: 'Copy invite link', }, + purposeForExpensify: { + [CONST.INTRO_CHOICES.TRACK]: 'Track business spend for taxes', + [CONST.INTRO_CHOICES.SUBMIT]: 'Get paid back by my employer', + [CONST.INTRO_CHOICES.MANAGE_TEAM]: "Manage my team's expenses", + [CONST.INTRO_CHOICES.CHAT_SPLIT]: 'Chat and split bills with friends', + welcomeMessage: 'Welcome to Expensify', + welcomeSubtitle: 'What would you like to do?', + }, violations: { allTagLevelsRequired: 'All tags required', autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 2478c8ba8bd2..858fe29a8faf 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2561,6 +2561,14 @@ export default { }, copyReferralLink: 'Copiar enlace de invitación', }, + purposeForExpensify: { + [CONST.INTRO_CHOICES.TRACK]: 'Seguimiento de los gastos de empresa para fines fiscales', + [CONST.INTRO_CHOICES.SUBMIT]: 'Reclamar gastos a mi empleador', + [CONST.INTRO_CHOICES.MANAGE_TEAM]: 'Gestionar los gastos de mi equipo', + [CONST.INTRO_CHOICES.CHAT_SPLIT]: 'Chatea y divide gastos con tus amigos', + welcomeMessage: 'Bienvenido a Expensify', + welcomeSubtitle: '¿Qué te gustaría hacer?', + }, violations: { allTagLevelsRequired: 'Todas las etiquetas son obligatorias', autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, diff --git a/src/libs/ComposerFocusManager.js b/src/libs/ComposerFocusManager.ts similarity index 78% rename from src/libs/ComposerFocusManager.js rename to src/libs/ComposerFocusManager.ts index 569e165da962..b66bbe92599e 100644 --- a/src/libs/ComposerFocusManager.js +++ b/src/libs/ComposerFocusManager.ts @@ -1,18 +1,20 @@ let isReadyToFocusPromise = Promise.resolve(); -let resolveIsReadyToFocus; +let resolveIsReadyToFocus: (value: void | PromiseLike) => void; function resetReadyToFocus() { isReadyToFocusPromise = new Promise((resolve) => { resolveIsReadyToFocus = resolve; }); } + function setReadyToFocus() { if (!resolveIsReadyToFocus) { return; } resolveIsReadyToFocus(); } -function isReadyToFocus() { + +function isReadyToFocus(): Promise { return isReadyToFocusPromise; } diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 1a10eb03a00e..526769723531 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -5,9 +5,12 @@ import { eachDayOfInterval, eachMonthOfInterval, endOfDay, + endOfMonth, endOfWeek, format, formatDistanceToNow, + getDate, + getDay, getDayOfYear, isAfter, isBefore, @@ -730,6 +733,25 @@ function formatToSupportedTimezone(timezoneInput: Timezone): Timezone { }; } +/** + * Returns the last business day of given date month + * + * param {Date} inputDate + * returns {number} + */ +function getLastBusinessDayOfMonth(inputDate: Date): number { + let currentDate = endOfMonth(inputDate); + const dayOfWeek = getDay(currentDate); + + if (dayOfWeek === 0) { + currentDate = subDays(currentDate, 2); + } else if (dayOfWeek === 6) { + currentDate = subDays(currentDate, 1); + } + + return getDate(currentDate); +} + const DateUtils = { formatToDayOfWeek, formatToLongDateWithWeekday, @@ -774,6 +796,7 @@ const DateUtils = { getWeekEndsOn, isTimeAtLeastOneMinuteInFuture, formatToSupportedTimezone, + getLastBusinessDayOfMonth, }; export default DateUtils; diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts index 26b3665ca4ce..5d925ae1c684 100644 --- a/src/libs/GroupChatUtils.ts +++ b/src/libs/GroupChatUtils.ts @@ -1,11 +1,12 @@ +import type {OnyxEntry} from 'react-native-onyx'; import type {Report} from '@src/types/onyx'; import * as ReportUtils from './ReportUtils'; /** * Returns the report name if the report is a group chat */ -function getGroupChatName(report: Report): string | undefined { - const participants = report.participantAccountIDs ?? []; +function getGroupChatName(report: OnyxEntry): string | undefined { + const participants = report?.participantAccountIDs ?? []; const isMultipleParticipantReport = participants.length > 1; return participants diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index ff5ad9327191..548751cbd8d1 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -118,7 +118,8 @@ function getForReportAction(reportAction: ReportAction): string { const hasModifiedMerchant = reportActionOriginalMessage && 'oldMerchant' in reportActionOriginalMessage && 'merchant' in reportActionOriginalMessage; if (hasModifiedAmount) { const oldCurrency = reportActionOriginalMessage?.oldCurrency ?? ''; - const oldAmount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage?.oldAmount ?? 0, oldCurrency); + const oldAmountValue = reportActionOriginalMessage?.oldAmount ?? 0; + const oldAmount = oldAmountValue > 0 ? CurrencyUtils.convertToDisplayString(reportActionOriginalMessage?.oldAmount ?? 0, oldCurrency) : ''; const currency = reportActionOriginalMessage?.currency ?? ''; const amount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage?.amount ?? 0, currency); @@ -187,8 +188,8 @@ function getForReportAction(reportAction: ReportAction): string { const hasModifiedTag = reportActionOriginalMessage && 'oldTag' in reportActionOriginalMessage && 'tag' in reportActionOriginalMessage; if (hasModifiedTag) { buildMessageFragmentForValue( - reportActionOriginalMessage?.tag ?? '', - reportActionOriginalMessage?.oldTag ?? '', + PolicyUtils.getCleanedTagName(reportActionOriginalMessage?.tag ?? ''), + PolicyUtils.getCleanedTagName(reportActionOriginalMessage?.oldTag ?? ''), policyTagListName, true, setFragments, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 03a3612d4566..e9fcb57df1da 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -59,6 +59,9 @@ const loadSidebarScreen = () => require('../../../pages/home/sidebar/SidebarScre const loadValidateLoginPage = () => require('../../../pages/ValidateLoginPage').default as React.ComponentType; const loadLogOutPreviousUserPage = () => require('../../../pages/LogOutPreviousUserPage').default as React.ComponentType; const loadConciergePage = () => require('../../../pages/ConciergePage').default as React.ComponentType; +const loadProfileAvatar = () => require('../../../pages/settings/Profile/ProfileAvatar').default as React.ComponentType; +const loadWorkspaceAvatar = () => require('../../../pages/workspace/WorkspaceAvatar').default as React.ComponentType; +const loadReportAvatar = () => require('../../../pages/ReportAvatar').default as React.ComponentType; let timezone: Timezone | null; let currentAccountID = -1; @@ -298,6 +301,33 @@ function AuthScreens({session, lastOpenedPublicRoomID, isUsingMemoryOnlyKeys = f getComponent={loadReportAttachments} listeners={modalScreenListeners} /> + + + ({ [SCREENS.EDIT_REQUEST.ROOT]: () => require('../../../pages/EditRequestPage').default as React.ComponentType, [SCREENS.EDIT_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, + [SCREENS.EDIT_REQUEST.REPORT_FIELD]: () => require('../../../pages/EditReportFieldPage').default as React.ComponentType, }); const PrivateNotesModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx index a3fe1c657f34..5462b6c0ce4e 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx @@ -27,19 +27,21 @@ function Overlay({onPress, isModalOnTheLeft = false}: OverlayProps) { we have 30px draggable ba at the top and the rest of the dimmed area is clickable. On other devices, everything behaves normally like one big pressable */} diff --git a/src/libs/Navigation/linkingConfig.ts b/src/libs/Navigation/linkingConfig.ts index 1a495e92eb80..0951b41d78b5 100644 --- a/src/libs/Navigation/linkingConfig.ts +++ b/src/libs/Navigation/linkingConfig.ts @@ -29,6 +29,9 @@ const linkingConfig: LinkingOptions = { [SCREENS.SAML_SIGN_IN]: ROUTES.SAML_SIGN_IN, [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: ROUTES.DESKTOP_SIGN_IN_REDIRECT, [SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route, + [SCREENS.PROFILE_AVATAR]: ROUTES.PROFILE_AVATAR.route, + [SCREENS.WORKSPACE_AVATAR]: ROUTES.WORKSPACE_AVATAR.route, + [SCREENS.REPORT_AVATAR]: ROUTES.REPORT_AVATAR.route, // Sidebar [SCREENS.HOME]: { @@ -478,6 +481,7 @@ const linkingConfig: LinkingOptions = { screens: { [SCREENS.EDIT_REQUEST.ROOT]: ROUTES.EDIT_REQUEST.route, [SCREENS.EDIT_REQUEST.CURRENCY]: ROUTES.EDIT_CURRENCY_REQUEST.route, + [SCREENS.EDIT_REQUEST.REPORT_FIELD]: ROUTES.EDIT_REPORT_FIELD_REQUEST.route, }, }, [SCREENS.RIGHT_MODAL.SIGN_IN]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index a1cbb562997e..dd5a7720f00d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -323,7 +323,9 @@ type SignInNavigatorParamList = { }; type ReferralDetailsNavigatorParamList = { - [SCREENS.REFERRAL_DETAILS]: undefined; + [SCREENS.REFERRAL_DETAILS]: { + contentType: ValueOf; + }; }; type ProcessMoneyRequestHoldNavigatorParamList = { @@ -416,6 +418,15 @@ type AuthScreensParamList = { reportID: string; source: string; }; + [SCREENS.PROFILE_AVATAR]: { + accountID: string; + }; + [SCREENS.WORKSPACE_AVATAR]: { + policyID: string; + }; + [SCREENS.REPORT_AVATAR]: { + reportID: string; + }; [SCREENS.NOT_FOUND]: undefined; [NAVIGATORS.LEFT_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: NavigatorScreenParams; diff --git a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts index 547ecb1de5b2..9767210b3479 100644 --- a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts +++ b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts @@ -2,6 +2,7 @@ import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import Visibility from '@libs/Visibility'; +import * as Modal from '@userActions/Modal'; import ROUTES from '@src/ROUTES'; import backgroundRefresh from './backgroundRefresh'; import PushNotification from './index'; @@ -26,22 +27,25 @@ export default function subscribeToReportCommentPushNotifications() { Navigation.isNavigationReady() .then(Navigation.waitForProtectedRoutes) .then(() => { - try { - // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back - if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) { - Navigation.goBack(ROUTES.HOME); - } + // The attachment modal remains open when navigating to the report so we need to close it + Modal.close(() => { + try { + // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back + if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) { + Navigation.goBack(ROUTES.HOME); + } - Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID}); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(String(reportID))); - } catch (error) { - let errorMessage = String(error); - if (error instanceof Error) { - errorMessage = error.message; - } + Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID}); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(String(reportID))); + } catch (error) { + let errorMessage = String(error); + if (error instanceof Error) { + errorMessage = error.message; + } - Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: errorMessage}); - } + Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: errorMessage}); + } + }); }); }); } diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index e86c9daacb42..2973228af51f 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -17,6 +17,7 @@ import Navigation from './Navigation/Navigation'; import Permissions from './Permissions'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import * as PhoneNumber from './PhoneNumber'; +import * as PolicyUtils from './PolicyUtils'; import * as ReportActionUtils from './ReportActionsUtils'; import * as ReportUtils from './ReportUtils'; import * as TaskUtils from './TaskUtils'; @@ -945,19 +946,23 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt * @returns {Array} */ function getTagsOptions(tags) { - return _.map(tags, (tag) => ({ - text: tag.name, - keyForList: tag.name, - searchText: tag.name, - tooltipText: tag.name, - isDisabled: !tag.enabled, - })); + return _.map(tags, (tag) => { + // This is to remove unnecessary escaping backslash in tag name sent from backend. + const cleanedName = PolicyUtils.getCleanedTagName(tag.name); + return { + text: cleanedName, + keyForList: tag.name, + searchText: tag.name, + tooltipText: cleanedName, + isDisabled: !tag.enabled, + }; + }); } /** * Build the section list for tags * - * @param {Object[]} rawTags + * @param {Object[]} tags * @param {String} tags[].name * @param {Boolean} tags[].enabled * @param {String[]} recentlyUsedTags @@ -967,14 +972,8 @@ function getTagsOptions(tags) { * @param {Number} maxRecentReportsToShow * @returns {Array} */ -function getTagListSections(rawTags, recentlyUsedTags, selectedOptions, searchInputValue, maxRecentReportsToShow) { +function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInputValue, maxRecentReportsToShow) { const tagSections = []; - const tags = _.map(rawTags, (tag) => { - // This is to remove unnecessary escaping backslash in tag name sent from backend. - const tagName = tag.name && tag.name.replace(/\\{1,2}:/g, ':'); - - return {...tag, name: tagName}; - }); const sortedTags = sortTags(tags); const enabledTags = _.filter(sortedTags, (tag) => tag.enabled); const numberOfTags = _.size(enabledTags); @@ -999,7 +998,7 @@ function getTagListSections(rawTags, recentlyUsedTags, selectedOptions, searchIn } if (!_.isEmpty(searchInputValue)) { - const searchTags = _.filter(enabledTags, (tag) => tag.name.toLowerCase().includes(searchInputValue.toLowerCase())); + const searchTags = _.filter(enabledTags, (tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchInputValue.toLowerCase())); tagSections.push({ // "Search" section diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index ec7346e26f80..b8ed62f93082 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -195,6 +195,13 @@ function getTagList(policyTags: OnyxCollection, tagKey: string) { return policyTags?.[policyTagKey]?.tags ?? {}; } +/** + * Cleans up escaping of colons (used to create multi-level tags, e.g. "Parent: Child") in the tag name we receive from the backend + */ +function getCleanedTagName(tag: string) { + return tag?.replace(/\\{1,2}:/g, ':'); +} + function isPendingDeletePolicy(policy: OnyxEntry): boolean { return policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } @@ -221,6 +228,7 @@ export { getTag, getTagListName, getTagList, + getCleanedTagName, isPendingDeletePolicy, isPolicyMember, isPaidGroupPolicy, diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index bcba68a3a0bd..7fca9f54b744 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -29,27 +29,28 @@ type FileNameAndExtension = { * @param receiptFileName */ function getThumbnailAndImageURIs(transaction: Transaction, receiptPath: string | null = null, receiptFileName: string | null = null): ThumbnailAndImageURI { + if (Object.hasOwn(transaction?.pendingFields ?? {}, 'waypoints')) { + return {thumbnail: null, image: ReceiptGeneric, isLocalFile: true}; + } + // URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg const path = transaction?.receipt?.source ?? receiptPath ?? ''; // filename of uploaded image or last part of remote URI const filename = transaction?.filename ?? receiptFileName ?? ''; const isReceiptImage = Str.isImage(filename); - const hasEReceipt = transaction?.hasEReceipt; - if (!Object.hasOwn(transaction?.pendingFields ?? {}, 'waypoints')) { - if (hasEReceipt) { - return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction}; - } + if (hasEReceipt) { + return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction}; + } - // For local files, we won't have a thumbnail yet - if (isReceiptImage && (path.startsWith('blob:') || path.startsWith('file:'))) { - return {thumbnail: null, image: path, isLocalFile: true}; - } + // For local files, we won't have a thumbnail yet + if (isReceiptImage && (path.startsWith('blob:') || path.startsWith('file:'))) { + return {thumbnail: null, image: path, isLocalFile: true}; + } - if (isReceiptImage) { - return {thumbnail: `${path}.1024.jpg`, image: path}; - } + if (isReceiptImage) { + return {thumbnail: `${path}.1024.jpg`, image: path}; } const {fileExtension} = FileUtils.splitExtensionFromFileName(filename) as FileNameAndExtension; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 262b8bf475af..3154f578c309 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -472,7 +472,7 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = let messageText = message?.text ?? ''; if (messageText) { - messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); + messageText = String(messageText).replace(CONST.REGEX.LINE_BREAK, ' ').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); } return { lastMessageText: messageText, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 78086c354de0..1d888b087e53 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -29,7 +29,7 @@ import type { TransactionViolation, } from '@src/types/onyx'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {IOUMessage, OriginalMessageActionName, OriginalMessageCreated} from '@src/types/onyx/OriginalMessage'; +import type {ChangeLog, IOUMessage, OriginalMessageActionName, OriginalMessageCreated} from '@src/types/onyx/OriginalMessage'; import type {Status} from '@src/types/onyx/PersonalDetails'; import type {NotificationPreference} from '@src/types/onyx/Report'; import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; @@ -368,13 +368,12 @@ type CustomIcon = { type OptionData = { text: string; alternateText?: string | null; - allReportErrors?: Errors | null; + allReportErrors?: Errors; brickRoadIndicator?: typeof CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR | '' | null; tooltipText?: string | null; alternateTextMaxLines?: number; boldStyle?: boolean; customIcon?: CustomIcon; - descriptiveText?: string; subtitle?: string | null; login?: string | null; accountID?: number | null; @@ -395,8 +394,10 @@ type OptionData = { isAllowedToComment?: boolean | null; isThread?: boolean | null; isTaskReport?: boolean | null; - parentReportAction?: ReportAction; + parentReportAction?: OnyxEntry; displayNamesWithTooltips?: DisplayNameWithTooltips | null; + descriptiveText?: string; + notificationPreference?: NotificationPreference | null; isDisabled?: boolean | null; name?: string | null; } & Report; @@ -1459,6 +1460,84 @@ function getWorkspaceIcon(report: OnyxEntry, policy: OnyxEntry = return workspaceIcon; } +/** + * Checks if a report is a group chat. + * + * A report is a group chat if it meets the following conditions: + * - Not a chat thread. + * - Not a task report. + * - Not a money request / IOU report. + * - Not an archived room. + * - Not a public / admin / announce chat room (chat type doesn't match any of the specified types). + * - More than 1 participant (note that participantAccountIDs excludes the current user). + * + */ +function isGroupChat(report: OnyxEntry): boolean { + return Boolean( + report && + !isChatThread(report) && + !isTaskReport(report) && + !isMoneyRequestReport(report) && + !isArchivedRoom(report) && + !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) && + (report.participantAccountIDs?.length ?? 0) > 1, + ); +} + +function getGroupChatParticipantIDs(participants: number[]): number[] { + return [...new Set([...participants, ...(currentUserAccountID ? [currentUserAccountID] : [])])]; +} + +/** + * Returns an array of the participants Ids of a report + * + * @deprecated Use getVisibleMemberIDs instead + */ +function getParticipantsIDs(report: OnyxEntry): number[] { + if (!report) { + return []; + } + + const participants = report.participantAccountIDs ?? []; + + // Build participants list for IOU/expense reports + if (isMoneyRequestReport(report)) { + const onlyTruthyValues = [report.managerID, report.ownerAccountID, ...participants].filter(Boolean) as number[]; + const onlyUnique = [...new Set([...onlyTruthyValues])]; + return onlyUnique; + } + + if (isGroupChat(report)) { + return getGroupChatParticipantIDs(participants); + } + + return participants; +} + +/** + * Returns an array of the visible member accountIDs for a report + */ +function getVisibleMemberIDs(report: OnyxEntry): number[] { + if (!report) { + return []; + } + + const visibleChatMemberAccountIDs = report.visibleChatMemberAccountIDs ?? []; + + // Build participants list for IOU/expense reports + if (isMoneyRequestReport(report)) { + const onlyTruthyValues = [report.managerID, report.ownerAccountID, ...visibleChatMemberAccountIDs].filter(Boolean) as number[]; + const onlyUnique = [...new Set([...onlyTruthyValues])]; + return onlyUnique; + } + + if (isGroupChat(report)) { + return getGroupChatParticipantIDs(visibleChatMemberAccountIDs); + } + + return visibleChatMemberAccountIDs; +} + /** * Returns the appropriate icons for the given chat report using the stored personalDetails. * The Avatar sources can be URLs or Icon components according to the chat type. @@ -1575,6 +1654,10 @@ function getIcons( return isPayer ? [managerIcon, ownerIcon] : [ownerIcon, managerIcon]; } + if (isGroupChat(report)) { + return getIconsForParticipants(getVisibleMemberIDs(report), personalDetails); + } + return getIconsForParticipants(report?.participantAccountIDs ?? [], personalDetails); } @@ -1616,8 +1699,7 @@ function getDisplayNameForParticipant(accountID?: number, shouldUseShortForm = f // This is to check if account is an invite/optimistically created one // and prevent from falling back to 'Hidden', so a correct value is shown // when searching for a new user - if (personalDetails.isOptimisticPersonalDetail === true) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (personalDetails.isOptimisticPersonalDetail === true && formattedLogin) { return formattedLogin; } @@ -2156,7 +2238,7 @@ function getTransactionReportName(reportAction: OnyxEntry): string return Localize.translateLocal(ReportActionsUtils.isSentMoneyReportAction(reportAction) ? 'iou.threadSentMoneyReportName' : 'iou.threadRequestReportName', { formattedAmount: CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency, TransactionUtils.isDistanceRequest(transaction)) ?? '', - comment: transactionDetails?.comment ?? '', + comment: (!TransactionUtils.isMerchantMissing(transaction) ? transactionDetails?.merchant : transactionDetails?.comment) ?? '', }); } @@ -2318,6 +2400,48 @@ function getModifiedExpenseOriginalMessage(oldTransaction: OnyxEntry, parentReportActionMessage: string) { + if (!parentReportAction?.originalMessage) { + return ''; + } + const originalMessage = isChangeLogObject(parentReportAction.originalMessage); + const participantAccountIDs = originalMessage?.targetAccountIDs ?? []; + + const participants = participantAccountIDs.map((id) => getDisplayNameForParticipant(id)); + const users = participants.length > 1 ? participants.join(` ${Localize.translateLocal('common.and')} `) : participants[0]; + if (!users) { + return parentReportActionMessage; + } + const actionType = parentReportAction.actionName; + const isInviteAction = actionType === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || actionType === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM; + + const verbKey = isInviteAction ? 'workspace.invite.invited' : 'workspace.invite.removed'; + const prepositionKey = isInviteAction ? 'workspace.invite.to' : 'workspace.invite.from'; + + const verb = Localize.translateLocal(verbKey); + const preposition = Localize.translateLocal(prepositionKey); + + const roomName = originalMessage?.roomName ?? ''; + + return roomName ? `${verb} ${users} ${preposition} ${roomName}` : `${verb} ${users}`; +} + /** * Get the title for a report. */ @@ -2340,6 +2464,9 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu ) { return Localize.translateLocal('parentReportAction.hiddenMessage'); } + if (isAdminRoom(report) || isUserCreatedPolicyRoom(report)) { + return getAdminRoomInvitedParticipants(parentReportAction, parentReportActionMessage); + } return parentReportActionMessage || Localize.translateLocal('parentReportAction.deletedMessage'); } @@ -2499,12 +2626,13 @@ function getParsedComment(text: string): string { return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : lodashEscape(text); } -function buildOptimisticAddCommentReportAction(text?: string, file?: File): OptimisticReportAction { +function buildOptimisticAddCommentReportAction(text?: string, file?: File, actorAccountID?: number): OptimisticReportAction { const parser = new ExpensiMark(); const commentText = getParsedComment(text ?? ''); const isAttachment = !text && file !== undefined; const attachmentInfo = isAttachment ? file : {}; const htmlForNewComment = isAttachment ? CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML : commentText; + const accountID = actorAccountID ?? currentUserAccountID; // Remove HTML from text when applying optimistic offline comment const textForNewComment = isAttachment ? CONST.ATTACHMENT_MESSAGE_TEXT : parser.htmlToText(htmlForNewComment); @@ -2513,16 +2641,16 @@ function buildOptimisticAddCommentReportAction(text?: string, file?: File): Opti reportAction: { reportActionID: NumberUtils.rand64(), actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, - actorAccountID: currentUserAccountID, + actorAccountID: accountID, person: [ { style: 'strong', - text: allPersonalDetails?.[currentUserAccountID ?? -1]?.displayName ?? currentUserEmail, + text: allPersonalDetails?.[accountID ?? -1]?.displayName ?? currentUserEmail, type: 'TEXT', }, ], automatic: false, - avatar: allPersonalDetails?.[currentUserAccountID ?? -1]?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + avatar: allPersonalDetails?.[accountID ?? -1]?.avatar ?? UserUtils.getDefaultAvatarURL(accountID), created: DateUtils.getDBTimeWithSkew(), message: [ { @@ -3548,8 +3676,8 @@ function shouldHideReport(report: OnyxEntry, currentReportId: string): b /** * Checks to see if a report's parentAction is a money request that contains a violation */ -function doesTransactionThreadHaveViolations(report: Report, transactionViolations: OnyxCollection, parentReportAction: ReportAction): boolean { - if (parentReportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { +function doesTransactionThreadHaveViolations(report: OnyxEntry, transactionViolations: OnyxCollection, parentReportAction: OnyxEntry): boolean { + if (parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { return false; } const {IOUTransactionID, IOUReportID} = parentReportAction.originalMessage ?? {}; @@ -3559,7 +3687,7 @@ function doesTransactionThreadHaveViolations(report: Report, transactionViolatio if (!isCurrentUserSubmitter(IOUReportID)) { return false; } - if (report.stateNum !== CONST.REPORT.STATE_NUM.OPEN && report.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED) { + if (report?.stateNum !== CONST.REPORT.STATE_NUM.OPEN && report?.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED) { return false; } return TransactionUtils.hasViolation(IOUTransactionID, transactionViolations); @@ -3662,7 +3790,7 @@ function shouldReportBeInOptionList({ // All unread chats (even archived ones) in GSD mode will be shown. This is because GSD mode is specifically for focusing the user on the most relevant chats, primarily, the unread ones if (isInGSDMode) { - return isUnread(report); + return isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; } // Archived reports should always be shown when in default (most recent) mode. This is because you should still be able to access and search for the chats to find them. @@ -4321,46 +4449,6 @@ function getTaskAssigneeChatOnyxData( }; } -/** - * Returns an array of the participants Ids of a report - * - * @deprecated Use getVisibleMemberIDs instead - */ -function getParticipantsIDs(report: OnyxEntry): number[] { - if (!report) { - return []; - } - - const participants = report.participantAccountIDs ?? []; - - // Build participants list for IOU/expense reports - if (isMoneyRequestReport(report)) { - const onlyTruthyValues = [report.managerID, report.ownerAccountID, ...participants].filter(Boolean) as number[]; - const onlyUnique = [...new Set([...onlyTruthyValues])]; - return onlyUnique; - } - return participants; -} - -/** - * Returns an array of the visible member accountIDs for a report* - */ -function getVisibleMemberIDs(report: OnyxEntry): number[] { - if (!report) { - return []; - } - - const visibleChatMemberAccountIDs = report.visibleChatMemberAccountIDs ?? []; - - // Build participants list for IOU/expense reports - if (isMoneyRequestReport(report)) { - const onlyTruthyValues = [report.managerID, report.ownerAccountID, ...visibleChatMemberAccountIDs].filter(Boolean) as number[]; - const onlyUnique = [...new Set([...onlyTruthyValues])]; - return onlyUnique; - } - return visibleChatMemberAccountIDs; -} - /** * Return iou report action display message */ @@ -4417,30 +4505,6 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry) }); } -/** - * Checks if a report is a group chat. - * - * A report is a group chat if it meets the following conditions: - * - Not a chat thread. - * - Not a task report. - * - Not a money request / IOU report. - * - Not an archived room. - * - Not a public / admin / announce chat room (chat type doesn't match any of the specified types). - * - More than 2 participants. - * - */ -function isGroupChat(report: OnyxEntry): boolean { - return Boolean( - report && - !isChatThread(report) && - !isTaskReport(report) && - !isMoneyRequestReport(report) && - !isArchivedRoom(report) && - !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) && - (report.participantAccountIDs?.length ?? 0) > 2, - ); -} - function shouldUseFullTitleToDisplay(report: OnyxEntry): boolean { return isMoneyRequestReport(report) || isPolicyExpenseChat(report) || isChatRoom(report) || isChatThread(report) || isTaskReport(report); } @@ -4525,6 +4589,13 @@ function getReportFieldTitle(report: OnyxEntry, reportField: PolicyRepor }); } +/** + * Given a report field, check if the field is for the report title. + */ +function isReportFieldOfTypeTitle(reportField: PolicyReportField): boolean { + return reportField.type === 'formula' && reportField.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; +} + /** * Checks if thread replies should be displayed */ @@ -4542,7 +4613,7 @@ function shouldDisplayThreadReplies(reportAction: OnyxEntry, repor * - The action is a whisper action and it's neither a report preview nor IOU action * - The action is the thread's first chat */ -function shouldDisableThread(reportAction: OnyxEntry, reportID: string) { +function shouldDisableThread(reportAction: OnyxEntry, reportID: string): boolean { const isSplitBillAction = ReportActionsUtils.isSplitBillAction(reportAction); const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); @@ -4558,10 +4629,27 @@ function shouldDisableThread(reportAction: OnyxEntry, reportID: st ); } +function canBeAutoReimbursed(report: OnyxEntry, policy: OnyxEntry = null): boolean { + if (!policy) { + return false; + } + type CurrencyType = (typeof CONST.DIRECT_REIMBURSEMENT_CURRENCIES)[number]; + const reimbursableTotal = getMoneyRequestReimbursableTotal(report); + const autoReimbursementLimit = policy.autoReimbursementLimit ?? 0; + const isAutoReimbursable = + isGroupPolicy(report) && + policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && + autoReimbursementLimit >= reimbursableTotal && + reimbursableTotal > 0 && + CONST.DIRECT_REIMBURSEMENT_CURRENCIES.includes(report?.currency as CurrencyType); + return isAutoReimbursable; +} + export { getReportParticipantsTitle, isReportMessageAttachment, findLastAccessedReport, + canBeAutoReimbursed, canEditReportAction, canFlagReportAction, shouldShowFlagComment, @@ -4740,6 +4828,7 @@ export { shouldDisableThread, doesReportBelongToWorkspace, getChildReportNotificationPreference, + isReportFieldOfTypeTitle, }; export type { diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 445d9dc30dd8..ddd0365e865f 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -1,17 +1,17 @@ /* eslint-disable rulesdir/prefer-underscore-method */ import Str from 'expensify-common/lib/str'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, TransactionViolation} from '@src/types/onyx'; +import type {PersonalDetails, PersonalDetailsList, TransactionViolation} from '@src/types/onyx'; import type Beta from '@src/types/onyx/Beta'; -import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type Policy from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; import type {ReportActions} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; import * as CollectionUtils from './CollectionUtils'; import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; @@ -147,6 +147,7 @@ function getOrderedReportIDs( const isInGSDMode = priorityMode === CONST.PRIORITY_MODE.GSD; const isInDefaultMode = !isInGSDMode; const allReportsDictValues = Object.values(allReports); + // Filter out all the reports that shouldn't be displayed let reportsToDisplay = allReportsDictValues.filter((report) => { const parentReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`; @@ -257,12 +258,12 @@ function getOptionData({ parentReportAction, hasViolations, }: { - report: Report; - reportActions: Record; - personalDetails: Record; - preferredLocale: ValueOf; - policy: Policy; - parentReportAction: ReportAction; + report: OnyxEntry; + reportActions: OnyxEntry; + personalDetails: OnyxEntry; + preferredLocale: DeepValueOf; + policy: OnyxEntry | undefined; + parentReportAction: OnyxEntry | undefined; hasViolations: boolean; }): ReportUtils.OptionData | undefined { // When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for @@ -275,7 +276,7 @@ function getOptionData({ const result: ReportUtils.OptionData = { text: '', alternateText: null, - allReportErrors: null, + allReportErrors: undefined, brickRoadIndicator: null, tooltipText: null, subtitle: null, @@ -317,7 +318,8 @@ function getOptionData({ result.isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report); result.pendingAction = report.pendingFields ? report.pendingFields.addWorkspaceRoom || report.pendingFields.createChat : undefined; - result.allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions) as OnyxCommon.Errors; + // @ts-expect-error TODO: Remove this once OptionsListUtils (https://github.com/Expensify/App/issues/24921) is migrated to TypeScript. + result.allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions); result.brickRoadIndicator = hasErrors || hasViolations ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; result.ownerAccountID = report.ownerAccountID; result.managerID = report.managerID; diff --git a/src/libs/SuggestionUtils.js b/src/libs/SuggestionUtils.js deleted file mode 100644 index 45641ebb5a0f..000000000000 --- a/src/libs/SuggestionUtils.js +++ /dev/null @@ -1,47 +0,0 @@ -import CONST from '@src/CONST'; - -/** - * Return the max available index for arrow manager. - * @param {Number} numRows - * @param {Boolean} isAutoSuggestionPickerLarge - * @returns {Number} - */ -function getMaxArrowIndex(numRows, isAutoSuggestionPickerLarge) { - // rowCount is number of emoji/mention suggestions. For small screen we can fit 3 items - // and for large we show up to 20 items for mentions/emojis - const rowCount = isAutoSuggestionPickerLarge - ? Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) - : Math.min(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_SUGGESTIONS); - - // -1 because we start at 0 - return rowCount - 1; -} - -/** - * Trims first character of the string if it is a space - * @param {String} str - * @returns {String} - */ -function trimLeadingSpace(str) { - return str.slice(0, 1) === ' ' ? str.slice(1) : str; -} - -/** - * Checks if space is available to render large suggestion menu - * @param {Number} listHeight - * @param {Number} composerHeight - * @param {Number} totalSuggestions - * @returns {Boolean} - */ -function hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, totalSuggestions) { - const maxSuggestions = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER; - const chatFooterHeight = CONST.CHAT_FOOTER_SECONDARY_ROW_HEIGHT + 2 * CONST.CHAT_FOOTER_SECONDARY_ROW_PADDING; - const availableHeight = listHeight - composerHeight - chatFooterHeight; - const menuHeight = - (!totalSuggestions || totalSuggestions > maxSuggestions ? maxSuggestions : totalSuggestions) * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT + - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING * 2; - - return availableHeight > menuHeight; -} - -export {getMaxArrowIndex, trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu}; diff --git a/src/libs/SuggestionUtils.ts b/src/libs/SuggestionUtils.ts new file mode 100644 index 000000000000..96379ce49ef3 --- /dev/null +++ b/src/libs/SuggestionUtils.ts @@ -0,0 +1,23 @@ +import CONST from '@src/CONST'; + +/** + * Trims first character of the string if it is a space + */ +function trimLeadingSpace(str: string): string { + return str.startsWith(' ') ? str.slice(1) : str; +} +/** + * Checks if space is available to render large suggestion menu + */ +function hasEnoughSpaceForLargeSuggestionMenu(listHeight: number, composerHeight: number, totalSuggestions: number): boolean { + const maxSuggestions = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER; + const chatFooterHeight = CONST.CHAT_FOOTER_SECONDARY_ROW_HEIGHT + 2 * CONST.CHAT_FOOTER_SECONDARY_ROW_PADDING; + const availableHeight = listHeight - composerHeight - chatFooterHeight; + const menuHeight = + (!totalSuggestions || totalSuggestions > maxSuggestions ? maxSuggestions : totalSuggestions) * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT + + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING * 2; + + return availableHeight > menuHeight; +} + +export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu}; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 1229e700d297..29c692b86709 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -141,11 +141,12 @@ function hasReceipt(transaction: Transaction | undefined | null): boolean { } function isMerchantMissing(transaction: Transaction) { + if (transaction.modifiedMerchant && transaction.modifiedMerchant !== '') { + return transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + } const isMerchantEmpty = transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === ''; - const isModifiedMerchantEmpty = !transaction.modifiedMerchant || transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.modifiedMerchant === ''; - - return isMerchantEmpty && isModifiedMerchantEmpty; + return isMerchantEmpty; } /** diff --git a/src/libs/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index 11472ce3e385..b4f3cd34a8c4 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -2,31 +2,44 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as ReportUtils from '@libs/ReportUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; import updateUnread from './updateUnread'; let allReports: OnyxCollection = {}; +export default function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, currentReportID: string) { + return Object.values(reports ?? {}).filter( + (report) => + ReportUtils.isUnread(report) && + ReportUtils.shouldReportBeInOptionList({ + report, + currentReportId: currentReportID ?? '', + betas: [], + policies: {}, + doesReportHaveViolations: false, + isInGSDMode: false, + excludeEmptyChats: false, + }) && + /** + * Chats with hidden preference remain invisible in the LHN and are not considered "unread." + * They are excluded from the LHN rendering, but not filtered from the "option list." + * This ensures they appear in Search, but not in the LHN or unread count. + * + * Furthermore, muted reports may or may not appear in the LHN depending on priority mode, + * but they should not be considered in the unread indicator count. + */ + report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN && + report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE, + ); +} + const triggerUnreadUpdate = () => { - const currentReportID = navigationRef.isReady() ? Navigation.getTopmostReportId() : ''; + const currentReportID = navigationRef.isReady() ? Navigation.getTopmostReportId() ?? '' : ''; // We want to keep notification count consistent with what can be accessed from the LHN list - const unreadReports = Object.values(allReports ?? {}).filter((report) => { - if (!ReportUtils.isUnread(report)) { - return false; - } - - return ReportUtils.shouldReportBeInOptionList({ - report, - currentReportId: currentReportID ?? '', - betas: [], - policies: {}, - doesReportHaveViolations: false, - isInGSDMode: false, - excludeEmptyChats: false, - }); - }); + const unreadReports = getUnreadReportsForUnreadIndicator(allReports, currentReportID); updateUnread(unreadReports.length); }; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 7ee752a1f0ef..d258b5419103 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1183,6 +1183,21 @@ function updateMoneyRequestTag(transactionID, transactionThreadReportID, tag) { API.write('UpdateMoneyRequestTag', params, onyxData); } +/** + * Updates the category of a money request + * + * @param {String} transactionID + * @param {Number} transactionThreadReportID + * @param {String} category + */ +function updateMoneyRequestCategory(transactionID, transactionThreadReportID, category) { + const transactionChanges = { + category, + }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, true); + API.write('UpdateMoneyRequestCategory', params, onyxData); +} + /** * Updates the description of a money request * @@ -1957,6 +1972,32 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co }); }); + _.each(participants, (participant) => { + const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(participant); + if (!isPolicyExpenseChat) { + return; + } + + const optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(participant.policyID, category); + const optimisticPolicyRecentlyUsedTags = Policy.buildOptimisticPolicyRecentlyUsedTags(participant.policyID, tag); + + if (!_.isEmpty(optimisticPolicyRecentlyUsedCategories)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${participant.policyID}`, + value: optimisticPolicyRecentlyUsedCategories, + }); + } + + if (!_.isEmpty(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${participant.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + }); + // Save the new splits array into the transaction's comment in case the user calls CompleteSplitBill while offline optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -3259,6 +3300,7 @@ function submitReport(expenseReport) { const optimisticSubmittedReportAction = ReportUtils.buildOptimisticSubmittedReportAction(expenseReport.total, expenseReport.currency, expenseReport.reportID); const parentReport = ReportUtils.getReport(expenseReport.parentReportID); + const policy = ReportUtils.getPolicy(expenseReport.policyID); const isCurrentUserManager = currentUserPersonalDetails.accountID === expenseReport.managerID; const optimisticData = [ @@ -3361,7 +3403,7 @@ function submitReport(expenseReport) { 'SubmitReport', { reportID: expenseReport.reportID, - managerAccountID: expenseReport.managerID, + managerAccountID: policy.submitsTo || expenseReport.managerID, reportActionID: optimisticSubmittedReportAction.reportActionID, }, {optimisticData, successData, failureData}, @@ -3692,6 +3734,7 @@ export { updateMoneyRequestBillable, updateMoneyRequestMerchant, updateMoneyRequestTag, + updateMoneyRequestCategory, updateMoneyRequestAmountAndCurrency, updateMoneyRequestDescription, replaceReceipt, diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index 7e91c3531b3a..b9632d05d581 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -1,6 +1,6 @@ import {createRef} from 'react'; -import type {MutableRefObject, SyntheticEvent} from 'react'; -import type {NativeTouchEvent} from 'react-native'; +import type {MutableRefObject} from 'react'; +import type {GestureResponderEvent} from 'react-native'; import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx/lib/types'; @@ -17,7 +17,7 @@ import type PaymentMethod from '@src/types/onyx/PaymentMethod'; import type {FilterMethodPaymentType} from '@src/types/onyx/WalletTransfer'; type KYCWallRef = { - continueAction?: (event?: SyntheticEvent, iouPaymentType?: TransferMethod) => void; + continueAction?: (event?: GestureResponderEvent | KeyboardEvent, iouPaymentType?: TransferMethod) => void; }; /** diff --git a/src/libs/actions/ReimbursementAccount/index.js b/src/libs/actions/ReimbursementAccount/index.js index 0404115f086b..f963640bc74e 100644 --- a/src/libs/actions/ReimbursementAccount/index.js +++ b/src/libs/actions/ReimbursementAccount/index.js @@ -13,9 +13,10 @@ export {setBankAccountFormValidationErrors, setPersonalBankAccountFormValidation * - CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID to ask them to login to their bank via Plaid * * @param {String} subStep + * @returns {Promise} */ function setBankAccountSubStep(subStep) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {subStep}}); + return Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {subStep}}); } function hideBankAccountErrors() { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 2ac85dfafa27..228b88d194ba 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -40,6 +40,7 @@ import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/Rep import type ReportAction from '@src/types/onyx/ReportAction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import * as Modal from './Modal'; import * as Session from './Session'; import * as Welcome from './Welcome'; @@ -975,7 +976,7 @@ function markCommentAsUnread(reportID: string, reportActionCreated: string) { }, null); // If no action created date is provided, use the last action's from other user - const actionCreationTime = reportActionCreated || (latestReportActionFromOtherUsers?.created ?? DateUtils.getDBTime(0)); + const actionCreationTime = reportActionCreated || (latestReportActionFromOtherUsers?.created ?? allReports?.[reportID]?.lastVisibleActionCreated ?? DateUtils.getDBTime(0)); // We subtract 1 millisecond so that the lastReadTime is updated to just before a given reportAction's created date // For example, if we want to mark a report action with ID 100 and created date '2014-04-01 16:07:02.999' unread, we set the lastReadTime to '2014-04-01 16:07:02.998' @@ -1685,7 +1686,7 @@ function addPolicyReport(policyReport: ReportUtils.OptimisticChatReport) { /** Deletes a report, along with its reportActions, any linked reports, and any linked IOU report. */ function deleteReport(reportID: string) { - const report = allReports?.[reportID]; + const report = currentReportData?.[reportID]; const onyxData: Record = { [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: null, @@ -1836,7 +1837,7 @@ function shouldShowReportActionNotification(reportID: string, action: ReportActi return false; } - const report = allReports?.[reportID]; + const report = currentReportData?.[reportID]; if (!report || (report && report.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { Log.info(`${tag} No notification because the report does not exist or is pending deleted`, false); return false; @@ -1870,7 +1871,7 @@ function showReportActionNotification(reportID: string, reportAction: ReportActi return; } - const onClick = () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + const onClick = () => Modal.close(() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID))); if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) { LocalNotification.showModifiedExpenseNotification(report, reportAction, onClick); @@ -2087,7 +2088,7 @@ function getCurrentUserAccountID(): number { /** Leave a report by setting the state to submitted and closed */ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = false) { - const report = allReports?.[reportID]; + const report = currentReportData?.[reportID]; if (!report) { return; @@ -2179,7 +2180,7 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal /** Invites people to a room */ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record) { - const report = allReports?.[reportID]; + const report = currentReportData?.[reportID]; if (!report) { return; @@ -2238,7 +2239,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record !targetAccountIDs.includes(id)); const visibleChatMemberAccountIDsAfterRemoval = report?.visibleChatMemberAccountIDs?.filter((id: number) => !targetAccountIDs.includes(id)); @@ -2499,6 +2500,115 @@ function getReportPrivateNote(reportID: string) { API.read('GetReportPrivateNote', parameters, {optimisticData, successData, failureData}); } +/** + * Completes the engagement modal that new NewDot users see when they first sign up/log in by doing the following: + * + * - Sets the introSelected NVP to the choice the user made + * - Creates an optimistic report comment from concierge + */ +function completeEngagementModal(text: string, choice: ValueOf) { + const commandName = 'CompleteEngagementModal'; + const conciergeAccountID = PersonalDetailsUtils.getAccountIDsByLogins([CONST.EMAIL.CONCIERGE])[0]; + const reportComment = ReportUtils.buildOptimisticAddCommentReportAction(text, undefined, conciergeAccountID); + const reportCommentAction: OptimisticAddCommentReportAction = reportComment.reportAction; + const lastComment = reportCommentAction?.message?.[0]; + const lastCommentText = ReportUtils.formatReportLastMessageText(lastComment?.text ?? ''); + const reportCommentText = reportComment.commentText; + const currentTime = DateUtils.getDBTime(); + + const optimisticReport: Partial = { + lastVisibleActionCreated: currentTime, + lastMessageTranslationKey: lastComment?.translationKey ?? '', + lastMessageText: lastCommentText, + lastMessageHtml: lastCommentText, + lastActorAccountID: currentUserAccountID, + lastReadTime: currentTime, + }; + + const conciergeChatReport = ReportUtils.getChatByParticipants([conciergeAccountID]); + conciergeChatReportID = conciergeChatReport?.reportID; + + const report = ReportUtils.getReport(conciergeChatReportID); + + if (!isEmptyObject(report) && ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + optimisticReport.notificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS; + } + + // Optimistically add the new actions to the store before waiting to save them to the server + const optimisticReportActions: OnyxCollection = {}; + if (reportCommentAction?.reportActionID) { + optimisticReportActions[reportCommentAction.reportActionID] = reportCommentAction; + } + + type CompleteEngagementParameters = { + reportID: string; + reportActionID?: string; + commentReportActionID?: string | null; + reportComment?: string; + engagementChoice: string; + timezone?: string; + }; + + const parameters: CompleteEngagementParameters = { + reportID: conciergeChatReportID ?? '', + reportActionID: reportCommentAction.reportActionID, + reportComment: reportCommentText, + engagementChoice: choice, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${conciergeChatReportID}`, + value: optimisticReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${conciergeChatReportID}`, + value: optimisticReportActions as ReportActions, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_INTRO_SELECTED, + value: {choice}, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${conciergeChatReportID}`, + value: {[reportCommentAction.reportActionID ?? '']: {pendingAction: null}}, + }, + ]; + + API.write(commandName, parameters, { + optimisticData, + successData, + }); + notifyNewAction(conciergeChatReportID ?? '', reportCommentAction.actorAccountID, reportCommentAction.reportActionID); +} + +function dismissEngagementModal() { + const commandName = 'SetNameValuePair'; + const parameters = { + name: ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL, + value: true, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL, + value: true, + }, + ]; + + API.write(commandName, parameters, { + optimisticData, + }); +} + /** Loads necessary data for rendering the RoomMembersPage */ function openRoomMembersPage(reportID: string) { type OpenRoomMembersPageParameters = { @@ -2709,6 +2819,8 @@ export { hasErrorInPrivateNotes, getOlderActions, getNewerActions, + completeEngagementModal, + dismissEngagementModal, openRoomMembersPage, savePrivateNotesDraft, getDraftPrivateNote, diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts index 3e3cba49480d..3f6b2dc99a8f 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome.ts @@ -1,13 +1,16 @@ +import type {NavigationState} from '@react-navigation/native'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; +import type {RootStackParamList} from '@navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type OnyxPolicy from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; +import type {EmptyObject} from '@src/types/utils/EmptyObject'; import * as Policy from './Policy'; let resolveIsReadyPromise: (value?: Promise) => void | undefined; @@ -16,20 +19,11 @@ let isReadyPromise = new Promise((resolve) => { }); let isFirstTimeNewExpensifyUser: boolean | undefined; +let hasDismissedModal: boolean | undefined; +let hasSelectedChoice: boolean | undefined; let isLoadingReportData = true; let currentUserAccountID: number | undefined; -type Route = { - name: string; - params?: {path: string; exitTo?: string; openOnAdminRoom?: boolean}; -}; - -type ShowParams = { - routes: Route[]; - showCreateMenu?: () => void; - showPopoverMenu?: () => boolean; -}; - /** * Check that a few requests have completed so that the welcome action can proceed: * @@ -38,7 +32,7 @@ type ShowParams = { * - Whether we have loaded all reports the server knows about */ function checkOnReady() { - if (isFirstTimeNewExpensifyUser === undefined || isLoadingReportData) { + if (isFirstTimeNewExpensifyUser === undefined || isLoadingReportData || hasSelectedChoice === undefined || hasDismissedModal === undefined) { return; } @@ -58,6 +52,26 @@ Onyx.connect({ }, }); +Onyx.connect({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + initWithStoredValues: true, + callback: (value) => { + hasSelectedChoice = !!value; + + checkOnReady(); + }, +}); + +Onyx.connect({ + key: ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL, + initWithStoredValues: true, + callback: (value) => { + hasDismissedModal = value ?? false; + + checkOnReady(); + }, +}); + Onyx.connect({ key: ONYXKEYS.IS_LOADING_REPORT_DATA, initWithStoredValues: false, @@ -80,7 +94,7 @@ Onyx.connect({ }, }); -const allPolicies: OnyxCollection = {}; +const allPolicies: OnyxCollection | EmptyObject = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, callback: (val, key) => { @@ -111,7 +125,7 @@ Onyx.connect({ /** * Shows a welcome action on first login */ -function show({routes, showCreateMenu = () => {}, showPopoverMenu = () => false}: ShowParams) { +function show(routes: NavigationState['routes'], showEngagementModal = () => {}) { isReadyPromise.then(() => { if (!isFirstTimeNewExpensifyUser) { return; @@ -119,16 +133,14 @@ function show({routes, showCreateMenu = () => {}, showPopoverMenu = () => false} // If we are rendering the SidebarScreen at the same time as a workspace route that means we've already created a workspace via workspace/new and should not open the global // create menu right now. We should also stay on the workspace page if that is our destination. - const topRoute = routes.length > 0 ? routes[routes.length - 1] : undefined; - const isWorkspaceRoute = topRoute !== undefined && topRoute.name === SCREENS.RIGHT_MODAL.SETTINGS && topRoute.params?.path.includes('workspace'); - const transitionRoute = routes.find((route) => route.name === SCREENS.TRANSITION_BETWEEN_APPS); - const exitingToWorkspaceRoute = transitionRoute?.params?.exitTo === 'workspace/new'; - const openOnAdminRoom = topRoute?.params?.openOnAdminRoom ?? false; - const isDisplayingWorkspaceRoute = isWorkspaceRoute ?? exitingToWorkspaceRoute; + const transitionRoute = routes.find( + (route): route is NavigationState>['routes'][number] => route.name === SCREENS.TRANSITION_BETWEEN_APPS, + ); + const isExitingToWorkspaceRoute = transitionRoute?.params?.exitTo === 'workspace/new'; // If we already opened the workspace settings or want the admin room to stay open, do not // navigate away to the workspace chat report - const shouldNavigateToWorkspaceChat = !isDisplayingWorkspaceRoute && !openOnAdminRoom; + const shouldNavigateToWorkspaceChat = !isExitingToWorkspaceRoute; const workspaceChatReport = Object.values(allReports ?? {}).find((report) => { if (report) { @@ -137,7 +149,7 @@ function show({routes, showCreateMenu = () => {}, showPopoverMenu = () => false} return false; }); - if (workspaceChatReport ?? openOnAdminRoom) { + if (workspaceChatReport) { // This key is only updated when we call ReconnectApp, setting it to false now allows the user to navigate normally instead of always redirecting to the workspace chat Onyx.set(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, false); } @@ -147,19 +159,17 @@ function show({routes, showCreateMenu = () => {}, showPopoverMenu = () => false} Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(workspaceChatReport.reportID)); } - // If showPopoverMenu exists and returns true then it opened the Popover Menu successfully, and we can update isFirstTimeNewExpensifyUser - // so the Welcome logic doesn't run again - if (showPopoverMenu?.()) { - isFirstTimeNewExpensifyUser = false; - } + // New user has been redirected to their workspace chat, and we won't show them the engagement modal. + // So we update isFirstTimeNewExpensifyUser to prevent the Welcome logic from running again + isFirstTimeNewExpensifyUser = false; return; } // If user is not already an admin of a free policy and we are not navigating them to their workspace or creating a new workspace via workspace/new then - // we will show the create menu. - if (!Policy.isAdminOfFreePolicy(allPolicies ?? undefined) && !isDisplayingWorkspaceRoute) { - showCreateMenu(); + // we will show the engagement modal. + if (!Policy.isAdminOfFreePolicy(allPolicies ?? undefined) && !isExitingToWorkspaceRoute && !hasSelectedChoice && !hasDismissedModal && Object.keys(allPolicies ?? {}).length === 1) { + showEngagementModal(); } // Update isFirstTimeNewExpensifyUser so the Welcome logic doesn't run again diff --git a/src/libs/calculateAnchorPosition.ts b/src/libs/calculateAnchorPosition.ts index 66966b7b504c..3b6617aa3ed0 100644 --- a/src/libs/calculateAnchorPosition.ts +++ b/src/libs/calculateAnchorPosition.ts @@ -22,7 +22,7 @@ export default function calculateAnchorPosition(anchorComponent: View, anchorOri if (anchorOrigin?.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP && anchorOrigin?.horizontal === CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT) { return resolve({horizontal: x, vertical: y + height + (anchorOrigin?.shiftVertical ?? 0)}); } - return resolve({horizontal: x + width, vertical: y}); + return resolve({horizontal: x + width, vertical: y + (anchorOrigin?.shiftVertical ?? 0)}); }); }); } diff --git a/src/pages/EditReportFieldDatePage.tsx b/src/pages/EditReportFieldDatePage.tsx new file mode 100644 index 000000000000..5ee86b2bf8e6 --- /dev/null +++ b/src/pages/EditReportFieldDatePage.tsx @@ -0,0 +1,82 @@ +import React, {useCallback, useRef} from 'react'; +import {View} from 'react-native'; +import DatePicker from '@components/DatePicker'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type EditReportFieldDatePageProps = { + /** Value of the policy report field */ + fieldValue: string; + + /** Name of the policy report field */ + fieldName: string; + + /** ID of the policy report field */ + fieldID: string; + + /** Callback to fire when the Save button is pressed */ + onSubmit: () => void; +}; + +function EditReportFieldDatePage({fieldName, onSubmit, fieldValue, fieldID}: EditReportFieldDatePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const inputRef = useRef(null); + + const validate = useCallback( + (value: Record) => { + const errors: Record = {}; + if (value[fieldID].trim() === '') { + errors[fieldID] = 'common.error.fieldRequired'; + } + return errors; + }, + [fieldID], + ); + + return ( + inputRef.current?.focus()} + testID={EditReportFieldDatePage.displayName} + > + + {/* @ts-expect-error TODO: TS migration */} + + + + + + + ); +} + +EditReportFieldDatePage.displayName = 'EditReportFieldDatePage'; + +export default EditReportFieldDatePage; diff --git a/src/pages/EditReportFieldDropdownPage.tsx b/src/pages/EditReportFieldDropdownPage.tsx new file mode 100644 index 000000000000..a1d76e13f261 --- /dev/null +++ b/src/pages/EditReportFieldDropdownPage.tsx @@ -0,0 +1,82 @@ +import React, {useMemo, useState} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import OptionsSelector from '@components/OptionsSelector'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type EditReportFieldDropdownPageProps = { + /** Value of the policy report field */ + fieldValue: string; + + /** Name of the policy report field */ + fieldName: string; + + /** Options of the policy report field */ + fieldOptions: string[]; + + /** Callback to fire when the Save button is pressed */ + onSubmit: () => void; +}; + +function EditReportFieldDropdownPage({fieldName, onSubmit, fieldValue, fieldOptions}: EditReportFieldDropdownPageProps) { + const [searchValue, setSearchValue] = useState(''); + const styles = useThemeStyles(); + const {getSafeAreaMargins} = useStyleUtils(); + const {translate} = useLocalize(); + + const sections = useMemo(() => { + const filteredOptions = fieldOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase())); + return [ + { + title: translate('common.recents'), + shouldShow: true, + data: [], + }, + { + title: translate('common.all'), + shouldShow: true, + data: filteredOptions.map((option) => ({ + text: option, + keyForList: option, + searchText: option, + tooltipText: option, + })), + }, + ]; + }, [fieldOptions, searchValue, translate]); + + return ( + + {({insets}) => ( + <> + + + + )} + + ); +} + +EditReportFieldDropdownPage.displayName = 'EditReportFieldDropdownPage'; + +export default EditReportFieldDropdownPage; diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx new file mode 100644 index 000000000000..d74582708995 --- /dev/null +++ b/src/pages/EditReportFieldPage.tsx @@ -0,0 +1,103 @@ +import React, {useEffect} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyReportFields, Report} from '@src/types/onyx'; +import EditReportFieldDatePage from './EditReportFieldDatePage'; +import EditReportFieldDropdownPage from './EditReportFieldDropdownPage'; +import EditReportFieldTextPage from './EditReportFieldTextPage'; + +type EditReportFieldPageOnyxProps = { + /** The report object for the expense report */ + report: OnyxEntry; + + /** Policy report fields */ + policyReportFields: OnyxEntry; +}; + +type EditReportFieldPageProps = EditReportFieldPageOnyxProps & { + /** Route from navigation */ + route: { + /** Params from the route */ + params: { + /** Which field we are editing */ + fieldID: string; + + /** reportID for the expense report */ + reportID: string; + + /** policyID for the expense report */ + policyID: string; + }; + }; +}; + +function EditReportFieldPage({route, report, policyReportFields}: EditReportFieldPageProps) { + const policyReportField = policyReportFields?.[route.params.fieldID]; + const reportFieldValue = report?.reportFields?.[policyReportField?.fieldID ?? '']; + + // Decides whether to allow or disallow editing a money request + useEffect(() => {}, []); + + if (policyReportField) { + if (policyReportField.type === 'text' || policyReportField.type === 'formula') { + return ( + {}} + /> + ); + } + + if (policyReportField.type === 'date') { + return ( + {}} + /> + ); + } + + if (policyReportField.type === 'dropdown') { + return ( + {}} + /> + ); + } + } + + return ( + + {}} + onLinkPress={() => {}} + /> + + ); +} + +EditReportFieldPage.displayName = 'EditReportFieldPage'; + +export default withOnyx({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, + }, + policyReportFields: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${route.params.policyID}`, + }, +})(EditReportFieldPage); diff --git a/src/pages/EditReportFieldTextPage.tsx b/src/pages/EditReportFieldTextPage.tsx new file mode 100644 index 000000000000..b468861e9a27 --- /dev/null +++ b/src/pages/EditReportFieldTextPage.tsx @@ -0,0 +1,80 @@ +import React, {useCallback, useRef} from 'react'; +import {View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type EditReportFieldTextPageProps = { + /** Value of the policy report field */ + fieldValue: string; + + /** Name of the policy report field */ + fieldName: string; + + /** ID of the policy report field */ + fieldID: string; + + /** Callback to fire when the Save button is pressed */ + onSubmit: () => void; +}; + +function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, fieldID}: EditReportFieldTextPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const inputRef = useRef(null); + + const validate = useCallback( + (value: Record) => { + const errors: Record = {}; + if (value[fieldID].trim() === '') { + errors[fieldID] = 'common.error.fieldRequired'; + } + return errors; + }, + [fieldID], + ); + + return ( + inputRef.current?.focus()} + testID={EditReportFieldTextPage.displayName} + > + + {/* @ts-expect-error TODO: TS migration */} + + + + + + + ); +} + +EditReportFieldTextPage.displayName = 'EditReportFieldTextPage'; + +export default EditReportFieldTextPage; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 606d3da1ddb9..3eb9d88f1120 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -110,12 +110,6 @@ function EditRequestPage({report, route, policyCategories, policyTags, parentRep }); }, [parentReportAction, fieldToEdit]); - // Update the transaction object and close the modal - function editMoneyRequest(transactionChanges) { - IOU.editMoneyRequest(transaction, report.reportID, transactionChanges); - Navigation.dismissModal(report.reportID); - } - const saveAmountAndCurrency = useCallback( ({amount, currency: newCurrency}) => { const newAmount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(amount)); @@ -176,6 +170,16 @@ function EditRequestPage({report, route, policyCategories, policyTags, parentRep [transactionTag, transaction.transactionID, report.reportID], ); + const saveCategory = useCallback( + ({category: newCategory}) => { + // In case the same category has been selected, reset the category. + const updatedCategory = newCategory === transactionCategory ? '' : newCategory; + IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory); + Navigation.dismissModal(); + }, + [transactionCategory, transaction.transactionID, report.reportID], + ); + const saveComment = useCallback( ({comment: newComment}) => { // Only update comment if it has changed @@ -235,14 +239,7 @@ function EditRequestPage({report, route, policyCategories, policyTags, parentRep { - let updatedCategory = transactionChanges.category; - // In case the same category has been selected, do reset of the category. - if (transactionCategory === updatedCategory) { - updatedCategory = ''; - } - editMoneyRequest({category: updatedCategory}); - }} + onSubmit={saveCategory} /> ); } diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index c0c782f176ca..f054eaf4ad07 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -5,7 +5,6 @@ import React, {useEffect} from 'react'; import {ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import AttachmentModal from '@components/AttachmentModal'; import AutoUpdateTime from '@components/AutoUpdateTime'; import Avatar from '@components/Avatar'; import BlockingView from '@components/BlockingViews/BlockingView'; @@ -103,7 +102,6 @@ function ProfilePage(props) { const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(details); const avatar = lodashGet(details, 'avatar', UserUtils.getDefaultAvatar()); const fallbackIcon = lodashGet(details, 'fallbackIcon', ''); - const originalFileName = lodashGet(details, 'originalFileName', ''); const login = lodashGet(details, 'login', ''); const timezone = lodashGet(details, 'timezone', {}); @@ -154,32 +152,22 @@ function ProfilePage(props) { {hasMinimumDetails && ( - Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(String(accountID)))} + accessibilityLabel={props.translate('common.profile')} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} > - {({show}) => ( - - - - - - )} - + + + + {Boolean(displayName) && ( ; }; -const defaultProps = { - account: null, -}; +type ReferralDetailsPageProps = ReferralDetailsPageOnyxProps & StackScreenProps; -function ReferralDetailsPage({route, account}) { +function ReferralDetailsPage({route, account}: ReferralDetailsPageProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -48,7 +37,7 @@ function ReferralDetailsPage({route, account}) { const {isExecuting, singleExecution} = useSingleExecution(); let {contentType} = route.params; - if (!_.includes(_.values(CONST.REFERRAL_PROGRAM.CONTENT_TYPES), contentType)) { + if (!Object.values(CONST.REFERRAL_PROGRAM.CONTENT_TYPES).includes(contentType)) { contentType = CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND; } @@ -56,7 +45,7 @@ function ReferralDetailsPage({route, account}) { const contentBody = translate(`referralProgram.${contentType}.body`); const isShareCode = contentType === CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE; const shouldShowClipboard = contentType === CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND || isShareCode; - const referralLink = `${CONST.REFERRAL_PROGRAM.LINK}/?thanks=${encodeURIComponent(account.primaryLogin)}`; + const referralLink = `${CONST.REFERRAL_PROGRAM.LINK}${account?.primaryLogin ? `/?thanks=${account.primaryLogin}` : ''}`; return ( } headerContainerStyles={[styles.staticHeaderImage, styles.justifyContentEnd]} - backgroundColor={theme.PAGE_THEMES[SCREENS.RIGHT_MODAL.REFERRAL].backgroundColor} + backgroundColor={theme.PAGE_THEMES[SCREENS.REFERRAL_DETAILS].backgroundColor} > {contentHeader} {contentBody} @@ -102,9 +91,7 @@ function ReferralDetailsPage({route, account}) { } ReferralDetailsPage.displayName = 'ReferralDetailsPage'; -ReferralDetailsPage.propTypes = propTypes; -ReferralDetailsPage.defaultProps = defaultProps; -export default withOnyx({ +export default withOnyx({ account: {key: ONYXKEYS.ACCOUNT}, })(ReferralDetailsPage); diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js index 01ab3b849a0b..2452b7e46007 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js @@ -335,7 +335,9 @@ function ReimbursementAccountPage({reimbursementAccount, route, onfidoToken, pol ); const continueFunction = () => { - setShouldShowContinueSetupButton(false); + BankAccounts.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL).then(() => { + setShouldShowContinueSetupButton(false); + }); fetchData(true); }; diff --git a/src/pages/ReportAvatar.tsx b/src/pages/ReportAvatar.tsx new file mode 100644 index 000000000000..2a8114da744d --- /dev/null +++ b/src/pages/ReportAvatar.tsx @@ -0,0 +1,58 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import AttachmentModal from '@components/AttachmentModal'; +import Navigation from '@libs/Navigation/Navigation'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as UserUtils from '@libs/UserUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Policy, Report} from '@src/types/onyx'; + +type ReportAvatarOnyxProps = { + report: OnyxEntry; + isLoadingApp: OnyxEntry; + policies: OnyxCollection; +}; + +type ReportAvatarProps = ReportAvatarOnyxProps & StackScreenProps; + +function ReportAvatar({report = {} as Report, policies, isLoadingApp = true}: ReportAvatarProps) { + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '0'}`]; + const isArchivedRoom = ReportUtils.isArchivedRoom(report); + const policyName = isArchivedRoom ? report?.oldPolicyName : policy?.name; + const avatarURL = policy?.avatar ?? '' ? policy?.avatar ?? '' : ReportUtils.getDefaultWorkspaceAvatar(policyName); + + return ( + { + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID ?? '')); + }} + isWorkspaceAvatar + originalFileName={policy?.originalFileName ?? policyName} + shouldShowNotFoundPage={!report?.reportID && !isLoadingApp} + isLoading={(!report?.reportID || !policy?.id) && isLoadingApp} + /> + ); +} + +ReportAvatar.displayName = 'ReportAvatar'; + +export default withOnyx({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID ?? ''}`, + }, + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, +})(ReportAvatar); diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index ff9ed62c6a65..3e682d592370 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -197,7 +197,10 @@ function ReportDetailsPage(props) { size={CONST.AVATAR_SIZE.LARGE} /> ) : ( - + )} diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index 7dbc1c7036c4..65238fd5ea8c 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -100,7 +100,8 @@ function ReportParticipantsPage(props) { { - const sections = []; + useEffect(() => { + const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => { + setDidScreenTransitionEnd(true); + }); + + return () => { + unsubscribeTransitionEnd(); + }; + // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const sections = useMemo(() => { + const sectionsArr = []; let indexOffset = 0; + if (!didScreenTransitionEnd) { + return []; + } + // Filter all options that is a part of the search term or in the personal details let filterSelectedOptions = selectedOptions; if (searchTerm !== '') { @@ -112,7 +131,7 @@ function RoomInvitePage(props) { }); } - sections.push({ + sectionsArr.push({ title: undefined, data: filterSelectedOptions, shouldShow: true, @@ -126,7 +145,7 @@ function RoomInvitePage(props) { const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, false)); const hasUnselectedUserToInvite = userToInvite && !_.contains(selectedLogins, userToInvite.login); - sections.push({ + sectionsArr.push({ title: translate('common.contacts'), data: personalDetailsFormatted, shouldShow: !_.isEmpty(personalDetailsFormatted), @@ -135,7 +154,7 @@ function RoomInvitePage(props) { indexOffset += personalDetailsFormatted.length; if (hasUnselectedUserToInvite) { - sections.push({ + sectionsArr.push({ title: undefined, data: [OptionsListUtils.formatMemberForList(userToInvite, false)], shouldShow: true, @@ -143,8 +162,8 @@ function RoomInvitePage(props) { }); } - return sections; - }; + return sectionsArr; + }, [personalDetails, searchTerm, selectedOptions, translate, userToInvite, didScreenTransitionEnd]); const toggleOption = useCallback( (option) => { @@ -198,7 +217,12 @@ function RoomInvitePage(props) { if (!userToInvite && CONST.EXPENSIFY_EMAILS.includes(searchValue)) { return translate('messages.errorMessageInvalidEmail'); } - if (!userToInvite && excludedUsers.includes(searchValue)) { + if ( + !userToInvite && + excludedUsers.includes( + parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible ? OptionsListUtils.addSMSDomainIfPhoneNumber(LoginUtils.appendCountryCode(searchValue)) : searchValue, + ) + ) { return translate('messages.userIsAlreadyMember', {login: searchValue, name: reportName}); } return OptionsListUtils.getHeaderMessage(personalDetails.length !== 0, Boolean(userToInvite), searchValue); @@ -208,49 +232,43 @@ function RoomInvitePage(props) { shouldEnableMaxHeight testID={RoomInvitePage.displayName} > - {({didScreenTransitionEnd}) => { - const sections = didScreenTransitionEnd ? getSections() : []; - - return ( - Navigation.goBack(backRoute)} - > - { - Navigation.goBack(backRoute); - }} - /> - - - - - - ); - }} + Navigation.goBack(backRoute)} + > + { + Navigation.goBack(backRoute); + }} + /> + + + + + ); } diff --git a/src/pages/TeachersUnite/ImTeacherPage.js b/src/pages/TeachersUnite/ImTeacherPage.tsx similarity index 56% rename from src/pages/TeachersUnite/ImTeacherPage.js rename to src/pages/TeachersUnite/ImTeacherPage.tsx index 62cd4529611b..aa26d82c1227 100644 --- a/src/pages/TeachersUnite/ImTeacherPage.js +++ b/src/pages/TeachersUnite/ImTeacherPage.tsx @@ -1,35 +1,26 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import * as LoginUtils from '@libs/LoginUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Session} from '@src/types/onyx'; import ImTeacherUpdateEmailPage from './ImTeacherUpdateEmailPage'; import IntroSchoolPrincipalPage from './IntroSchoolPrincipalPage'; -const propTypes = { - /** Current user session */ - session: PropTypes.shape({ - /** Current user primary login */ - email: PropTypes.string.isRequired, - }), +type ImTeacherPageOnyxProps = { + session: OnyxEntry; }; -const defaultProps = { - session: { - email: null, - }, -}; +type ImTeacherPageProps = ImTeacherPageOnyxProps; -function ImTeacherPage(props) { - const isLoggedInEmailPublicDomain = LoginUtils.isEmailPublicDomain(props.session.email); +function ImTeacherPage(props: ImTeacherPageProps) { + const isLoggedInEmailPublicDomain = LoginUtils.isEmailPublicDomain(props.session?.email ?? ''); return isLoggedInEmailPublicDomain ? : ; } -ImTeacherPage.propTypes = propTypes; -ImTeacherPage.defaultProps = defaultProps; ImTeacherPage.displayName = 'ImTeacherPage'; -export default withOnyx({ +export default withOnyx({ session: { key: ONYXKEYS.SESSION, }, diff --git a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx similarity index 93% rename from src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js rename to src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx index 994350629da6..9433aba2f299 100644 --- a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js +++ b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.tsx @@ -11,10 +11,6 @@ import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import ROUTES from '@src/ROUTES'; -const propTypes = {}; - -const defaultProps = {}; - function ImTeacherUpdateEmailPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -49,8 +45,6 @@ function ImTeacherUpdateEmailPage() { ); } -ImTeacherUpdateEmailPage.propTypes = propTypes; -ImTeacherUpdateEmailPage.defaultProps = defaultProps; ImTeacherUpdateEmailPage.displayName = 'ImTeacherUpdateEmailPage'; export default ImTeacherUpdateEmailPage; diff --git a/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js b/src/pages/TeachersUnite/IntroSchoolPrincipalPage.tsx similarity index 68% rename from src/pages/TeachersUnite/IntroSchoolPrincipalPage.js rename to src/pages/TeachersUnite/IntroSchoolPrincipalPage.tsx index e0715da9e5ef..b84ad62858eb 100644 --- a/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js +++ b/src/pages/TeachersUnite/IntroSchoolPrincipalPage.tsx @@ -1,10 +1,8 @@ import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx/lib/types'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -22,71 +20,66 @@ import TeachersUnite from '@userActions/TeachersUnite'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {LoginList} from '@src/types/onyx'; -const propTypes = { - /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** Phone/Email associated with user */ - partnerUserID: PropTypes.string, - }), +type IntroSchoolPrincipalFormData = { + firstName: string; + lastName: string; + partnerUserID: string; }; -const defaultProps = { - loginList: {}, +type IntroSchoolPrincipalPageOnyxProps = { + loginList: OnyxEntry; }; -function IntroSchoolPrincipalPage(props) { +type IntroSchoolPrincipalPageProps = IntroSchoolPrincipalPageOnyxProps; + +function IntroSchoolPrincipalPage(props: IntroSchoolPrincipalPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isProduction} = useEnvironment(); /** - * @param {Object} values - * @param {String} values.firstName - * @param {String} values.partnerUserID - * @param {String} values.lastName + * Submit form to pass firstName, partnerUserID and lastName */ - const onSubmit = (values) => { + const onSubmit = (values: IntroSchoolPrincipalFormData) => { const policyID = isProduction ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; TeachersUnite.addSchoolPrincipal(values.firstName.trim(), values.partnerUserID.trim(), values.lastName.trim(), policyID); }; /** - * @param {Object} values - * @param {String} values.firstName - * @param {String} values.partnerUserID - * @returns {Object} - An object containing the errors for each inputID + * @returns - An object containing the errors for each inputID */ const validate = useCallback( - (values) => { + (values: IntroSchoolPrincipalFormData) => { const errors = {}; if (!ValidationUtils.isValidLegalName(values.firstName)) { - ErrorUtils.addErrorMessage(errors, 'firstName', translate('privatePersonalDetails.error.hasInvalidCharacter')); - } else if (_.isEmpty(values.firstName)) { - ErrorUtils.addErrorMessage(errors, 'firstName', translate('bankAccount.error.firstName')); + ErrorUtils.addErrorMessage(errors, 'firstName', 'privatePersonalDetails.error.hasInvalidCharacter'); + } else if (!values.firstName) { + ErrorUtils.addErrorMessage(errors, 'firstName', 'bankAccount.error.firstName'); } if (!ValidationUtils.isValidLegalName(values.lastName)) { - ErrorUtils.addErrorMessage(errors, 'lastName', translate('privatePersonalDetails.error.hasInvalidCharacter')); - } else if (_.isEmpty(values.lastName)) { - ErrorUtils.addErrorMessage(errors, 'lastName', translate('bankAccount.error.lastName')); + ErrorUtils.addErrorMessage(errors, 'lastName', 'privatePersonalDetails.error.hasInvalidCharacter'); + } else if (!values.lastName) { + ErrorUtils.addErrorMessage(errors, 'lastName', 'bankAccount.error.lastName'); } - if (_.isEmpty(values.partnerUserID)) { - ErrorUtils.addErrorMessage(errors, 'partnerUserID', translate('teachersUnitePage.error.enterEmail')); + if (!values.partnerUserID) { + ErrorUtils.addErrorMessage(errors, 'partnerUserID', 'teachersUnitePage.error.enterEmail'); } - if (!_.isEmpty(values.partnerUserID) && lodashGet(props.loginList, values.partnerUserID.toLowerCase())) { + if (values.partnerUserID && props.loginList?.[values.partnerUserID.toLowerCase()]) { ErrorUtils.addErrorMessage(errors, 'partnerUserID', 'teachersUnitePage.error.tryDifferentEmail'); } - if (!_.isEmpty(values.partnerUserID) && !Str.isValidEmail(values.partnerUserID)) { - ErrorUtils.addErrorMessage(errors, 'partnerUserID', translate('teachersUnitePage.error.enterValidEmail')); + if (values.partnerUserID && !Str.isValidEmail(values.partnerUserID)) { + ErrorUtils.addErrorMessage(errors, 'partnerUserID', 'teachersUnitePage.error.enterValidEmail'); } - if (!_.isEmpty(values.partnerUserID) && LoginUtils.isEmailPublicDomain(values.partnerUserID)) { - ErrorUtils.addErrorMessage(errors, 'partnerUserID', translate('teachersUnitePage.error.tryDifferentEmail')); + if (values.partnerUserID && LoginUtils.isEmailPublicDomain(values.partnerUserID)) { + ErrorUtils.addErrorMessage(errors, 'partnerUserID', 'teachersUnitePage.error.tryDifferentEmail'); } return errors; }, - [props.loginList, translate], + [props.loginList], ); return ( @@ -98,6 +91,7 @@ function IntroSchoolPrincipalPage(props) { title={translate('teachersUnitePage.introSchoolPrincipal')} onBackButtonPress={() => Navigation.goBack(ROUTES.TEACHERS_UNITE)} /> + {/* @ts-expect-error TODO: Remove this once FormProvider (https://github.com/Expensify/App/issues/31972) is migrated to TypeScript. */} {translate('teachersUnitePage.schoolPrincipalVerfiyExpense')} ({ loginList: {key: ONYXKEYS.LOGIN_LIST}, })(IntroSchoolPrincipalPage); diff --git a/src/pages/TeachersUnite/KnowATeacherPage.js b/src/pages/TeachersUnite/KnowATeacherPage.tsx similarity index 73% rename from src/pages/TeachersUnite/KnowATeacherPage.js rename to src/pages/TeachersUnite/KnowATeacherPage.tsx index 5b8c9455ba38..a309f628850a 100644 --- a/src/pages/TeachersUnite/KnowATeacherPage.js +++ b/src/pages/TeachersUnite/KnowATeacherPage.tsx @@ -1,10 +1,8 @@ import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx/lib/types'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -22,32 +20,29 @@ import TeachersUnite from '@userActions/TeachersUnite'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {LoginList} from '@src/types/onyx'; -const propTypes = { - /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** Phone/Email associated with user */ - partnerUserID: PropTypes.string, - }), +type KnowATeacherFormData = { + firstName: string; + lastName: string; + partnerUserID: string; }; -const defaultProps = { - loginList: {}, +type KnowATeacherPageOnyxProps = { + loginList: OnyxEntry; }; -function KnowATeacherPage(props) { +type KnowATeacherPageProps = KnowATeacherPageOnyxProps; + +function KnowATeacherPage(props: KnowATeacherPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isProduction} = useEnvironment(); /** * Submit form to pass firstName, partnerUserID and lastName - * @param {Object} values - * @param {String} values.partnerUserID - * @param {String} values.firstName - * @param {String} values.lastName */ - const onSubmit = (values) => { + const onSubmit = (values: KnowATeacherFormData) => { const phoneLogin = LoginUtils.getPhoneLogin(values.partnerUserID); const validateIfnumber = LoginUtils.validateNumber(phoneLogin); const contactMethod = (validateIfnumber || values.partnerUserID).trim().toLowerCase(); @@ -60,40 +55,37 @@ function KnowATeacherPage(props) { }; /** - * @param {Object} values - * @param {String} values.firstName - * @param {String} values.partnerUserID - * @returns {Object} - An object containing the errors for each inputID + * @returns - An object containing the errors for each inputID */ const validate = useCallback( - (values) => { + (values: KnowATeacherFormData) => { const errors = {}; const phoneLogin = LoginUtils.getPhoneLogin(values.partnerUserID); const validateIfnumber = LoginUtils.validateNumber(phoneLogin); if (!ValidationUtils.isValidLegalName(values.firstName)) { - ErrorUtils.addErrorMessage(errors, 'firstName', translate('privatePersonalDetails.error.hasInvalidCharacter')); - } else if (_.isEmpty(values.firstName)) { - ErrorUtils.addErrorMessage(errors, 'firstName', translate('bankAccount.error.firstName')); + ErrorUtils.addErrorMessage(errors, 'firstName', 'privatePersonalDetails.error.hasInvalidCharacter'); + } else if (!values.firstName) { + ErrorUtils.addErrorMessage(errors, 'firstName', 'bankAccount.error.firstName'); } if (!ValidationUtils.isValidLegalName(values.lastName)) { - ErrorUtils.addErrorMessage(errors, 'lastName', translate('privatePersonalDetails.error.hasInvalidCharacter')); - } else if (_.isEmpty(values.lastName)) { - ErrorUtils.addErrorMessage(errors, 'lastName', translate('bankAccount.error.lastName')); + ErrorUtils.addErrorMessage(errors, 'lastName', 'privatePersonalDetails.error.hasInvalidCharacter'); + } else if (!values.lastName) { + ErrorUtils.addErrorMessage(errors, 'lastName', 'bankAccount.error.lastName'); } - if (_.isEmpty(values.partnerUserID)) { - ErrorUtils.addErrorMessage(errors, 'partnerUserID', translate('teachersUnitePage.error.enterPhoneEmail')); + if (!values.partnerUserID) { + ErrorUtils.addErrorMessage(errors, 'partnerUserID', 'teachersUnitePage.error.enterPhoneEmail'); } - if (!_.isEmpty(values.partnerUserID) && lodashGet(props.loginList, validateIfnumber || values.partnerUserID.toLowerCase())) { + if (values.partnerUserID && props.loginList?.[validateIfnumber || values.partnerUserID.toLowerCase()]) { ErrorUtils.addErrorMessage(errors, 'partnerUserID', 'teachersUnitePage.error.tryDifferentEmail'); } - if (!_.isEmpty(values.partnerUserID) && !(validateIfnumber || Str.isValidEmail(values.partnerUserID))) { + if (values.partnerUserID && !(validateIfnumber || Str.isValidEmail(values.partnerUserID))) { ErrorUtils.addErrorMessage(errors, 'partnerUserID', 'contacts.genericFailureMessages.invalidContactMethod'); } return errors; }, - [props.loginList, translate], + [props.loginList], ); return ( @@ -105,6 +97,7 @@ function KnowATeacherPage(props) { title={translate('teachersUnitePage.iKnowATeacher')} onBackButtonPress={() => Navigation.goBack(ROUTES.TEACHERS_UNITE)} /> + {/* @ts-expect-error TODO: Remove this once FormProvider (https://github.com/Expensify/App/issues/31972) is migrated to TypeScript. */} {translate('teachersUnitePage.getInTouch')} ({ loginList: {key: ONYXKEYS.LOGIN_LIST}, })(KnowATeacherPage); diff --git a/src/pages/TeachersUnite/SaveTheWorldPage.js b/src/pages/TeachersUnite/SaveTheWorldPage.tsx similarity index 100% rename from src/pages/TeachersUnite/SaveTheWorldPage.js rename to src/pages/TeachersUnite/SaveTheWorldPage.tsx diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 7cc1b6ce26dd..813d05707098 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -73,6 +73,9 @@ const propTypes = { /** The URL for the policy avatar */ avatar: PropTypes.string, }), + + /** The reportID of the request */ + reportID: PropTypes.string.isRequired, }; const defaultProps = { @@ -92,7 +95,7 @@ function HeaderView(props) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - const participants = lodashGet(props.report, 'participantAccountIDs', []); + const participants = ReportUtils.getParticipantsIDs(props.report); const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, props.personalDetails); const isMultipleParticipant = participants.length > 1; const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant); @@ -135,14 +138,14 @@ function HeaderView(props) { threeDotMenuItems.push({ icon: Expensicons.Trashcan, text: translate('common.delete'), - onSelected: Session.checkIfActionIsAllowed(() => Task.deleteTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum)), + onSelected: Session.checkIfActionIsAllowed(() => Task.deleteTask(props.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum)), }); } } const join = Session.checkIfActionIsAllowed(() => Report.updateNotificationPreference( - props.report.reportID, + props.reportID, props.report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false, @@ -165,7 +168,7 @@ function HeaderView(props) { threeDotMenuItems.push({ icon: Expensicons.ChatBubbles, text: translate('common.leave'), - onSelected: Session.checkIfActionIsAllowed(() => Report.leaveRoom(props.report.reportID, isWorkspaceMemberLeavingWorkspaceRoom)), + onSelected: Session.checkIfActionIsAllowed(() => Report.leaveRoom(props.reportID, isWorkspaceMemberLeavingWorkspaceRoom)), }); } diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index c072666920ae..c52b8ec6760a 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -354,6 +354,13 @@ function ReportActionCompose({ runOnJS(submitForm)(); }, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]); + const emojiShiftVertical = useMemo(() => { + const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; + const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; + const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; + return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; + }, [styles]); + return ( @@ -453,6 +460,7 @@ function ReportActionCompose({ onModalHide={focus} onEmojiSelected={(...args) => composerRef.current.replaceSelectionWithText(...args)} emojiPickerID={report.reportID} + shiftVertical={emojiShiftVertical} /> )} Report.navigateToConciergeChatAndDeleteReport(props.report.reportID)} - > - - - - {parentReportAction && ( - - )} - - {!props.shouldHideThreadDividerLine && } - - ); -} - -ReportActionItemParentAction.defaultProps = defaultProps; -ReportActionItemParentAction.propTypes = propTypes; -ReportActionItemParentAction.displayName = 'ReportActionItemParentAction'; - -export default compose( - withWindowDimensions, - withLocalize, - withOnyx({ - report: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - }, - parentReportActions: { - key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, - canEvict: false, - }, - }), -)(ReportActionItemParentAction); diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx new file mode 100644 index 000000000000..f6d09ab76f09 --- /dev/null +++ b/src/pages/home/report/ReportActionItemParentAction.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as Report from '@userActions/Report'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; +import ReportActionItem from './ReportActionItem'; + +type ReportActionItemParentActionOnyxProps = { + /** The report currently being looked at */ + report: OnyxEntry; + + /** The actions from the parent report */ + parentReportActions: OnyxEntry; +}; + +type ReportActionItemParentActionProps = ReportActionItemParentActionOnyxProps & { + /** Flag to show, hide the thread divider line */ + shouldHideThreadDividerLine?: boolean; + + /** Flag to display the new marker on top of the comment */ + shouldDisplayNewMarker: boolean; + + /** Position index of the report parent action in the overall report FlatList view */ + index: number; + + /** The id of the report */ + // eslint-disable-next-line react/no-unused-prop-types + reportID: string; + + /** The id of the parent report */ + // eslint-disable-next-line react/no-unused-prop-types + parentReportID: string; +}; + +function ReportActionItemParentAction({report, parentReportActions = {}, index = 0, shouldHideThreadDividerLine = false, shouldDisplayNewMarker}: ReportActionItemParentActionProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {isSmallScreenWidth} = useWindowDimensions(); + const parentReportAction = parentReportActions?.[`${report?.parentReportActionID ?? ''}`] ?? null; + + // In case of transaction threads, we do not want to render the parent report action. + if (ReportActionsUtils.isTransactionThread(parentReportAction)) { + return null; + } + return ( + Report.navigateToConciergeChatAndDeleteReport(report?.reportID ?? '0')} + > + + + + {parentReportAction && ( + + )} + + {!shouldHideThreadDividerLine && } + + ); +} + +ReportActionItemParentAction.displayName = 'ReportActionItemParentAction'; + +export default withOnyx({ + report: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + }, + parentReportActions: { + key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + canEvict: false, + }, +})(ReportActionItemParentAction); diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 379b010f16e7..d64734a78085 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -14,6 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import * as ReportUtils from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import reportPropTypes from '@pages/reportPropTypes'; import CONST from '@src/CONST'; @@ -172,6 +173,7 @@ const chatReportSelector = (report) => hasDraft: report.hasDraft, isPinned: report.isPinned, isHidden: report.isHidden, + notificationPreference: report.notificationPreference, errorFields: { addWorkspaceRoom: report.errorFields && report.errorFields.addWorkspaceRoom, }, @@ -201,6 +203,7 @@ const chatReportSelector = (report) => parentReportActionID: report.parentReportActionID, parentReportID: report.parentReportID, isDeletedParentAction: report.isDeletedParentAction, + isUnreadWithMention: ReportUtils.isUnreadWithMention(report), }; /** diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 9aeabefd645d..bbcdc5cebef4 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -1,4 +1,3 @@ -import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -20,12 +19,9 @@ import * as IOU from '@userActions/IOU'; import * as Policy from '@userActions/Policy'; import * as Session from '@userActions/Session'; import * as Task from '@userActions/Task'; -import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; /** * @param {Object} [policy] @@ -142,18 +138,6 @@ function FloatingActionButtonAndPopover(props) { } }; - useEffect(() => { - const navigationState = props.navigation.getState(); - const routes = lodashGet(navigationState, 'routes', []); - const currentRoute = routes[navigationState.index]; - if (currentRoute && ![NAVIGATORS.CENTRAL_PANE_NAVIGATOR, SCREENS.HOME].includes(currentRoute.name)) { - return; - } - - Welcome.show({routes, showCreateMenu}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isLoading]); - useEffect(() => { if (!didScreenBecomeInactive()) { return; diff --git a/src/pages/home/sidebar/SidebarScreen/index.js b/src/pages/home/sidebar/SidebarScreen/index.js index 0b4c520c78a2..e823d24b87fe 100755 --- a/src/pages/home/sidebar/SidebarScreen/index.js +++ b/src/pages/home/sidebar/SidebarScreen/index.js @@ -1,4 +1,5 @@ import React, {useCallback, useRef} from 'react'; +import PurposeForUsingExpensifyModal from '@components/PurposeForUsingExpensifyModal'; import useWindowDimensions from '@hooks/useWindowDimensions'; import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; import BaseSidebarScreen from './BaseSidebarScreen'; @@ -44,6 +45,7 @@ function SidebarScreen(props) { onShowCreateMenu={createDragoverListener} onHideCreateMenu={removeDragoverListener} /> + ); diff --git a/src/pages/home/sidebar/SidebarScreen/index.native.js b/src/pages/home/sidebar/SidebarScreen/index.native.js index 36724c02d278..7f36e4ebfa22 100755 --- a/src/pages/home/sidebar/SidebarScreen/index.native.js +++ b/src/pages/home/sidebar/SidebarScreen/index.native.js @@ -1,4 +1,5 @@ import React from 'react'; +import PurposeForUsingExpensifyModal from '@components/PurposeForUsingExpensifyModal'; import useWindowDimensions from '@hooks/useWindowDimensions'; import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; import BaseSidebarScreen from './BaseSidebarScreen'; @@ -14,6 +15,7 @@ function SidebarScreen(props) { {...props} > + ); diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js index 3c4d7b3887c0..9888bf80508b 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.js +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js @@ -73,7 +73,7 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) { const navigateBackToPreviousScreen = useCallback(() => Navigation.goBack(ROUTES.SETTINGS_PROFILE, false, true), []); const updateStatus = useCallback( ({emojiCode, statusText}) => { - const clearAfterTime = draftClearAfter || currentUserClearAfter; + const clearAfterTime = draftClearAfter || currentUserClearAfter || CONST.CUSTOM_STATUS_TYPES.NEVER; const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({dateTimeString: clearAfterTime}); if (!isValid && clearAfterTime !== CONST.CUSTOM_STATUS_TYPES.NEVER) { setBrickRoadIndicator(isValidClearAfterDate() ? null : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); diff --git a/src/pages/settings/Profile/ProfileAvatar.tsx b/src/pages/settings/Profile/ProfileAvatar.tsx new file mode 100644 index 000000000000..2aa0f52609c1 --- /dev/null +++ b/src/pages/settings/Profile/ProfileAvatar.tsx @@ -0,0 +1,60 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import AttachmentModal from '@components/AttachmentModal'; +import Navigation from '@libs/Navigation/Navigation'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; +import * as UserUtils from '@libs/UserUtils'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import * as PersonalDetails from '@userActions/PersonalDetails'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +type ProfileAvatarOnyxProps = { + personalDetails: OnyxEntry; + isLoadingApp: OnyxEntry; +}; + +type ProfileAvatarProps = ProfileAvatarOnyxProps & StackScreenProps; + +function ProfileAvatar({route, personalDetails, isLoadingApp = true}: ProfileAvatarProps) { + const personalDetail = personalDetails?.[route.params.accountID]; + const avatarURL = personalDetail?.avatar ?? ''; + const accountID = Number(route.params.accountID ?? ''); + const isLoading = personalDetail?.isLoading ?? (isLoadingApp && !Object.keys(personalDetail ?? {}).length); + + useEffect(() => { + if (!ValidationUtils.isValidAccountRoute(Number(accountID)) ?? !!avatarURL) { + return; + } + PersonalDetails.openPublicProfilePage(accountID); + }, [accountID, avatarURL]); + + return ( + { + Navigation.goBack(); + }} + originalFileName={personalDetail?.originalFileName ?? ''} + isLoading={isLoading} + shouldShowNotFoundPage={!avatarURL} + /> + ); +} + +ProfileAvatar.displayName = 'ProfileAvatar'; + +export default withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, +})(ProfileAvatar); diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 89dfa4f0e419..99cc5cf7e35a 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -127,6 +127,7 @@ function ProfilePage(props) { errors={lodashGet(props.currentUserPersonalDetails, 'errorFields.avatar', null)} errorRowStyles={[styles.mt6]} onErrorClose={PersonalDetails.clearAvatarErrors} + onViewPhotoPress={() => Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(accountID))} previewSource={UserUtils.getFullSizeAvatar(avatarURL, accountID)} originalFileName={currentUserDetails.originalFileName} headerTitle={props.translate('profilePage.profileAvatar')} diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js index c6044bd81efe..488afa7c5a71 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.js +++ b/src/pages/settings/Report/NotificationPreferencePage.js @@ -43,9 +43,7 @@ function NotificationPreferencePage(props) { /> - Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, option.value, true, undefined, undefined, props.report) - } + onSelectRow={(option) => Report.updateNotificationPreference(props.reportID, props.report.notificationPreference, option.value, true, undefined, undefined, props.report)} initiallyFocusedOptionKey={_.find(notificationPreferenceOptions, (locale) => locale.isSelected).keyForList} /> diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index 8fcea461eacd..a7e2b5ee07fb 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -276,6 +276,7 @@ function LoginForm(props) { textContentType="username" id="username" name="username" + testID="username" onBlur={() => { if (firstBlurred.current || !Visibility.isVisible() || !Visibility.hasFocus()) { return; diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 9d5b51d667ff..7686a2c542b0 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -251,7 +251,10 @@ function SignInPageInner({credentials, account, isInModal, activeClients, prefer return ( // Bottom SafeAreaView is removed so that login screen svg displays correctly on mobile. // The SVG should flow under the Home Indicator on iOS. - + { + if (!needToClearError) { + return; + } + + if (props.account.errors) { + Session.clearAccountMessages(); + return; + } + setNeedToClearError(false); + }, [props.account.errors, needToClearError]); + /** * Switches between 2fa and recovery code, clears inputs and errors */ @@ -360,6 +373,7 @@ function BaseValidateCodeForm(props) { hasError={hasError} autoFocus key="validateCode" + testID="validateCode" /> {hasError && } diff --git a/src/pages/workspace/WorkspaceAvatar.tsx b/src/pages/workspace/WorkspaceAvatar.tsx new file mode 100644 index 000000000000..1a420ee0fbd3 --- /dev/null +++ b/src/pages/workspace/WorkspaceAvatar.tsx @@ -0,0 +1,51 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import AttachmentModal from '@components/AttachmentModal'; +import Navigation from '@libs/Navigation/Navigation'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as UserUtils from '@libs/UserUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Policy} from '@src/types/onyx'; + +type WorkspaceAvatarOnyxProps = { + policy: OnyxEntry; + isLoadingApp: OnyxEntry; +}; + +type WorkspaceAvatarProps = WorkspaceAvatarOnyxProps & StackScreenProps; + +function WorkspaceAvatar({route, policy, isLoadingApp = true}: WorkspaceAvatarProps) { + const avatarURL = policy?.avatar ?? '' ? policy?.avatar ?? '' : ReportUtils.getDefaultWorkspaceAvatar(policy?.name ?? ''); + + return ( + { + Navigation.goBack(ROUTES.WORKSPACE_SETTINGS.getRoute(route.params.policyID ?? '')); + }} + isWorkspaceAvatar + originalFileName={policy?.originalFileName ?? policy?.name ?? ''} + shouldShowNotFoundPage={!Object.keys(policy ?? {}).length && !isLoadingApp} + isLoading={!Object.keys(policy ?? {}).length && isLoadingApp} + /> + ); +} + +WorkspaceAvatar.displayName = 'WorkspaceAvatar'; + +export default withOnyx({ + policy: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID ?? ''}`, + }, + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, +})(WorkspaceAvatar); diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index 80813c847239..6c8476fed5cb 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -67,6 +67,15 @@ function dismissError(policyID) { Policy.removeWorkspace(policyID); } +/** + * Whether the policy report should be archived when we delete the policy. + * @param {Object} report + * @returns {Boolean} + */ +function shouldArchiveReport(report) { + return ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report); +} + function WorkspaceInitialPage(props) { const styles = useThemeStyles(); const policy = props.policyDraft && props.policyDraft.id ? props.policyDraft : props.policy; @@ -111,7 +120,7 @@ function WorkspaceInitialPage(props) { * Call the delete policy and hide the modal */ const confirmDeleteAndHideModal = useCallback(() => { - Policy.deleteWorkspace(policyID, policyReports, policy.name); + Policy.deleteWorkspace(policyID, _.filter(policyReports, shouldArchiveReport), policy.name); setIsDeleteModalOpen(false); // Pop the deleted workspace page before opening workspace settings. Navigation.goBack(ROUTES.SETTINGS_WORKSPACES); diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js index 2150358a5134..72f3747c127c 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -1,3 +1,4 @@ +import {useNavigation} from '@react-navigation/native'; import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; @@ -76,6 +77,8 @@ function WorkspaceInvitePage(props) { const [selectedOptions, setSelectedOptions] = useState([]); const [personalDetails, setPersonalDetails] = useState([]); const [usersToInvite, setUsersToInvite] = useState([]); + const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); + const navigation = useNavigation(); const openWorkspaceInvitePage = () => { const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails); Policy.openWorkspaceInvitePage(props.route.params.policyID, _.keys(policyMemberEmailsToAccountIDs)); @@ -94,6 +97,18 @@ function WorkspaceInvitePage(props) { // eslint-disable-next-line react-hooks/exhaustive-deps -- policyID changes remount the component }, []); + useEffect(() => { + const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => { + setDidScreenTransitionEnd(true); + }); + + return () => { + unsubscribeTransitionEnd(); + }; + // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useNetwork({onReconnect: openWorkspaceInvitePage}); const excludedUsers = useMemo(() => PolicyUtils.getIneligibleInvitees(props.policyMembers, props.personalDetails), [props.policyMembers, props.personalDetails]); @@ -145,10 +160,14 @@ function WorkspaceInvitePage(props) { // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change }, [props.personalDetails, props.policyMembers, props.betas, searchTerm, excludedUsers]); - const getSections = () => { - const sections = []; + const sections = useMemo(() => { + const sectionsArr = []; let indexOffset = 0; + if (!didScreenTransitionEnd) { + return []; + } + // Filter all options that is a part of the search term or in the personal details let filterSelectedOptions = selectedOptions; if (searchTerm !== '') { @@ -163,7 +182,7 @@ function WorkspaceInvitePage(props) { }); } - sections.push({ + sectionsArr.push({ title: undefined, data: filterSelectedOptions, shouldShow: true, @@ -176,7 +195,7 @@ function WorkspaceInvitePage(props) { const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login)); const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, OptionsListUtils.formatMemberForList); - sections.push({ + sectionsArr.push({ title: translate('common.contacts'), data: personalDetailsFormatted, shouldShow: !_.isEmpty(personalDetailsFormatted), @@ -188,7 +207,7 @@ function WorkspaceInvitePage(props) { const hasUnselectedUserToInvite = !_.contains(selectedLogins, userToInvite.login); if (hasUnselectedUserToInvite) { - sections.push({ + sectionsArr.push({ title: undefined, data: [OptionsListUtils.formatMemberForList(userToInvite)], shouldShow: true, @@ -197,8 +216,8 @@ function WorkspaceInvitePage(props) { } }); - return sections; - }; + return sectionsArr; + }, [personalDetails, searchTerm, selectedOptions, usersToInvite, translate, didScreenTransitionEnd]); const toggleOption = (option) => { Policy.clearErrors(props.route.params.policyID); @@ -253,7 +272,12 @@ function WorkspaceInvitePage(props) { if (usersToInvite.length === 0 && CONST.EXPENSIFY_EMAILS.includes(searchValue)) { return translate('messages.errorMessageInvalidEmail'); } - if (usersToInvite.length === 0 && excludedUsers.includes(searchValue)) { + if ( + usersToInvite.length === 0 && + excludedUsers.includes( + parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible ? OptionsListUtils.addSMSDomainIfPhoneNumber(LoginUtils.appendCountryCode(searchValue)) : searchValue, + ) + ) { return translate('messages.userIsAlreadyMember', {login: searchValue, name: policyName}); } return OptionsListUtils.getHeaderMessage(personalDetails.length !== 0, usersToInvite.length > 0, searchValue); @@ -264,56 +288,50 @@ function WorkspaceInvitePage(props) { shouldEnableMaxHeight testID={WorkspaceInvitePage.displayName} > - {({didScreenTransitionEnd}) => { - const sections = didScreenTransitionEnd ? getSections() : []; - - return ( - Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} - > - { - Policy.clearErrors(props.route.params.policyID); - Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(props.route.params.policyID)); - }} - /> - { - SearchInputManager.searchInput = value; - setSearchTerm(value); - }} - headerMessage={headerMessage} - onSelectRow={toggleOption} - onConfirm={inviteUser} - showScrollIndicator - showLoadingPlaceholder={!didScreenTransitionEnd || !OptionsListUtils.isPersonalDetailsReady(props.personalDetails)} - shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} - /> - - - - - ); - }} + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} + > + { + Policy.clearErrors(props.route.params.policyID); + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(props.route.params.policyID)); + }} + /> + { + SearchInputManager.searchInput = value; + setSearchTerm(value); + }} + headerMessage={headerMessage} + onSelectRow={toggleOption} + onConfirm={inviteUser} + showScrollIndicator + showLoadingPlaceholder={!didScreenTransitionEnd || !OptionsListUtils.isPersonalDetailsReady(props.personalDetails)} + shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} + /> + + + + ); } diff --git a/src/pages/workspace/WorkspacePageWithSections.js b/src/pages/workspace/WorkspacePageWithSections.tsx similarity index 54% rename from src/pages/workspace/WorkspacePageWithSections.js rename to src/pages/workspace/WorkspacePageWithSections.tsx index a51f7861cba5..8817f813a990 100644 --- a/src/pages/workspace/WorkspacePageWithSections.js +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -1,9 +1,9 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useEffect, useRef} from 'react'; +import type {RouteProp} from '@react-navigation/native'; +import React, {useEffect, useMemo, useRef} from 'react'; +import type {ReactNode} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx/lib/types'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -11,76 +11,60 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollViewWithContext from '@components/ScrollViewWithContext'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import BankAccount from '@libs/models/BankAccount'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; -import userPropTypes from '@pages/settings/userPropTypes'; import * as BankAccounts from '@userActions/BankAccounts'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type {Policy, ReimbursementAccount, User} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; -const propTypes = { - shouldSkipVBBACall: PropTypes.bool, - - /** The text to display in the header */ - headerText: PropTypes.string.isRequired, - - /** The route object passed to this page from the navigator */ - route: PropTypes.shape({ - /** Each parameter passed via the URL */ - params: PropTypes.shape({ - /** The policyID that is being configured */ - policyID: PropTypes.string.isRequired, - }).isRequired, - }).isRequired, - +type WorkspacePageWithSectionsOnyxProps = { /** From Onyx */ /** Bank account attached to free plan */ - reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes, + reimbursementAccount: OnyxEntry; /** User Data from Onyx */ - user: userPropTypes, + user: OnyxEntry; +}; - /** Main content of the page */ - children: PropTypes.func, +type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & + WorkspacePageWithSectionsOnyxProps & { + shouldSkipVBBACall?: boolean; - /** Content to be added as fixed footer */ - footer: PropTypes.element, + /** The text to display in the header */ + headerText: string; - /** The guides call task ID to associate with the workspace page being shown */ - guidesCallTaskID: PropTypes.string, + /** The route object passed to this page from the navigator */ + route: RouteProp<{params: {policyID: string}}>; - /** The route where we navigate when the user press the back button */ - backButtonRoute: PropTypes.string, + /** Main content of the page */ + children: (hasVBA?: boolean, policyID?: string, isUsingECard?: boolean) => ReactNode; - /** Policy values needed in the component */ - policy: PropTypes.shape({ - name: PropTypes.string, - }).isRequired, + /** Content to be added as fixed footer */ + footer?: ReactNode; - /** Option to use the default scroll view */ - shouldUseScrollView: PropTypes.bool, + /** The guides call task ID to associate with the workspace page being shown */ + guidesCallTaskID: string; - /** Option to show the loading page while the API is calling */ - shouldShowLoading: PropTypes.bool, -}; + /** The route where we navigate when the user press the back button */ + backButtonRoute?: Route; -const defaultProps = { - children: () => {}, - user: {}, - reimbursementAccount: ReimbursementAccountProps.reimbursementAccountDefaultProps, - footer: null, - guidesCallTaskID: '', - shouldUseScrollView: false, - shouldSkipVBBACall: false, - backButtonRoute: '', - shouldShowLoading: true, -}; + /** Option to use the default scroll view */ + shouldUseScrollView?: boolean; + + /** Option to show the loading page while the API is calling */ + shouldShowLoading?: boolean; + + /** Policy values needed in the component */ + policy: OnyxEntry; + }; -function fetchData(skipVBBACal) { +function fetchData(skipVBBACal?: boolean) { if (skipVBBACal) { return; } @@ -89,28 +73,28 @@ function fetchData(skipVBBACal) { } function WorkspacePageWithSections({ - backButtonRoute, - children, - footer, - guidesCallTaskID, + backButtonRoute = '', + children = () => null, + footer = null, + guidesCallTaskID = '', headerText, policy, - reimbursementAccount, + reimbursementAccount = {}, route, - shouldUseScrollView, - shouldSkipVBBACall, + shouldUseScrollView = false, + shouldSkipVBBACall = false, user, - shouldShowLoading, -}) { + shouldShowLoading = true, +}: WorkspacePageWithSectionsProps) { const styles = useThemeStyles(); useNetwork({onReconnect: () => fetchData(shouldSkipVBBACall)}); - const isLoading = lodashGet(reimbursementAccount, 'isLoading', true); - const achState = lodashGet(reimbursementAccount, 'achData.state', ''); + const isLoading = reimbursementAccount?.isLoading ?? true; + const achState = reimbursementAccount?.achData?.state ?? ''; + const isUsingECard = user?.isUsingExpensifyCard ?? false; + const policyID = route.params.policyID; + const policyName = policy?.name; const hasVBA = achState === BankAccount.STATE.OPEN; - const isUsingECard = lodashGet(user, 'isUsingExpensifyCard', false); - const policyID = lodashGet(route, 'params.policyID'); - const policyName = lodashGet(policy, 'name'); const content = children(hasVBA, policyID, isUsingECard); const firstRender = useRef(true); @@ -123,6 +107,14 @@ function WorkspacePageWithSections({ fetchData(shouldSkipVBBACall); }, [shouldSkipVBBACall]); + const shouldShow = useMemo(() => { + if (isEmptyObject(policy)) { + return true; + } + + return !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy); + }, [policy]); + return ( Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} - shouldShow={_.isEmpty(policy) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy)} - subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'} + shouldShow={shouldShow} + subtitleKey={isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized'} > Navigation.goBack(backButtonRoute || ROUTES.WORKSPACE_INITIAL.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack(backButtonRoute ?? ROUTES.WORKSPACE_INITIAL.getRoute(policyID))} /> {(isLoading || firstRender.current) && shouldShowLoading ? ( @@ -164,18 +156,15 @@ function WorkspacePageWithSections({ ); } -WorkspacePageWithSections.propTypes = propTypes; -WorkspacePageWithSections.defaultProps = defaultProps; WorkspacePageWithSections.displayName = 'WorkspacePageWithSections'; -export default compose( - withOnyx({ +export default withPolicyAndFullscreenLoading( + withOnyx({ user: { key: ONYXKEYS.USER, }, reimbursementAccount: { key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, }, - }), - withPolicyAndFullscreenLoading, -)(WorkspacePageWithSections); + })(WorkspacePageWithSections), +); diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js index 737a5f1b7cf6..f15d0228aec4 100644 --- a/src/pages/workspace/WorkspaceSettingsPage.js +++ b/src/pages/workspace/WorkspaceSettingsPage.js @@ -112,6 +112,7 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) { enabledWhenOffline > Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy.id))} source={lodashGet(policy, 'avatar')} size={CONST.AVATAR_SIZE.LARGE} DefaultAvatar={() => ( diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx index 2126409231b8..79ff76204c69 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx @@ -1,14 +1,15 @@ -import type {StackScreenProps} from '@react-navigation/stack'; +import type {RouteProp} from '@react-navigation/native'; import React from 'react'; import useLocalize from '@hooks/useLocalize'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import CONST from '@src/CONST'; -import type SCREENS from '@src/SCREENS'; import WorkspaceInvoicesNoVBAView from './WorkspaceInvoicesNoVBAView'; import WorkspaceInvoicesVBAView from './WorkspaceInvoicesVBAView'; -type WorkspaceInvoicesPageProps = StackScreenProps; +/** Defined route object that contains the policyID param, WorkspacePageWithSections is a common component for Workspaces and expect the route prop that includes the policyID */ +type WorkspaceInvoicesPageProps = { + route: RouteProp<{params: {policyID: string}}>; +}; function WorkspaceInvoicesPage({route}: WorkspaceInvoicesPageProps) { const {translate} = useLocalize(); @@ -17,13 +18,13 @@ function WorkspaceInvoicesPage({route}: WorkspaceInvoicesPageProps) { - {(hasVBA: boolean, policyID: string) => ( + {(hasVBA?: boolean, policyID?: string) => ( <> - {!hasVBA && } - {hasVBA && } + {!hasVBA && policyID && } + {hasVBA && policyID && } )} diff --git a/src/styles/index.ts b/src/styles/index.ts index aace13c34594..726df7658c5c 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1467,7 +1467,7 @@ const styles = (theme: ThemeColors) => createMenuPositionReportActionCompose: (windowHeight: number) => ({ horizontal: 18 + variables.sideBarWidth, - vertical: windowHeight - 83, + vertical: windowHeight - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM, } satisfies AnchorPosition), createMenuPositionRightSidepane: { @@ -2875,6 +2875,10 @@ const styles = (theme: ThemeColors) => outlineStyle: 'none', }, + boxShadowNone: { + boxShadow: 'none', + }, + cardStyleNavigator: { overflow: 'hidden', height: '100%', diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 4d4234e167ef..98389330a6e6 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -130,7 +130,7 @@ const darkTheme = { backgroundColor: colors.yellow600, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, - [SCREENS.RIGHT_MODAL.REFERRAL]: { + [SCREENS.REFERRAL_DETAILS]: { backgroundColor: colors.pink800, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 9cc5b03ac777..e07b74df6e7c 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -130,7 +130,7 @@ const lightTheme = { backgroundColor: colors.yellow600, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, - [SCREENS.RIGHT_MODAL.REFERRAL]: { + [SCREENS.REFERRAL_DETAILS]: { backgroundColor: colors.pink800, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 8b040dd8d72c..ea8b3f258c89 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1,5 +1,5 @@ import {StyleSheet} from 'react-native'; -import type {Animated, DimensionValue, ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {Animated, ColorValue, DimensionValue, ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {EdgeInsets} from 'react-native-safe-area-context'; import type {ValueOf} from 'type-fest'; @@ -388,7 +388,7 @@ function getWidthStyle(width: number): ViewStyle { /** * Returns a style with backgroundColor and borderColor set to the same color */ -function getBackgroundAndBorderStyle(backgroundColor: string | undefined): ViewStyle { +function getBackgroundAndBorderStyle(backgroundColor: ColorValue | undefined): ViewStyle { return { backgroundColor, borderColor: backgroundColor, @@ -398,7 +398,7 @@ function getBackgroundAndBorderStyle(backgroundColor: string | undefined): ViewS /** * Returns a style with the specified backgroundColor */ -function getBackgroundColorStyle(backgroundColor: string): ViewStyle { +function getBackgroundColorStyle(backgroundColor: ColorValue): ViewStyle { return { backgroundColor, }; diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts index 0ea3e05e8d6a..4e7c5396b649 100644 --- a/src/types/onyx/Account.ts +++ b/src/types/onyx/Account.ts @@ -50,7 +50,7 @@ type Account = { /** The active policy ID. Initiating a SmartScan will create an expense on this policy by default. */ activePolicyID?: string; - errors?: OnyxCommon.Errors; + errors?: OnyxCommon.Errors | null; success?: string; codesAreCopied?: boolean; twoFactorAuthStep?: TwoFactorAuthStep; diff --git a/src/types/onyx/IntroSelected.ts b/src/types/onyx/IntroSelected.ts new file mode 100644 index 000000000000..f0047ac134ee --- /dev/null +++ b/src/types/onyx/IntroSelected.ts @@ -0,0 +1,9 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type IntroSelected = { + /** The choice that the user selected in the engagement modal */ + choice: ValueOf; +}; + +export default IntroSelected; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 2fd04af328ff..97e6597c6444 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -98,6 +98,12 @@ type Policy = { /** The employee list of the policy */ employeeList?: []; + /** The reimbursement choice for policy */ + reimbursementChoice?: ValueOf; + + /** The maximum report total allowed to trigger auto reimbursement. */ + autoReimbursementLimit?: number; + /** Whether to leave the calling account as an admin on the policy */ makeMeAdmin?: boolean; diff --git a/src/types/onyx/PolicyReportField.ts b/src/types/onyx/PolicyReportField.ts index 6a3be23c0a0b..a1724a9ff52f 100644 --- a/src/types/onyx/PolicyReportField.ts +++ b/src/types/onyx/PolicyReportField.ts @@ -24,5 +24,4 @@ type PolicyReportField = { }; type PolicyReportFields = Record; -export default PolicyReportField; -export type {PolicyReportFields}; +export type {PolicyReportField, PolicyReportFields}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index e6d6c27fc818..8e54f97874e3 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -14,6 +14,7 @@ import type Form from './Form'; import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import type {FundList} from './Fund'; import type Fund from './Fund'; +import type IntroSelected from './IntroSelected'; import type IOU from './IOU'; import type Locale from './Locale'; import type {LoginList} from './Login'; @@ -30,7 +31,7 @@ import type Policy from './Policy'; import type {PolicyCategories, PolicyCategory} from './PolicyCategory'; import type {PolicyMembers} from './PolicyMember'; import type PolicyMember from './PolicyMember'; -import type PolicyReportField from './PolicyReportField'; +import type {PolicyReportField, PolicyReportFields} from './PolicyReportField'; import type {PolicyTag, PolicyTags} from './PolicyTag'; import type PrivatePersonalDetails from './PrivatePersonalDetails'; import type RecentlyUsedCategories from './RecentlyUsedCategories'; @@ -85,6 +86,7 @@ export type { FrequentlyUsedEmoji, Fund, FundList, + IntroSelected, IOU, Locale, Login, @@ -140,5 +142,6 @@ export type { WalletTransfer, ReportUserIsTyping, PolicyReportField, + PolicyReportFields, RecentlyUsedReportFields, }; diff --git a/tests/perf-test/README.md b/tests/perf-test/README.md index e7391612f071..a9b1643d191d 100644 --- a/tests/perf-test/README.md +++ b/tests/perf-test/README.md @@ -32,7 +32,6 @@ We use Reassure for monitoring performance regression. It helps us check if our - **Render count**: If the number of renders increases by one compared to the baseline, it will be considered a performance regression, leading to a failed test. This metric helps detect unexpected changes in component rendering behavior. *NOTE: sometimes regressions are intentional. For instance, if a new functionality is added to the tested component, causing an additional re-render, this regression is expected.* - **Render duration**: A performance regression will occur if the measured rendering time is 20% higher than the baseline, resulting in a failed test. This threshold allows for reasonable fluctuations and accounts for changes that may lead to longer rendering times. - ## Tips for Performance Testing with Reassure - Before you start using Reassure, take a bit of time to learn what it does [docs](https://callstack.github.io/reassure/). @@ -40,6 +39,27 @@ We use Reassure for monitoring performance regression. It helps us check if our - Mocking is a crucial part of performance testing. To achieve more accurate and meaningful results, mock and use as much data as possible. - Inside each test, there is a defined scenario function that represents the specific user interaction you want to measure (HINT: there is no need to add assertions in performance tests). - More runs generally lead to better and more reliable results by averaging out variations. Additionally, consider adjusting the number of runs per series for each specific test to achieve more granular insights. +- There's no need to mock Onyx before every test that uses `measureFunction()` because it doesn't need to be reset between each test case and we can just configure it once before running the tests. + +## Why reassure test may fail: + +- **Wrong mocking**: + + - Double-check and ensure that the mocks are accurate and aligned with the expected behavior. + - Review the test cases and adjust the mocking accordingly. +- **Timeouts**: + + - The performance test takes much longer than regular tests. This is because we run each test scenario multiple times (10 by default) and repeat this for two branches of code. + - This may lead to timeouts, especially if the Onyx mockup has extensive data. + - Be mindful of the number of test runs. While repetition is essential, find the optimal balance to avoid unnecessarily extended test durations. +- **Render count error**: + + - If the number of renders increases, the test on CI will fail with the following error: + + ```Render count difference exceeded the allowed deviation of 0. Current difference: 1``` + + - Investigate the code changes that might be causing this and address them to maintain a stable render count. More info [here](https://github.com/Expensify/App/blob/fe9e9e3e31bae27c2398678aa632e808af2690b5/tests/perf-test/README.md?plain=1#L32). + - It is important to run Reassure tests locally and see if our changes caused a regression. ## What can be tested (scenarios) diff --git a/tests/perf-test/SearchPage.perf-test.js b/tests/perf-test/SearchPage.perf-test.js index c1568bea5dcd..046d651469f1 100644 --- a/tests/perf-test/SearchPage.perf-test.js +++ b/tests/perf-test/SearchPage.perf-test.js @@ -146,7 +146,7 @@ test('[Search Page] should render options list', async () => { .then(() => measurePerformance(, {scenario, runs})); }); -test('[Search Page] should search in options list', async () => { +test.skip('[Search Page] should search in options list', async () => { const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock(); const scenario = async () => { @@ -177,7 +177,7 @@ test('[Search Page] should search in options list', async () => { .then(() => measurePerformance(, {scenario, runs})); }); -test('[Search Page] should click on list item', async () => { +test.skip('[Search Page] should click on list item', async () => { const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock(); const scenario = async () => { diff --git a/tests/perf-test/SignInPage.perf-test.tsx b/tests/perf-test/SignInPage.perf-test.tsx new file mode 100644 index 000000000000..80964c3c49cd --- /dev/null +++ b/tests/perf-test/SignInPage.perf-test.tsx @@ -0,0 +1,148 @@ +import type * as NativeNavigation from '@react-navigation/native'; +import {fireEvent, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {measurePerformance} from 'reassure'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxProvider from '@components/OnyxProvider'; +import {WindowDimensionsProvider} from '@components/withWindowDimensions'; +import * as Localize from '@libs/Localize'; +import type * as Navigation from '@libs/Navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SignInPage from '@src/pages/signin/SignInPage'; +import getValidCodeCredentials from '../utils/collections/getValidCodeCredentials'; +import userAccount, {getValidAccount} from '../utils/collections/userAccount'; +import PusherHelper from '../utils/PusherHelper'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('../../src/libs/Navigation/Navigation', () => { + const actualNav = jest.requireActual('../../src/libs/Navigation/Navigation'); + return { + ...actualNav, + navigationRef: { + addListener: () => jest.fn(), + removeListener: () => jest.fn(), + }, + } as typeof Navigation; +}); + +const mockedNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useFocusEffect: jest.fn(), + useIsFocused: () => ({ + navigate: mockedNavigate, + }), + useRoute: () => jest.fn(), + useNavigation: () => ({ + navigate: jest.fn(), + addListener: () => jest.fn(), + }), + createNavigationContainerRef: jest.fn(), + } as typeof NativeNavigation; +}); + +type Props = Partial & {navigation: Partial}; + +function SignInPageWrapper(args: Props) { + return ( + + + + ); +} + +const login = 'test@mail.com'; + +describe('SignInPage', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + }); + + // Initialize the network key for OfflineWithFeedback + beforeEach(() => { + global.fetch = TestHelper.getGlobalFetchMock() as typeof fetch; + wrapOnyxWithWaitForBatchedUpdates(Onyx); + Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); + }); + + // Clear out Onyx after each test so that each test starts with a clean state + afterEach(() => { + Onyx.clear(); + PusherHelper.teardown(); + }); + + test('[SignInPage] should add username and click continue button', () => { + const addListener = jest.fn(); + const scenario = async () => { + // Checking the SignInPage is mounted + await screen.findByTestId('SignInPage'); + + const usernameInput = screen.getByTestId('username'); + + fireEvent.changeText(usernameInput, login); + + const hintContinueButtonText = Localize.translateLocal('common.continue'); + + const continueButton = await screen.findByText(hintContinueButtonText); + + fireEvent.press(continueButton); + }; + + const navigation = {addListener}; + + return waitForBatchedUpdates() + .then(() => + Onyx.multiSet({ + [ONYXKEYS.ACCOUNT]: userAccount, + [ONYXKEYS.IS_SIDEBAR_LOADED]: false, + }), + ) + .then(() => measurePerformance(, {scenario})); + }); + + test('[SignInPage] should add magic code and click Sign In button', () => { + const addListener = jest.fn(); + const scenario = async () => { + // Checking the SignInPage is mounted + await screen.findByTestId('SignInPage'); + + const welcomeBackText = Localize.translateLocal('welcomeText.welcomeBack'); + const enterMagicCodeText = Localize.translateLocal('welcomeText.welcomeEnterMagicCode', {login}); + + await screen.findByText(`${welcomeBackText} ${enterMagicCodeText}`); + const magicCodeInput = screen.getByTestId('validateCode'); + + fireEvent.changeText(magicCodeInput, '123456'); + + const signInButtonText = Localize.translateLocal('common.signIn'); + const signInButton = await screen.findByText(signInButtonText); + + fireEvent.press(signInButton); + }; + + const navigation = {addListener}; + + return waitForBatchedUpdates() + .then(() => + Onyx.multiSet({ + [ONYXKEYS.ACCOUNT]: getValidAccount(login), + [ONYXKEYS.CREDENTIALS]: getValidCodeCredentials(login), + [ONYXKEYS.IS_SIDEBAR_LOADED]: false, + }), + ) + .then(() => measurePerformance(, {scenario})); + }); +}); diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js index e1c11fbb8ca8..3aab3a13c1c3 100644 --- a/tests/unit/CalendarPickerTest.js +++ b/tests/unit/CalendarPickerTest.js @@ -7,6 +7,7 @@ import DateUtils from '../../src/libs/DateUtils'; const monthNames = DateUtils.getMonthNames(CONST.LOCALES.EN); jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({navigate: jest.fn()}), createNavigationContainerRef: jest.fn(), })); diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.js index 7480da456d7f..a752eea1a990 100644 --- a/tests/unit/DateUtilsTest.js +++ b/tests/unit/DateUtilsTest.js @@ -213,4 +213,35 @@ describe('DateUtils', () => { }); }); }); + + describe('getLastBusinessDayOfMonth', () => { + const scenarios = [ + { + // Last business day of May in 2025 + inputDate: new Date(2025, 4), + expectedResult: 30, + }, + { + // Last business day of February in 2024 + inputDate: new Date(2024, 2), + expectedResult: 29, + }, + { + // Last business day of January in 2024 + inputDate: new Date(2024, 0), + expectedResult: 31, + }, + { + // Last business day of September in 2023 + inputDate: new Date(2023, 8), + expectedResult: 29, + }, + ]; + + test.each(scenarios)('returns a last business day based on the input date', ({inputDate, expectedResult}) => { + const lastBusinessDay = DateUtils.getLastBusinessDayOfMonth(inputDate); + + expect(lastBusinessDay).toEqual(expectedResult); + }); + }); }); diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index aedc02cc628b..c8ffdc44edb5 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -25,6 +25,27 @@ describe('ModifiedExpenseMessage', () => { }); }); + describe('when the amount is changed while the original value was partial', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE, + originalMessage: { + amount: 1800, + currency: CONST.CURRENCY.USD, + oldAmount: 0, + oldCurrency: CONST.CURRENCY.USD, + }, + }; + + it('returns the correct text message', () => { + const expectedResult = `set the amount to $18.00.`; + + const result = ModifiedExpenseMessage.getForReportAction(reportAction); + + expect(result).toEqual(expectedResult); + }); + }); + describe('when the amount is changed and the description is removed', () => { const reportAction = { ...createRandomReportAction(1), @@ -169,6 +190,25 @@ describe('ModifiedExpenseMessage', () => { }); }); + describe('when the merchant is changed while the previous merchant was partial', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE, + originalMessage: { + merchant: 'KFC', + oldMerchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + }, + }; + + it('returns the correct text message', () => { + const expectedResult = `set the merchant to "KFC".`; + + const result = ModifiedExpenseMessage.getForReportAction(reportAction); + + expect(result).toEqual(expectedResult); + }); + }); + describe('when the merchant and the description are removed', () => { const reportAction = { ...createRandomReportAction(1), diff --git a/tests/unit/UnreadIndicatorUpdaterTest.ts b/tests/unit/UnreadIndicatorUpdaterTest.ts new file mode 100644 index 000000000000..a5f58b57793a --- /dev/null +++ b/tests/unit/UnreadIndicatorUpdaterTest.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import CONST from '../../src/CONST'; +import getUnreadReportsForUnreadIndicator from '../../src/libs/UnreadIndicatorUpdater'; + +describe('UnreadIndicatorUpdaterTest', () => { + describe('should return correct number of unread reports', () => { + it('given last read time < last visible action created', () => { + const reportsToBeUsed = { + 1: {reportID: '1', reportName: 'test', type: CONST.REPORT.TYPE.EXPENSE, lastReadTime: '2023-07-08 07:15:44.030', lastVisibleActionCreated: '2023-08-08 07:15:44.030'}, + 2: {reportID: '2', reportName: 'test', type: CONST.REPORT.TYPE.TASK, lastReadTime: '2023-02-05 09:12:05.000', lastVisibleActionCreated: '2023-02-06 07:15:44.030'}, + 3: {reportID: '3', reportName: 'test', type: CONST.REPORT.TYPE.TASK}, + }; + expect(getUnreadReportsForUnreadIndicator(reportsToBeUsed, '3').length).toBe(2); + }); + + it('given some reports are incomplete', () => { + const reportsToBeUsed = { + 1: {reportID: '1', type: CONST.REPORT.TYPE.EXPENSE, lastReadTime: '2023-07-08 07:15:44.030', lastVisibleActionCreated: '2023-08-08 07:15:44.030'}, + 2: {reportID: '2', type: CONST.REPORT.TYPE.TASK, lastReadTime: '2023-02-05 09:12:05.000', lastVisibleActionCreated: '2023-02-06 07:15:44.030'}, + 3: {reportID: '3', type: CONST.REPORT.TYPE.TASK}, + }; + expect(getUnreadReportsForUnreadIndicator(reportsToBeUsed, '3').length).toBe(0); + }); + + it('given notification preference of some reports is hidden', () => { + const reportsToBeUsed = { + 1: { + reportID: '1', + reportName: 'test', + type: CONST.REPORT.TYPE.EXPENSE, + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + lastReadTime: '2023-07-08 07:15:44.030', + lastVisibleActionCreated: '2023-08-08 07:15:44.030', + }, + 2: {reportID: '2', reportName: 'test', type: CONST.REPORT.TYPE.TASK, lastReadTime: '2023-02-05 09:12:05.000', lastVisibleActionCreated: '2023-02-06 07:15:44.030'}, + 3: {reportID: '3', reportName: 'test', type: CONST.REPORT.TYPE.TASK}, + }; + expect(getUnreadReportsForUnreadIndicator(reportsToBeUsed, '3').length).toBe(1); + }); + }); +}); diff --git a/tests/utils/collections/getValidCodeCredentials.ts b/tests/utils/collections/getValidCodeCredentials.ts new file mode 100644 index 000000000000..5ee856b61160 --- /dev/null +++ b/tests/utils/collections/getValidCodeCredentials.ts @@ -0,0 +1,11 @@ +import {randEmail, randNumber} from '@ngneat/falso'; +import type {Credentials} from '@src/types/onyx'; + +function getValidCodeCredentials(login = randEmail()): Credentials { + return { + login, + validateCode: `${randNumber()}`, + }; +} + +export default getValidCodeCredentials; diff --git a/tests/utils/collections/userAccount.ts b/tests/utils/collections/userAccount.ts new file mode 100644 index 000000000000..9e7c33a228d5 --- /dev/null +++ b/tests/utils/collections/userAccount.ts @@ -0,0 +1,14 @@ +import CONST from '@src/CONST'; +import type {Account} from '@src/types/onyx'; + +function getValidAccount(credentialLogin = ''): Account { + return { + validated: true, + primaryLogin: credentialLogin, + isLoading: false, + requiresTwoFactorAuth: false, + }; +} + +export default CONST.DEFAULT_ACCOUNT_DATA; +export {getValidAccount};