diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml
index 831ec0c0b95e..d4577e112d59 100644
--- a/.github/workflows/deployExpensifyHelp.yml
+++ b/.github/workflows/deployExpensifyHelp.yml
@@ -46,6 +46,7 @@ jobs:
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca
id: deploy
+ if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork)
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index 4d6597334447..91e244a0ed7c 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -157,6 +157,8 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_PRODUCTION }}
+
- name: Build staging desktop app
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
@@ -168,6 +170,7 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_STAGING }}
iOS:
name: Build and deploy iOS
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index 3f02430f3c1f..fc9e75e626d3 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -265,6 +265,7 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_STAGING }}
web:
name: Build and deploy Web
diff --git a/android/app/build.gradle b/android/app/build.gradle
index d687ccfb0cc3..74830269ad45 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 1001045500
- versionName "1.4.55-0"
+ versionCode 1001045501
+ versionName "1.4.55-1"
}
flavorDimensions "default"
diff --git a/assets/images/document-plus.svg b/assets/images/document-plus.svg
new file mode 100644
index 000000000000..cce2e3027cea
--- /dev/null
+++ b/assets/images/document-plus.svg
@@ -0,0 +1,5 @@
+
diff --git a/desktop/main.ts b/desktop/main.ts
index cbc12d9d2608..6e14d661b345 100644
--- a/desktop/main.ts
+++ b/desktop/main.ts
@@ -21,7 +21,7 @@ const {DESKTOP_SHORTCUT_ACCELERATOR, LOCALES} = CONST;
// Setup google api key in process environment, we are setting it this way intentionally. It is required by the
// geolocation api (window.navigator.geolocation.getCurrentPosition) to work on desktop.
// Source: https://github.com/electron/electron/blob/98cd16d336f512406eee3565be1cead86514db7b/docs/api/environment-variables.md#google_api_key
-process.env.GOOGLE_API_KEY = CONFIG.GOOGLE_GEOLOCATION_API_KEY;
+process.env.GOOGLE_API_KEY = CONFIG.GCP_GEOLOCATION_API_KEY;
app.setName('New Expensify');
diff --git a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md
index c7ae49e02292..096a3d1527be 100644
--- a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md
+++ b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md
@@ -12,7 +12,7 @@ For a quick snapshot of how Expensify Chat works, and New Expensify in general,
# What’s Expensify Chat?
-Expensify Chat is an instant messaging and payment platform. You can manage all your payments, wether for business or personal, and discuss the transactions themselves.
+Expensify Chat is an instant messaging and payment platform. You can manage all your payments, whether for business or personal, and discuss the transactions themselves.
With Expensify Chat, you can start a conversation about that missing receipt your employee forgot to submit or chat about splitting that electric bill with your roommates. Expensify makes sending and receiving money as easy as sending and receiving messages. Chat with anyone directly, in groups, or in rooms.
diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg
index 2c5350cec2aa..c9b8286cf50f 100644
Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ
diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg
index bae3cd9f3e21..18fbfec9390f 100644
Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 1ea2673b92ee..22de50aee5a3 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.55.0
+ 1.4.55.1
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index cc6c5cf4c86a..61c240540779 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.55.0
+ 1.4.55.1
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 7c90acab4958..a30258647997 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
1.4.55
CFBundleVersion
- 1.4.55.0
+ 1.4.55.1
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index dd2084b238fb..310003ee8adc 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -39,7 +39,7 @@ PODS:
- React-Core
- Expo (50.0.4):
- ExpoModulesCore
- - ExpoImage (1.10.1):
+ - ExpoImage (1.11.0):
- ExpoModulesCore
- SDWebImage (~> 5.17.0)
- SDWebImageAVIFCoder (~> 0.10.1)
@@ -1790,7 +1790,7 @@ SPEC CHECKSUMS:
EXAV: 09a4d87fa6b113fbb0ada3aade6799f78271cb44
EXImageLoader: 55080616b2fe9da19ef8c7f706afd9814e279b6b
Expo: 1e3bcf9dd99de57a636127057f6b488f0609681a
- ExpoImage: 1cdaa65a6a70bb01067e21ad1347ff2d973885f5
+ ExpoImage: 390c524542b258f8173f475c1cc71f016444a7be
ExpoImageManipulator: c1d7cb865eacd620a35659f3da34c70531f10b59
ExpoModulesCore: 96d1751929ad10622773bb729ab28a8423f0dd0c
FBLazyVector: fbc4957d9aa695250b55d879c1d86f79d7e69ab4
@@ -1921,7 +1921,7 @@ SPEC CHECKSUMS:
SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2
VisionCamera: 0a6794d1974aed5d653d0d0cb900493e2583e35a
- Yoga: 13c8ef87792450193e117976337b8527b49e8c03
+ Yoga: e64aa65de36c0832d04e8c7bd614396c77a80047
PODFILE CHECKSUM: a431c146e1501391834a2f299a74093bac53b530
diff --git a/package-lock.json b/package-lock.json
index 61d6a27821cd..2b415ef9f137 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.55-0",
+ "version": "1.4.55-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.55-0",
+ "version": "1.4.55-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -51,10 +51,10 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4e020cfa13ffabde14313c92b341285aeb919f29",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
- "expo-image": "1.10.1",
+ "expo-image": "1.11.0",
"expo-image-manipulator": "11.8.0",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
@@ -23087,9 +23087,9 @@
}
},
"node_modules/classnames": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.4.0.tgz",
- "integrity": "sha512-lWxiIlphgAhTLN657pwU/ofFxsUTOWc2CRIFeoV5st0MGRJHStUnWIUJgDHxjUO/F0mXzGufXIM4Lfu/8h+MpA=="
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz",
+ "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA=="
},
"node_modules/clean-css": {
"version": "5.3.2",
@@ -27370,11 +27370,11 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae",
- "integrity": "sha512-k/SmW3EBR+gxFkJP/59LJsmBKjnKR07XS30yk/GkQ0lIfyYkNmFJ0dWm/S/54ezFweezR7MDaQ3zGc45Mb/U5A==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#4e020cfa13ffabde14313c92b341285aeb919f29",
+ "integrity": "sha512-sx3cIYkmiydNaXRe4kJebPyEje8HfssUbsoB6uW8vvMLwFheCZfkmF9fRMBNLo8BQsfWIstT5TApEhwuWPjqZg==",
"license": "MIT",
"dependencies": {
- "classnames": "2.4.0",
+ "classnames": "2.5.0",
"clipboard": "2.0.11",
"html-entities": "^2.4.0",
"jquery": "3.6.0",
@@ -27383,7 +27383,7 @@
"prop-types": "15.8.1",
"react": "16.12.0",
"react-dom": "16.12.0",
- "semver": "^7.5.2",
+ "semver": "^7.6.0",
"simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5",
"ua-parser-js": "^1.0.37",
"underscore": "1.13.6"
@@ -27514,8 +27514,9 @@
}
},
"node_modules/expo-image": {
- "version": "1.10.1",
- "license": "MIT",
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-1.11.0.tgz",
+ "integrity": "sha512-CfkSGWIGidxxqzErke16bCS9aefS04qvgjk+T9Nc34LAb3ysBAqCv5hoCnvylHOvi/7jTCC/ouLm5oLLqkDSRQ==",
"dependencies": {
"@react-native/assets-registry": "~0.73.1"
},
diff --git a/package.json b/package.json
index 92a6b9cde5e1..13dbf58fbc52 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.55-0",
+ "version": "1.4.55-1",
"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.",
@@ -102,10 +102,10 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4e020cfa13ffabde14313c92b341285aeb919f29",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
- "expo-image": "1.10.1",
+ "expo-image": "1.11.0",
"expo-image-manipulator": "11.8.0",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
diff --git a/patches/expo-image+1.10.1+001+applyFill.patch b/patches/expo-image+1.10.1+001+applyFill.patch
deleted file mode 100644
index 5f168040d04d..000000000000
--- a/patches/expo-image+1.10.1+001+applyFill.patch
+++ /dev/null
@@ -1,112 +0,0 @@
-diff --git a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-index 619daf2..b58a0df 100644
---- a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-+++ b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-@@ -1,5 +1,9 @@
- package com.caverock.androidsvg
-
-+import com.caverock.androidsvg.SVG.SPECIFIED_COLOR
-+import com.caverock.androidsvg.SVG.SPECIFIED_FILL
-+import com.caverock.androidsvg.SVG.SvgElementBase
-+
- internal fun replaceColor(paint: SVG.SvgPaint?, newColor: Int) {
- if (paint is SVG.Colour && paint !== SVG.Colour.TRANSPARENT) {
- paint.colour = newColor
-@@ -19,15 +23,83 @@ internal fun replaceStyles(style: SVG.Style?, newColor: Int) {
- replaceColor(style.viewportFill, newColor)
- }
-
--internal fun applyTintColor(element: SVG.SvgObject, newColor: Int) {
-- if (element is SVG.SvgElementBase) {
-+internal fun hasStyle(element: SvgElementBase): Boolean {
-+ if (element.style == null && element.baseStyle == null) {
-+ return false
-+ }
-+
-+ val style = element.style
-+ val hasColorInStyle = style != null &&
-+ (
-+ style.color != null || style.fill != null || style.stroke != null ||
-+ style.stroke != null || style.stopColor != null || style.solidColor != null
-+ )
-+
-+ if (hasColorInStyle) {
-+ return true
-+ }
-+
-+ val baseStyle = element.baseStyle ?: return false
-+ return baseStyle.color != null || baseStyle.fill != null || baseStyle.stroke != null ||
-+ baseStyle.viewportFill != null || baseStyle.stopColor != null || baseStyle.solidColor != null
-+}
-+
-+internal fun defineStyles(element: SvgElementBase, newColor: Int, hasStyle: Boolean) {
-+ if (hasStyle) {
-+ return
-+ }
-+
-+ val style = if (element.style != null) {
-+ element.style
-+ } else {
-+ SVG.Style().also {
-+ element.style = it
-+ }
-+ }
-+
-+ val color = SVG.Colour(newColor)
-+ when (element) {
-+ is SVG.Path,
-+ is SVG.Circle,
-+ is SVG.Ellipse,
-+ is SVG.Rect,
-+ is SVG.SolidColor,
-+ is SVG.Line,
-+ is SVG.Polygon,
-+ is SVG.PolyLine -> {
-+ style.apply {
-+ fill = color
-+
-+ specifiedFlags = SPECIFIED_FILL
-+ }
-+ }
-+
-+ is SVG.TextPath -> {
-+ style.apply {
-+ this.color = color
-+
-+ specifiedFlags = SPECIFIED_COLOR
-+ }
-+ }
-+ }
-+}
-+
-+internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefinesStyle: Boolean) {
-+ val definesStyle = if (element is SvgElementBase) {
-+ val hasStyle = parentDefinesStyle || hasStyle(element)
-+
- replaceStyles(element.baseStyle, newColor)
- replaceStyles(element.style, newColor)
-+ defineStyles(element, newColor, hasStyle)
-+
-+ hasStyle
-+ } else {
-+ parentDefinesStyle
- }
-
- if (element is SVG.SvgContainer) {
- for (child in element.children) {
-- applyTintColor(child, newColor)
-+ applyTintColor(child, newColor, definesStyle)
- }
- }
- }
-@@ -36,8 +108,9 @@ fun applyTintColor(svg: SVG, newColor: Int) {
- val root = svg.rootElement
-
- replaceStyles(root.style, newColor)
-+ val hasStyle = hasStyle(root)
-
- for (child in root.children) {
-- applyTintColor(child, newColor)
-+ applyTintColor(child, newColor, hasStyle)
- }
- }
diff --git a/patches/expo-image+1.10.1+002+TintFix.patch b/patches/expo-image+1.10.1+002+TintFix.patch
deleted file mode 100644
index 92b56c039b43..000000000000
--- a/patches/expo-image+1.10.1+002+TintFix.patch
+++ /dev/null
@@ -1,38 +0,0 @@
-diff --git a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-index b58a0df..6b8da3c 100644
---- a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-+++ b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt
-@@ -107,6 +107,7 @@ internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefines
- fun applyTintColor(svg: SVG, newColor: Int) {
- val root = svg.rootElement
-
-+ replaceStyles(root.baseStyle, newColor)
- replaceStyles(root.style, newColor)
- val hasStyle = hasStyle(root)
-
-diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt
-index 602b570..8becf72 100644
---- a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt
-+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt
-@@ -31,6 +31,7 @@ import expo.modules.image.records.ImageLoadEvent
- import expo.modules.image.records.ImageProgressEvent
- import expo.modules.image.records.ImageTransition
- import expo.modules.image.records.SourceMap
-+import expo.modules.image.svg.SVGPictureDrawable
- import expo.modules.kotlin.AppContext
- import expo.modules.kotlin.tracing.beginAsyncTraceBlock
- import expo.modules.kotlin.tracing.trace
-@@ -127,7 +128,12 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(
- internal var tintColor: Int? = null
- set(value) {
- field = value
-- activeView.setTintColor(value)
-+ // To apply the tint color to the SVG, we need to recreate the drawable.
-+ if (activeView.drawable is SVGPictureDrawable) {
-+ shouldRerender = true
-+ } else {
-+ activeView.setTintColor(value)
-+ }
- }
-
- internal var isFocusableProp: Boolean = false
diff --git a/src/CONFIG.ts b/src/CONFIG.ts
index 37da65f0c305..76ea18d37d5f 100644
--- a/src/CONFIG.ts
+++ b/src/CONFIG.ts
@@ -21,7 +21,7 @@ const secureExpensifyUrl = Url.addTrailingForwardSlash(get(Config, 'SECURE_EXPEN
const useNgrok = get(Config, 'USE_NGROK', 'false') === 'true';
const useWebProxy = get(Config, 'USE_WEB_PROXY', 'true') === 'true';
const expensifyComWithProxy = getPlatform() === 'web' && useWebProxy ? '/' : expensifyURL;
-const googleGeolocationAPIKey = get(Config, 'GOOGLE_GEOLOCATION_API_KEY', 'AIzaSyBqg6bMvQU7cPWDKhhzpYqJrTEnSorpiLI');
+const googleGeolocationAPIKey = get(Config, 'GCP_GEOLOCATION_API_KEY', '');
// Throw errors on dev if config variables are not set correctly
if (ENVIRONMENT === CONST.ENVIRONMENT.DEV) {
@@ -94,5 +94,5 @@ export default {
WEB_CLIENT_ID: '921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com',
IOS_CLIENT_ID: '921154746561-s3uqn2oe4m85tufi6mqflbfbuajrm2i3.apps.googleusercontent.com',
},
- GOOGLE_GEOLOCATION_API_KEY: googleGeolocationAPIKey,
+ GCP_GEOLOCATION_API_KEY: googleGeolocationAPIKey,
} as const;
diff --git a/src/CONST.ts b/src/CONST.ts
index bb191ac5e028..3c53f083abac 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -332,6 +332,7 @@ const CONST = {
BETA_COMMENT_LINKING: 'commentLinking',
VIOLATIONS: 'violations',
REPORT_FIELDS: 'reportFields',
+ TRACK_EXPENSE: 'trackExpense',
P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests',
WORKFLOWS_DELAYED_SUBMISSION: 'workflowsDelayedSubmission',
},
@@ -358,6 +359,7 @@ const CONST = {
NOT_INSTALLED: 'not-installed',
},
TAX_RATES: {
+ CUSTOM_NAME_MAX_LENGTH: 8,
NAME_MAX_LENGTH: 50,
},
PLATFORM: {
@@ -513,7 +515,7 @@ const CONST = {
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];
+ return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.EUR];
},
EXAMPLE_PHONE_NUMBER: '+15005550006',
CONCIERGE_CHAT_NAME: 'Concierge',
@@ -1344,6 +1346,7 @@ const CONST = {
SEND: 'send',
SPLIT: 'split',
REQUEST: 'request',
+ TRACK_EXPENSE: 'track-expense',
},
REQUEST_TYPE: {
DISTANCE: 'distance',
@@ -1358,6 +1361,7 @@ const CONST = {
CANCEL: 'cancel',
DELETE: 'delete',
APPROVE: 'approve',
+ TRACK: 'track',
},
AMOUNT_MAX_LENGTH: 10,
RECEIPT_STATE: {
@@ -1472,6 +1476,15 @@ const CONST = {
MAKE_MEMBER: 'makeMember',
MAKE_ADMIN: 'makeAdmin',
},
+ MORE_FEATURES: {
+ ARE_CATEGORIES_ENABLED: 'areCategoriesEnabled',
+ ARE_TAGS_ENABLED: 'areTagsEnabled',
+ ARE_DISTANCE_RATES_ENABLED: 'areDistanceRatesEnabled',
+ ARE_WORKFLOWS_ENABLED: 'areWorkflowsEnabled',
+ ARE_REPORTFIELDS_ENABLED: 'areReportFieldsEnabled',
+ ARE_CONNECTIONS_ENABLED: 'areConnectionsEnabled',
+ ARE_TAXES_ENABLED: 'tax',
+ },
CATEGORIES_BULK_ACTION_TYPES: {
DELETE: 'delete',
DISABLE: 'disable',
@@ -1487,6 +1500,21 @@ const CONST = {
DISABLE: 'disable',
ENABLE: 'enable',
},
+ TAX_RATES_BULK_ACTION_TYPES: {
+ DELETE: 'delete',
+ DISABLE: 'disable',
+ ENABLE: 'enable',
+ },
+ COLLECTION_KEYS: {
+ DESCRIPTION: 'description',
+ REIMBURSER_EMAIL: 'reimburserEmail',
+ REIMBURSEMENT_CHOICE: 'reimbursementChoice',
+ APPROVAL_MODE: 'approvalMode',
+ AUTOREPORTING: 'autoReporting',
+ AUTOREPORTING_FREQUENCY: 'autoReportingFrequency',
+ AUTOREPORTING_OFFSET: 'autoReportingOffset',
+ GENERAL_SETTINGS: 'generalSettings',
+ },
},
CUSTOM_UNITS: {
@@ -3390,6 +3418,9 @@ const CONST = {
REPORT_FIELD_TITLE_FIELD_ID: 'text_title',
+ MOBILE_PAGINATION_SIZE: 15,
+ WEB_PAGINATION_SIZE: 50,
+
/** Dimensions for illustration shown in Confirmation Modal */
CONFIRM_CONTENT_SVG_SIZE: {
HEIGHT: 220,
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 99973935b20a..d3fab1b9fcde 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -16,9 +16,6 @@ const ONYXKEYS = {
/** Holds the reportID for the report between the user and their account manager */
ACCOUNT_MANAGER_REPORT_ID: 'accountManagerReportID',
- /** Boolean flag only true when first set */
- NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'isFirstTimeNewExpensifyUser',
-
/** Holds an array of client IDs which is used for multi-tabs on web in order to know
* which tab is the leader, and which ones are the followers */
ACTIVE_CLIENTS: 'activeClients',
@@ -106,27 +103,52 @@ const ONYXKEYS = {
STASHED_SESSION: 'stashedSession',
BETAS: 'betas',
- /** NVP keys
+ /** NVP keys */
+
+ /** Boolean flag only true when first set */
+ NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'nvp_isFirstTimeNewExpensifyUser',
+
/** Contains the user preference for the LHN priority mode */
NVP_PRIORITY_MODE: 'nvp_priorityMode',
/** Contains the users's block expiration (if they have one) */
- NVP_BLOCKED_FROM_CONCIERGE: 'private_blockedFromConcierge',
+ NVP_BLOCKED_FROM_CONCIERGE: 'nvp_private_blockedFromConcierge',
/** A unique identifier that each user has that's used to send notifications */
- NVP_PRIVATE_PUSH_NOTIFICATION_ID: 'private_pushNotificationID',
+ NVP_PRIVATE_PUSH_NOTIFICATION_ID: 'nvp_private_pushNotificationID',
/** The NVP with the last payment method used per policy */
- NVP_LAST_PAYMENT_METHOD: 'nvp_lastPaymentMethod',
+ NVP_LAST_PAYMENT_METHOD: 'nvp_private_lastPaymentMethod',
/** 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',
+ NVP_HAS_DISMISSED_IDLE_PANEL: 'nvp_hasDismissedIdlePanel',
/** This NVP contains the choice that the user made on the engagement modal */
- NVP_INTRO_SELECTED: 'introSelected',
+ NVP_INTRO_SELECTED: 'nvp_introSelected',
+
+ /** This NVP contains the active policyID */
+ NVP_ACTIVE_POLICY_ID: 'nvp_expensify_activePolicyID',
+
+ /** This NVP contains the referral banners the user dismissed */
+ NVP_DISMISSED_REFERRAL_BANNERS: 'nvp_dismissedReferralBanners',
+
+ /** Indicates which locale should be used */
+ NVP_PREFERRED_LOCALE: 'nvp_preferredLocale',
+
+ /** Whether the user has tried focus mode yet */
+ NVP_TRY_FOCUS_MODE: 'nvp_tryFocusMode',
+
+ /** Whether the user has been shown the hold educational interstitial yet */
+ NVP_HOLD_USE_EXPLAINED: 'holdUseExplained',
+
+ /** Store preferred skintone for emoji */
+ PREFERRED_EMOJI_SKIN_TONE: 'nvp_expensify_preferredEmojiSkinTone',
+
+ /** Store frequently used emojis for this user */
+ FREQUENTLY_USED_EMOJIS: 'nvp_expensify_frequentlyUsedEmojis',
/** The NVP with the last distance rate used per policy */
NVP_LAST_SELECTED_DISTANCE_RATES: 'lastSelectedDistanceRates',
@@ -153,9 +175,6 @@ const ONYXKEYS = {
ONFIDO_TOKEN: 'onfidoToken',
ONFIDO_APPLICANT_ID: 'onfidoApplicantID',
- /** Indicates which locale should be used */
- NVP_PREFERRED_LOCALE: 'preferredLocale',
-
/** User's Expensify Wallet */
USER_WALLET: 'userWallet',
@@ -177,12 +196,6 @@ const ONYXKEYS = {
/** The user's cash card and imported cards (including the Expensify Card) */
CARD_LIST: 'cardList',
- /** Whether the user has tried focus mode yet */
- NVP_TRY_FOCUS_MODE: 'tryFocusMode',
-
- /** Whether the user has been shown the hold educational interstitial yet */
- NVP_HOLD_USE_EXPLAINED: 'holdUseExplained',
-
/** Boolean flag used to display the focus mode notification */
FOCUS_MODE_NOTIFICATION: 'focusModeNotification',
@@ -195,12 +208,6 @@ const ONYXKEYS = {
/** Stores information about the active reimbursement account being set up */
REIMBURSEMENT_ACCOUNT: 'reimbursementAccount',
- /** Store preferred skintone for emoji */
- PREFERRED_EMOJI_SKIN_TONE: 'preferredEmojiSkinTone',
-
- /** Store frequently used emojis for this user */
- FREQUENTLY_USED_EMOJIS: 'frequentlyUsedEmojis',
-
/** Stores Workspace ID that will be tied to reimbursement account during setup */
REIMBURSEMENT_ACCOUNT_WORKSPACE_ID: 'reimbursementAccountWorkspaceID',
@@ -294,8 +301,8 @@ const ONYXKEYS = {
POLICY_CATEGORIES: 'policyCategories_',
POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_',
POLICY_TAGS: 'policyTags_',
- POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_',
- POLICY_REPORT_FIELDS: 'policyReportFields_',
+ POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_',
+ OLD_POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_',
WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_',
WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_',
REPORT: 'report_',
@@ -346,6 +353,8 @@ const ONYXKEYS = {
WORKSPACE_TAX_CUSTOM_NAME_DRAFT: 'workspaceTaxCustomNameDraft',
POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm',
POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft',
+ POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm',
+ POLICY_DISTANCE_RATE_EDIT_FORM_DRAFT: 'policyDistanceRateEditFormDraft',
CLOSE_ACCOUNT_FORM: 'closeAccount',
CLOSE_ACCOUNT_FORM_DRAFT: 'closeAccountDraft',
PROFILE_SETTINGS_FORM: 'profileSettingsForm',
@@ -420,6 +429,10 @@ const ONYXKEYS = {
POLICY_TAG_NAME_FORM_DRAFT: 'policyTagNameFormDraft',
WORKSPACE_NEW_TAX_FORM: 'workspaceNewTaxForm',
WORKSPACE_NEW_TAX_FORM_DRAFT: 'workspaceNewTaxFormDraft',
+ WORKSPACE_TAX_NAME_FORM: 'workspaceTaxNameForm',
+ WORKSPACE_TAX_NAME_FORM_DRAFT: 'workspaceTaxNameFormDraft',
+ WORKSPACE_TAX_VALUE_FORM: 'workspaceTaxValueForm',
+ WORKSPACE_TAX_VALUE_FORM_DRAFT: 'workspaceTaxValueFormDraft',
},
} as const;
@@ -471,6 +484,9 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.POLICY_TAG_NAME_FORM]: FormTypes.PolicyTagNameForm;
[ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm;
[ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm;
+ [ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM]: FormTypes.PolicyDistanceRateEditForm;
+ [ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm;
+ [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm;
};
type OnyxFormDraftValuesMapping = {
@@ -486,7 +502,6 @@ type OnyxCollectionValuesMapping = {
[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.PolicyReportFields;
[ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers;
[ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs;
[ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string;
@@ -506,6 +521,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations;
[ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT]: OnyxTypes.Transaction;
[ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags;
+ [ONYXKEYS.COLLECTION.OLD_POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags;
[ONYXKEYS.COLLECTION.SELECTED_TAB]: string;
[ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string;
[ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStep;
@@ -561,6 +577,8 @@ type OnyxValuesMapping = {
[ONYXKEYS.ONFIDO_TOKEN]: string;
[ONYXKEYS.ONFIDO_APPLICANT_ID]: string;
[ONYXKEYS.NVP_PREFERRED_LOCALE]: OnyxTypes.Locale;
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: string;
+ [ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners;
[ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet;
[ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido;
[ONYXKEYS.WALLET_ADDITIONAL_DETAILS]: OnyxTypes.WalletAdditionalDetails;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 3db389950e24..af34b8b7e9ea 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -37,7 +37,7 @@ const ROUTES = {
},
PROFILE_AVATAR: {
route: 'a/:accountID/avatar',
- getRoute: (accountID: string) => `a/${accountID}/avatar` as const,
+ getRoute: (accountID: string | number) => `a/${accountID}/avatar` as const,
},
TRANSITION_BETWEEN_APPS: 'transition',
@@ -332,9 +332,9 @@ const ROUTES = {
getUrlWithBackToParam(`create/${iouType}/taxAmount/${transactionID}/${reportID}`, backTo),
},
MONEY_REQUEST_STEP_CATEGORY: {
- route: ':action/:iouType/category/:transactionID/:reportID',
- getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
- getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}`, backTo),
+ route: ':action/:iouType/category/:transactionID/:reportID/:reportActionID?',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '', reportActionID?: string) =>
+ getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo),
},
MONEY_REQUEST_STEP_CURRENCY: {
route: 'create/:iouType/currency/:transactionID/:reportID/:pageIndex?',
@@ -347,9 +347,9 @@ const ROUTES = {
getUrlWithBackToParam(`${action}/${iouType}/date/${transactionID}/${reportID}`, backTo),
},
MONEY_REQUEST_STEP_DESCRIPTION: {
- route: ':action/:iouType/description/:transactionID/:reportID',
- getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
- getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}`, backTo),
+ route: ':action/:iouType/description/:transactionID/:reportID/:reportActionID?',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '', reportActionID?: string) =>
+ getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo),
},
MONEY_REQUEST_STEP_DISTANCE: {
route: 'create/:iouType/distance/:transactionID/:reportID',
@@ -616,6 +616,18 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/taxes/new',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/new` as const,
},
+ WORKSPACE_TAX_EDIT: {
+ route: 'settings/workspaces/:policyID/tax/:taxID',
+ getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}` as const,
+ },
+ WORKSPACE_TAX_NAME: {
+ route: 'settings/workspaces/:policyID/tax/:taxID/name',
+ getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}/name` as const,
+ },
+ WORKSPACE_TAX_VALUE: {
+ route: 'settings/workspaces/:policyID/tax/:taxID/value',
+ getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}/value` as const,
+ },
WORKSPACE_DISTANCE_RATES: {
route: 'settings/workspaces/:policyID/distance-rates',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const,
@@ -625,8 +637,16 @@ const ROUTES = {
getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/new` as const,
},
WORKSPACE_DISTANCE_RATES_SETTINGS: {
- route: 'settings/workspace/:policyID/distance-rates/settings',
- getRoute: (policyID: string) => `settings/workspace/${policyID}/distance-rates/settings` as const,
+ route: 'settings/workspaces/:policyID/distance-rates/settings',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates/settings` as const,
+ },
+ WORKSPACE_DISTANCE_RATE_DETAILS: {
+ route: 'settings/workspaces/:policyID/distance-rates/:rateID',
+ getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}` as const,
+ },
+ WORKSPACE_DISTANCE_RATE_EDIT: {
+ route: 'settings/workspaces/:policyID/distance-rates/:rateID/edit',
+ getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/edit` as const,
},
// Referral program promotion
REFERRAL_DETAILS_MODAL: {
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index cd7bb934247f..9c33c7e63d7d 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -217,6 +217,9 @@ const SCREENS = {
TAGS_EDIT: 'Tags_Edit',
TAG_EDIT: 'Tag_Edit',
TAXES: 'Workspace_Taxes',
+ TAX_EDIT: 'Workspace_Tax_Edit',
+ TAX_NAME: 'Workspace_Tax_Name',
+ TAX_VALUE: 'Workspace_Tax_Value',
TAXES_SETTINGS: 'Workspace_Taxes_Settings',
TAXES_SETTINGS_CUSTOM_TAX_NAME: 'Workspace_Taxes_Settings_CustomTaxName',
TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT: 'Workspace_Taxes_Settings_WorkspaceCurrency',
@@ -243,6 +246,8 @@ const SCREENS = {
DISTANCE_RATES: 'Distance_Rates',
CREATE_DISTANCE_RATE: 'Create_Distance_Rate',
DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings',
+ DISTANCE_RATE_DETAILS: 'Distance_Rate_Details',
+ DISTANCE_RATE_EDIT: 'Distance_Rate_Edit',
},
EDIT_REQUEST: {
diff --git a/src/components/AmountPicker/index.tsx b/src/components/AmountPicker/index.tsx
index 701c75175c02..45e511f24748 100644
--- a/src/components/AmountPicker/index.tsx
+++ b/src/components/AmountPicker/index.tsx
@@ -25,7 +25,8 @@ function AmountPicker({value, description, title, errorText = '', onInputChange,
const updateInput = (updatedValue: string) => {
if (updatedValue !== value) {
- onInputChange?.(updatedValue);
+ // We cast the updatedValue to a number and then back to a string to remove any leading zeros and separating commas
+ onInputChange?.(String(Number(updatedValue)));
}
hidePickerModal();
};
diff --git a/src/components/AvatarSkeleton.tsx b/src/components/AvatarSkeleton.tsx
index a6781448c3ba..273143f76098 100644
--- a/src/components/AvatarSkeleton.tsx
+++ b/src/components/AvatarSkeleton.tsx
@@ -6,12 +6,12 @@ import SkeletonViewContentLoader from './SkeletonViewContentLoader';
function AvatarSkeleton() {
const theme = useTheme();
- const skeletonCircleRadius = variables.componentSizeSmall / 2;
+ const skeletonCircleRadius = variables.sidebarAvatarSize / 2;
return (
diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx
index 16f31b9c7eba..396c10151fbf 100644
--- a/src/components/AvatarWithDisplayName.tsx
+++ b/src/components/AvatarWithDisplayName.tsx
@@ -60,7 +60,7 @@ function AvatarWithDisplayName({
const title = ReportUtils.getReportName(report);
const subtitle = ReportUtils.getChatRoomSubtitle(report);
const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report);
- const isMoneyRequestOrReport = ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report);
+ const isMoneyRequestOrReport = ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report);
const icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy);
const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails);
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails) as PersonalDetails[], false);
diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx
index 8bcda759d26c..e39e940ebf5c 100644
--- a/src/components/AvatarWithImagePicker.tsx
+++ b/src/components/AvatarWithImagePicker.tsx
@@ -287,11 +287,12 @@ function AvatarWithImagePicker({
return (
-
+
diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts
index 798369292958..83100788761f 100644
--- a/src/components/ButtonWithDropdownMenu/types.ts
+++ b/src/components/ButtonWithDropdownMenu/types.ts
@@ -12,6 +12,8 @@ type WorkspaceMemberBulkActionType = DeepValueOf;
+type WorkspaceTaxRatesBulkActionType = DeepValueOf;
+
type DropdownOption = {
value: TValueType;
text: string;
@@ -73,4 +75,4 @@ type ButtonWithDropdownMenuProps = {
wrapperStyle?: StyleProp;
};
-export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps};
+export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps, WorkspaceTaxRatesBulkActionType};
diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx
index 2919debe9cb1..dd169576186e 100644
--- a/src/components/CheckboxWithLabel.tsx
+++ b/src/components/CheckboxWithLabel.tsx
@@ -108,3 +108,5 @@ function CheckboxWithLabel(
CheckboxWithLabel.displayName = 'CheckboxWithLabel';
export default React.forwardRef(CheckboxWithLabel);
+
+export type {CheckboxWithLabelProps};
diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx
index 356fbd3726a3..524c8a3903e0 100644
--- a/src/components/CustomStatusBarAndBackground/index.tsx
+++ b/src/components/CustomStatusBarAndBackground/index.tsx
@@ -114,6 +114,11 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack
[prevIsRootStatusBarEnabled, isRootStatusBarEnabled, statusBarAnimation, statusBarStyle, theme.PAGE_THEMES, theme.appBG, theme.statusBarStyle],
);
+ useEffect(() => {
+ updateStatusBarAppearance({backgroundColor: theme.appBG});
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this to run on first render
+ }, []);
+
useEffect(() => {
didForceUpdateStatusBarRef.current = false;
}, [isRootStatusBarEnabled]);
diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js
index e138ca4d4194..6bceaf570ccc 100644
--- a/src/components/EmojiPicker/EmojiPicker.js
+++ b/src/components/EmojiPicker/EmojiPicker.js
@@ -7,6 +7,7 @@ import withViewportOffsetTop from '@components/withViewportOffsetTop';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as Browser from '@libs/Browser';
import calculateAnchorPosition from '@libs/calculateAnchorPosition';
import CONST from '@src/CONST';
import EmojiPickerMenu from './EmojiPickerMenu';
@@ -169,6 +170,7 @@ const EmojiPicker = forwardRef((props, ref) => {
// emojis. The best alternative is to set it to 1ms so it just "pops" in and out
return (
{emojiCode};
+}
+
+EmojiWithTooltip.displayName = 'EmojiWithTooltip';
+
+export default EmojiWithTooltip;
diff --git a/src/components/EmojiWithTooltip/index.tsx b/src/components/EmojiWithTooltip/index.tsx
new file mode 100644
index 000000000000..32103544b3aa
--- /dev/null
+++ b/src/components/EmojiWithTooltip/index.tsx
@@ -0,0 +1,42 @@
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import Text from '@components/Text';
+import Tooltip from '@components/Tooltip';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import type EmojiWithTooltipProps from './types';
+
+function EmojiWithTooltip({emojiCode, style = {}}: EmojiWithTooltipProps) {
+ const {preferredLocale} = useLocalize();
+ const styles = useThemeStyles();
+ const emoji = EmojiUtils.findEmojiByCode(emojiCode);
+ const emojiName = EmojiUtils.getEmojiName(emoji, preferredLocale);
+
+ const emojiTooltipContent = useCallback(
+ () => (
+
+
+
+ {emojiCode}
+
+
+ {`:${emojiName}:`}
+
+ ),
+ [emojiCode, emojiName, styles.alignItemsCenter, styles.ph2, styles.flexRow, styles.emojiTooltipWrapper, styles.fontColorReactionLabel, styles.onlyEmojisText, styles.textMicro],
+ );
+
+ return (
+
+ {emojiCode}
+
+ );
+}
+
+EmojiWithTooltip.displayName = 'EmojiWithTooltip';
+
+export default EmojiWithTooltip;
diff --git a/src/components/EmojiWithTooltip/types.ts b/src/components/EmojiWithTooltip/types.ts
new file mode 100644
index 000000000000..d13c389c0568
--- /dev/null
+++ b/src/components/EmojiWithTooltip/types.ts
@@ -0,0 +1,8 @@
+import type {StyleProp, TextStyle} from 'react-native';
+
+type EmojiWithTooltipProps = {
+ emojiCode: string;
+ style?: StyleProp;
+};
+
+export default EmojiWithTooltipProps;
diff --git a/src/components/FlatList/MVCPFlatList.js b/src/components/FlatList/MVCPFlatList.js
index 7fa8b364fb0f..656a0ed7f00e 100644
--- a/src/components/FlatList/MVCPFlatList.js
+++ b/src/components/FlatList/MVCPFlatList.js
@@ -44,15 +44,15 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont
if (scrollRef.current == null) {
return 0;
}
- return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop;
+ return horizontal ? scrollRef.current?.getScrollableNode()?.scrollLeft : scrollRef.current?.getScrollableNode()?.scrollTop;
}, [horizontal]);
- const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []);
+ const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode()?.childNodes[0], []);
const scrollToOffset = React.useCallback(
(offset, animated) => {
const behavior = animated ? 'smooth' : 'instant';
- scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior});
+ scrollRef.current?.getScrollableNode()?.scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior});
},
[horizontal],
);
@@ -68,12 +68,13 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont
}
const scrollOffset = getScrollOffset();
+ lastScrollOffsetRef.current = scrollOffset;
const contentViewLength = contentView.childNodes.length;
for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) {
const subview = contentView.childNodes[i];
const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop;
- if (subviewOffset > scrollOffset || i === contentViewLength - 1) {
+ if (subviewOffset > scrollOffset) {
prevFirstVisibleOffsetRef.current = subviewOffset;
firstVisibleViewRef.current = subview;
break;
@@ -126,6 +127,7 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont
}
adjustForMaintainVisibleContentPosition();
+ prepareForMaintainVisibleContentPosition();
});
});
mutationObserver.observe(contentView, {
@@ -135,7 +137,7 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont
});
mutationObserverRef.current = mutationObserver;
- }, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]);
+ }, [adjustForMaintainVisibleContentPosition, prepareForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]);
React.useEffect(() => {
if (!isListRenderedRef.current) {
@@ -172,13 +174,11 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont
const onScrollInternal = React.useCallback(
(ev) => {
- lastScrollOffsetRef.current = getScrollOffset();
-
prepareForMaintainVisibleContentPosition();
onScroll?.(ev);
},
- [getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll],
+ [prepareForMaintainVisibleContentPosition, onScroll],
);
return (
diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx
index 5c2488ca144a..902a96b1bcaf 100644
--- a/src/components/Form/FormWrapper.tsx
+++ b/src/components/Form/FormWrapper.tsx
@@ -110,7 +110,7 @@ function FormWrapper({
buttonText={submitButtonText}
isAlertVisible={((!isEmptyObject(errors) || !isEmptyObject(formState?.errorFields)) && !shouldHideFixErrorsAlert) || !!errorMessage}
isLoading={!!formState?.isLoading}
- message={typeof errorMessage === 'string' && isEmptyObject(formState?.errorFields) ? errorMessage : undefined}
+ message={isEmptyObject(formState?.errorFields) ? errorMessage : undefined}
onSubmit={onSubmit}
footerContent={footerContent}
onFixTheErrorsLinkPressed={onFixTheErrorsLinkPressed}
diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
index bd4f72c63ec3..af04c11de41e 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx
@@ -70,6 +70,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim
mixedUAStyles: {whiteSpace: 'pre'},
contentModel: HTMLContentModel.block,
}),
+ emoji: HTMLElementModel.fromCustomModel({tagName: 'emoji', contentModel: HTMLContentModel.textual}),
}),
[styles.colorMuted, styles.formError, styles.mb0, styles.textLabelSupporting, styles.lh16],
);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx
new file mode 100644
index 000000000000..6582e99477a8
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html';
+import EmojiWithTooltip from '@components/EmojiWithTooltip';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+function EmojiRenderer({tnode}: CustomRendererProps) {
+ const styles = useThemeStyles();
+ const style = 'islarge' in tnode.attributes ? styles.onlyEmojisText : {};
+ return (
+
+ );
+}
+
+EmojiRenderer.displayName = 'EmojiRenderer';
+
+export default EmojiRenderer;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
index 1914bcf4b5ff..fdd0c89ec5a0 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts
@@ -2,6 +2,7 @@ import type {CustomTagRendererRecord} from 'react-native-render-html';
import AnchorRenderer from './AnchorRenderer';
import CodeRenderer from './CodeRenderer';
import EditedRenderer from './EditedRenderer';
+import EmojiRenderer from './EmojiRenderer';
import ImageRenderer from './ImageRenderer';
import MentionHereRenderer from './MentionHereRenderer';
import MentionUserRenderer from './MentionUserRenderer';
@@ -25,6 +26,7 @@ const HTMLEngineProviderComponentList: CustomTagRendererRecord = {
/* eslint-disable @typescript-eslint/naming-convention */
'mention-user': MentionUserRenderer,
'mention-here': MentionHereRenderer,
+ emoji: EmojiRenderer,
'next-step-email': NextStepEmailRenderer,
/* eslint-enable @typescript-eslint/naming-convention */
};
diff --git a/src/components/HoldMenuSectionList.tsx b/src/components/HoldMenuSectionList.tsx
index aa5dd75ce159..4ffdfa1bd60e 100644
--- a/src/components/HoldMenuSectionList.tsx
+++ b/src/components/HoldMenuSectionList.tsx
@@ -59,12 +59,7 @@ function HoldMenuSectionList() {
/>
{translate(section.titleTranslationKey)}
-
- {translate(section.descriptionTranslationKey)}
-
+ {translate(section.descriptionTranslationKey)}
))}
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 73a091815460..7116ba2aab67 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -42,6 +42,7 @@ import Concierge from '@assets/images/concierge.svg';
import Connect from '@assets/images/connect.svg';
import Copy from '@assets/images/copy.svg';
import CreditCard from '@assets/images/creditcard.svg';
+import DocumentPlus from '@assets/images/document-plus.svg';
import DocumentSlash from '@assets/images/document-slash.svg';
import Document from '@assets/images/document.svg';
import DotIndicatorUnfilled from '@assets/images/dot-indicator-unfilled.svg';
@@ -314,4 +315,5 @@ export {
ChatBubbleUnread,
ChatBubbleReply,
Lightbulb,
+ DocumentPlus,
};
diff --git a/src/components/InlineCodeBlock/getCurrentData.ts b/src/components/InlineCodeBlock/getCurrentData.ts
new file mode 100644
index 000000000000..591ec74c885d
--- /dev/null
+++ b/src/components/InlineCodeBlock/getCurrentData.ts
@@ -0,0 +1,11 @@
+import type {TDefaultRendererProps} from 'react-native-render-html';
+import type {TTextOrTPhrasing} from './types';
+
+// Create a temporary solution to display when there are emojis in the inline code block
+// We can remove this after https://github.com/Expensify/App/issues/14676 is fixed
+export default function getCurrentData(defaultRendererProps: TDefaultRendererProps): string {
+ if ('data' in defaultRendererProps.tnode) {
+ return defaultRendererProps.tnode.data;
+ }
+ return defaultRendererProps.tnode.children.map((child) => ('data' in child ? child.data : '')).join('');
+}
diff --git a/src/components/InlineCodeBlock/index.native.tsx b/src/components/InlineCodeBlock/index.native.tsx
index 85d02b7239ca..1c8a1bea4312 100644
--- a/src/components/InlineCodeBlock/index.native.tsx
+++ b/src/components/InlineCodeBlock/index.native.tsx
@@ -1,11 +1,13 @@
import React from 'react';
import useThemeStyles from '@hooks/useThemeStyles';
+import getCurrentData from './getCurrentData';
import type InlineCodeBlockProps from './types';
import type {TTextOrTPhrasing} from './types';
import WrappedText from './WrappedText';
function InlineCodeBlock({TDefaultRenderer, defaultRendererProps, textStyle, boxModelStyle}: InlineCodeBlockProps) {
const styles = useThemeStyles();
+ const data = getCurrentData(defaultRendererProps);
return (
({TDefaultRenderer,
textStyles={textStyle}
wordStyles={[boxModelStyle, styles.codeWordStyle]}
>
- {'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data}
+ {data}
);
diff --git a/src/components/InlineCodeBlock/index.tsx b/src/components/InlineCodeBlock/index.tsx
index 593a08aaad5e..26a4e8b7a31f 100644
--- a/src/components/InlineCodeBlock/index.tsx
+++ b/src/components/InlineCodeBlock/index.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import {StyleSheet} from 'react-native';
import Text from '@components/Text';
+import getCurrentData from './getCurrentData';
import type InlineCodeBlockProps from './types';
import type {TTextOrTPhrasing} from './types';
@@ -8,12 +9,14 @@ function InlineCodeBlock({TDefaultRenderer,
const flattenTextStyle = StyleSheet.flatten(textStyle);
const {textDecorationLine, ...textStyles} = flattenTextStyle;
+ const data = getCurrentData(defaultRendererProps);
+
return (
- {'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data}
+ {data}
);
}
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
index 0549e19c2eb4..9ee465369be1 100644
--- a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
+++ b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
@@ -1,23 +1,35 @@
import type {ForwardedRef} from 'react';
-import React, {forwardRef} from 'react';
-import type {FlatListProps} from 'react-native';
+import React, {forwardRef, useMemo} from 'react';
+import type {FlatListProps, ScrollViewProps} from 'react-native';
import FlatList from '@components/FlatList';
-const WINDOW_SIZE = 15;
+type BaseInvertedFlatListProps = FlatListProps & {
+ shouldEnableAutoScrollToTopThreshold?: boolean;
+};
+
const AUTOSCROLL_TO_TOP_THRESHOLD = 128;
-const maintainVisibleContentPosition = {
- minIndexForVisible: 0,
- autoscrollToTopThreshold: AUTOSCROLL_TO_TOP_THRESHOLD,
-};
+function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: ForwardedRef) {
+ const {shouldEnableAutoScrollToTopThreshold, ...rest} = props;
+
+ const maintainVisibleContentPosition = useMemo(() => {
+ const config: ScrollViewProps['maintainVisibleContentPosition'] = {
+ // This needs to be 1 to avoid using loading views as anchors.
+ minIndexForVisible: 1,
+ };
+
+ if (shouldEnableAutoScrollToTopThreshold) {
+ config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD;
+ }
+
+ return config;
+ }, [shouldEnableAutoScrollToTopThreshold]);
-function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef) {
return (
@@ -27,3 +39,5 @@ function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef = FlatListProps & {
+ shouldEnableAutoScrollToTopThreshold?: boolean;
+};
+
// This is adapted from https://codesandbox.io/s/react-native-dsyse
// It's a HACK alert since FlatList has inverted scrolling on web
-function InvertedFlatList({onScroll: onScrollProp = () => {}, ...props}: FlatListProps, ref: ForwardedRef) {
+function InvertedFlatList({onScroll: onScrollProp = () => {}, ...props}: InvertedFlatListProps, ref: ForwardedRef) {
const lastScrollEvent = useRef(null);
const scrollEndTimeout = useRef(null);
const updateInProgress = useRef(false);
diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx
index 923337ba9ada..5065d1cc7c13 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHN.tsx
@@ -54,14 +54,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti
const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
const shouldShowGreenDotIndicator = !hasBrickError && ReportUtils.requiresAttentionFromCurrentUser(optionItem, optionItem.parentReportAction);
-
- const isHidden = optionItem.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
-
- const shouldOverrideHidden = hasBrickError || isFocused || optionItem.isPinned;
- if (isHidden && !shouldOverrideHidden) {
- return null;
- }
-
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];
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx
index 0db8e581e23e..121390d808b5 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx
@@ -37,7 +37,7 @@ function OptionRowLHNData({
const optionItemRef = useRef();
- const hasViolations = canUseViolations && ReportUtils.doesTransactionThreadHaveViolations(fullReport, transactionViolations, parentReportAction ?? null);
+ const shouldDisplayViolations = canUseViolations && ReportUtils.shouldDisplayTransactionThreadViolations(fullReport, transactionViolations, parentReportAction ?? null);
const optionItem = useMemo(() => {
// Note: ideally we'd have this as a dependent selector in onyx!
@@ -48,7 +48,7 @@ function OptionRowLHNData({
preferredLocale: preferredLocale ?? CONST.LOCALES.DEFAULT,
policy,
parentReportAction,
- hasViolations: !!hasViolations,
+ hasViolations: !!shouldDisplayViolations,
});
if (deepEqual(item, optionItemRef.current)) {
return optionItemRef.current;
diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx
index 56852a8e2ea1..86a52c2baf6c 100644
--- a/src/components/Lightbox/index.tsx
+++ b/src/components/Lightbox/index.tsx
@@ -112,7 +112,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
return;
}
- setContentSize({width: width * PixelRatio.get(), height: height * PixelRatio.get()});
+ setContentSize({width, height});
},
[contentSize, setContentSize],
);
diff --git a/src/components/MapView/responder/index.android.ts b/src/components/MapView/responder/index.android.ts
deleted file mode 100644
index a0fce71d8ef5..000000000000
--- a/src/components/MapView/responder/index.android.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import {PanResponder} from 'react-native';
-
-const responder = PanResponder.create({
- onStartShouldSetPanResponder: () => true,
- onPanResponderTerminationRequest: () => false,
-});
-
-export default responder;
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
index 71c0fe47ffca..76f4b251ec83 100644
--- a/src/components/Modal/index.tsx
+++ b/src/components/Modal/index.tsx
@@ -1,4 +1,4 @@
-import React, {useState} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import StatusBar from '@libs/StatusBar';
@@ -6,7 +6,7 @@ import CONST from '@src/CONST';
import BaseModal from './BaseModal';
import type BaseModalProps from './types';
-function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, ...rest}: BaseModalProps) {
+function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, shouldHandleNavigationBack, ...rest}: BaseModalProps) {
const theme = useTheme();
const StyleUtils = useStyleUtils();
const [previousStatusBarColor, setPreviousStatusBarColor] = useState();
@@ -22,8 +22,15 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
const hideModal = () => {
setStatusBarColor(previousStatusBarColor);
onModalHide();
+ if (window.history.state.shouldGoBack) {
+ window.history.back();
+ }
};
+ const handlePopStateRef = useRef(() => {
+ rest.onClose();
+ });
+
const showModal = () => {
const statusBarColor = StatusBar.getBackgroundColor() ?? theme.appBG;
@@ -35,9 +42,20 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
setStatusBarColor(isFullScreenModal ? theme.appBG : StyleUtils.getThemeBackgroundColor(statusBarColor));
}
+ if (shouldHandleNavigationBack) {
+ window.history.pushState({shouldGoBack: true}, '', null);
+ window.addEventListener('popstate', handlePopStateRef.current);
+ }
onModalShow?.();
};
+ useEffect(
+ () => () => {
+ window.removeEventListener('popstate', handlePopStateRef.current);
+ },
+ [],
+ );
+
return (
& {
* */
hideModalContentWhileAnimating?: boolean;
+ /** Whether handle navigation back when modal show. */
+ shouldHandleNavigationBack?: boolean;
+
/** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */
shouldUseCustomBackdrop?: boolean;
};
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index 2e8f80175b56..a4da7e551515 100755
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -665,7 +665,14 @@ function MoneyRequestConfirmationList({
description={translate('common.description')}
onPress={() => {
Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()),
+ ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ reportActionID,
+ ),
);
}}
style={styles.moneyRequestMenuItem}
@@ -757,6 +764,7 @@ function MoneyRequestConfirmationList({
transaction?.transactionID ?? '',
reportID,
Navigation.getActiveRouteWithoutParams(),
+ reportActionID,
),
);
}}
diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx
index e70e121569fd..5d3231ca0a41 100644
--- a/src/components/MoneyRequestHeader.tsx
+++ b/src/components/MoneyRequestHeader.tsx
@@ -71,11 +71,15 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
const deleteTransaction = useCallback(() => {
if (parentReportAction) {
const iouTransactionID = parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage?.IOUTransactionID ?? '' : '';
+ if (ReportActionsUtils.isTrackExpenseAction(parentReportAction)) {
+ IOU.deleteTrackExpense(parentReport?.reportID ?? '', iouTransactionID, parentReportAction, true);
+ return;
+ }
IOU.deleteMoneyRequest(iouTransactionID, parentReportAction, true);
}
setIsDeleteModalVisible(false);
- }, [parentReportAction, setIsDeleteModalVisible]);
+ }, [parentReport?.reportID, parentReportAction, setIsDeleteModalVisible]);
const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction);
const isPending = TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction);
@@ -84,7 +88,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
const canHoldOrUnholdRequest = !isSettled && !isApproved && !isDeletedParentAction;
// If the report supports adding transactions to it, then it also supports deleting transactions from it.
- const canDeleteRequest = isActionOwner && ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) && !isDeletedParentAction;
+ const canDeleteRequest = isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || ReportUtils.isTrackExpenseReport(report)) && !isDeletedParentAction;
const changeMoneyRequestStatus = () => {
const iouTransactionID = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage?.IOUTransactionID ?? '' : '';
@@ -109,7 +113,8 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
if (canHoldOrUnholdRequest) {
const isRequestIOU = parentReport?.type === 'iou';
const isHoldCreator = ReportUtils.isHoldCreator(transaction, report?.reportID) && isRequestIOU;
- const canModifyStatus = isPolicyAdmin || isActionOwner || isApprover;
+ const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report);
+ const canModifyStatus = !isTrackExpenseReport && (isPolicyAdmin || isActionOwner || isApprover);
if (isOnHold && (isHoldCreator || (!isRequestIOU && canModifyStatus))) {
threeDotsMenuItems.push({
icon: Expensicons.Stopwatch,
diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
index 0d1acc31ecdf..138bfc937926 100755
--- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
+++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
@@ -251,6 +251,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST;
const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT;
const isTypeSend = iouType === CONST.IOU.TYPE.SEND;
+ const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK_EXPENSE;
const canEditDistance = isTypeRequest || (canUseP2PDistanceRequests && isTypeSplit);
const {unit, rate, currency} = mileageRate;
@@ -381,7 +382,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
const splitOrRequestOptions = useMemo(() => {
let text;
- if (isTypeSplit && iouAmount === 0) {
+ if (isTypeTrackExpense) {
+ text = translate('iou.trackExpense');
+ } else if (isTypeSplit && iouAmount === 0) {
text = translate('iou.split');
} else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) {
text = translate('iou.request');
@@ -398,7 +401,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
value: iouType,
},
];
- }, [isTypeSplit, isTypeRequest, iouType, iouAmount, receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]);
+ }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]);
const selectedParticipants = useMemo(() => _.filter(pickedParticipants, (participant) => participant.selected), [pickedParticipants]);
const personalDetailsOfPayee = useMemo(() => payeePersonalDetails || currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]);
@@ -446,7 +449,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
} else {
const formattedSelectedParticipants = _.map(selectedParticipants, (participant) => ({
...participant,
- isDisabled: !participant.isPolicyExpenseChat && ReportUtils.isOptimisticPersonalDetail(participant.accountID),
+ isDisabled: !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID),
}));
sections.push({
title: translate('common.to'),
@@ -538,6 +541,11 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
const navigateToReportOrUserDetail = (option) => {
const activeRoute = Navigation.getActiveRouteWithoutParams();
+ if (option.isSelfDM) {
+ Navigation.navigate(ROUTES.PROFILE.getRoute(currentUserPersonalDetails.accountID, activeRoute));
+ return;
+ }
+
if (option.accountID) {
Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute));
} else if (option.reportID) {
diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx
index 7b45fd963fe7..c72cdd1fd898 100644
--- a/src/components/OptionRow.tsx
+++ b/src/components/OptionRow.tsx
@@ -229,7 +229,12 @@ function OptionRow({
numberOfLines={isMultilineSupported ? 2 : 1}
textStyles={displayNameStyle}
shouldUseFullTitle={
- !!option.isChatRoom || !!option.isPolicyExpenseChat || !!option.isMoneyRequestReport || !!option.isThread || !!option.isTaskReport
+ !!option.isChatRoom ||
+ !!option.isPolicyExpenseChat ||
+ !!option.isMoneyRequestReport ||
+ !!option.isThread ||
+ !!option.isTaskReport ||
+ !!option.isSelfDM
}
/>
{option.alternateText ? (
@@ -340,3 +345,5 @@ export default React.memo(
prevProps.option.pendingAction === nextProps.option.pendingAction &&
prevProps.option.customIcon === nextProps.option.customIcon,
);
+
+export type {OptionRowProps};
diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx
index e1cd18ba4767..19fc86c9f936 100644
--- a/src/components/Popover/index.tsx
+++ b/src/components/Popover/index.tsx
@@ -92,6 +92,7 @@ function Popover(props: PopoverProps) {
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
onClose={onCloseWithPopoverContext}
+ shouldHandleNavigationBack={props.shouldHandleNavigationBack}
type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.POPOVER}
popoverAnchorPosition={isSmallScreenWidth ? undefined : anchorPosition}
fullscreen={isSmallScreenWidth ? true : fullscreen}
diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts
index 314c1ba141c3..4e2f38293f6e 100644
--- a/src/components/Popover/types.ts
+++ b/src/components/Popover/types.ts
@@ -37,6 +37,9 @@ type PopoverProps = BaseModalProps &
/** Whether we want to show the popover on the right side of the screen */
fromSidebarMediumScreen?: boolean;
+
+ /** Whether handle navigation back when modal show. */
+ shouldHandleNavigationBack?: boolean;
};
type PopoverWithWindowDimensionsProps = PopoverProps & WindowDimensionsProps;
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 4ee070e19893..44a446b56653 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -215,4 +215,4 @@ function PopoverMenu({
PopoverMenu.displayName = 'PopoverMenu';
export default React.memo(PopoverMenu);
-export type {PopoverMenuItem};
+export type {PopoverMenuItem, PopoverMenuProps};
diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx
index 792002441ac6..deda6dbd217a 100644
--- a/src/components/PopoverWithMeasuredContent.tsx
+++ b/src/components/PopoverWithMeasuredContent.tsx
@@ -14,6 +14,9 @@ import type {WindowDimensionsProps} from './withWindowDimensions/types';
type PopoverWithMeasuredContentProps = Omit & {
/** The horizontal and vertical anchors points for the popover */
anchorPosition: AnchorPosition;
+
+ /** Whether handle navigation back when modal show. */
+ shouldHandleNavigationBack?: boolean;
};
/**
@@ -42,6 +45,7 @@ function PopoverWithMeasuredContent({
statusBarTranslucent = true,
avoidKeyboard = false,
hideModalContentWhileAnimating = false,
+ shouldHandleNavigationBack = false,
...props
}: PopoverWithMeasuredContentProps) {
const styles = useThemeStyles();
@@ -117,6 +121,7 @@ function PopoverWithMeasuredContent({
};
return isContentMeasured ? (
;
};
type ReferralProgramCTAProps = ReferralProgramCTAOnyxProps & {
@@ -36,7 +37,7 @@ function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: Ref
User.dismissReferralBanner(referralContentType);
};
- if (!referralContentType || dismissedReferralBanners[referralContentType]) {
+ if (!referralContentType || dismissedReferralBanners?.[referralContentType]) {
return null;
}
@@ -82,7 +83,6 @@ function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: Ref
export default withOnyx({
dismissedReferralBanners: {
- key: ONYXKEYS.ACCOUNT,
- selector: (data) => data?.dismissedReferralBanners ?? {},
+ key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS,
},
})(ReferralProgramCTA);
diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx
index 60dbfc07966a..e9b0ce3dae3f 100644
--- a/src/components/ReportActionItem/MoneyReportView.tsx
+++ b/src/components/ReportActionItem/MoneyReportView.tsx
@@ -29,14 +29,11 @@ type MoneyReportViewProps = {
/** Policy that the report belongs to */
policy: OnyxEntry;
- /** Policy report fields */
- policyReportFields: PolicyReportField[];
-
/** Whether we should display the horizontal rule below the component */
shouldShowHorizontalRule: boolean;
};
-function MoneyReportView({report, policy, policyReportFields, shouldShowHorizontalRule}: MoneyReportViewProps) {
+function MoneyReportView({report, policy, shouldShowHorizontalRule}: MoneyReportViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -60,9 +57,9 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont
];
const sortedPolicyReportFields = useMemo((): PolicyReportField[] => {
- const fields = ReportUtils.getAvailableReportFields(report, policyReportFields);
+ const fields = ReportUtils.getAvailableReportFields(report, Object.values(policy?.fieldList ?? {}));
return fields.sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight);
- }, [policyReportFields, report]);
+ }, [policy, report]);
return (
@@ -75,13 +72,14 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont
const isTitleField = ReportUtils.isReportFieldOfTypeTitle(reportField);
const fieldValue = isTitleField ? report.reportName : reportField.value ?? reportField.defaultValue;
const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy);
+ const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID);
return (
{
if (isSplitBillAction) {
@@ -108,14 +110,24 @@ function MoneyRequestAction({
shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(iouReport);
}
- return isDeletedParentAction || isReversedTransaction ? (
- ${translate(isReversedTransaction ? 'parentReportAction.reversedTransaction' : 'parentReportAction.deletedRequest')}`} />
- ) : (
+ if (isDeletedParentAction || isReversedTransaction) {
+ let message: TranslationPaths;
+ if (isReversedTransaction) {
+ message = 'parentReportAction.reversedTransaction';
+ } else if (isTrackExpenseAction) {
+ message = 'parentReportAction.deletedExpense';
+ } else {
+ message = 'parentReportAction.deletedRequest';
+ }
+ return ${translate(message)}`} />;
+ }
+ return (
;
+ return lodashIsEmpty(props.iouReport) && !(props.isBillSplit || props.isTrackExpense) ? null : ;
}
MoneyRequestPreview.displayName = 'MoneyRequestPreview';
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts
index 17dd42b2f794..3b3eda4ec30a 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts
+++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts
@@ -56,6 +56,9 @@ type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & {
/** True if this is this IOU is a split instead of a 1:1 request */
isBillSplit: boolean;
+ /** Whether this IOU is a track expense */
+ isTrackExpense: boolean;
+
/** True if the IOU Preview card is hovered */
isHovered?: boolean;
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index f1aa1751dd84..d183d27fefb8 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -133,7 +133,8 @@ function ReportPreview({
const hasReceipts = transactionsWithReceipts.length > 0;
const isScanning = hasReceipts && areAllRequestsBeingSmartScanned;
- const hasErrors = hasMissingSmartscanFields || (canUseViolations && ReportUtils.hasViolations(iouReportID, transactionViolations));
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const hasErrors = hasMissingSmartscanFields || (canUseViolations && ReportUtils.hasViolations(iouReportID, transactionViolations)) || ReportUtils.hasActionsWithErrors(iouReportID);
const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3);
const lastThreeReceipts = lastThreeTransactionsWithReceipts.map((transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction));
diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx
index e9bbd0f27bdc..219199c25bc3 100644
--- a/src/components/ReportWelcomeText.tsx
+++ b/src/components/ReportWelcomeText.tsx
@@ -3,6 +3,7 @@ import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
@@ -33,6 +34,7 @@ type ReportWelcomeTextProps = ReportWelcomeTextOnyxProps & {
function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const {canUseTrackExpense} = usePermissions();
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
const isChatRoom = ReportUtils.isChatRoom(report);
const isSelfDM = ReportUtils.isSelfDM(report);
@@ -42,7 +44,7 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant);
const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(policy);
const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(report, isUserPolicyAdmin);
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, policy, participantAccountIDs);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, policy, participantAccountIDs, canUseTrackExpense);
const additionalText = moneyRequestOptions.map((item) => translate(`reportActionsView.iouTypes.${item}`)).join(', ');
const canEditPolicyDescription = ReportUtils.canEditPolicyDescription(policy);
const reportName = ReportUtils.getReportName(report);
@@ -158,9 +160,9 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP
))}
)}
- {(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND) || moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)) && (
- {translate('reportActionsView.usePlusButton', {additionalText})}
- )}
+ {(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND) ||
+ moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST) ||
+ moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK_EXPENSE)) && {translate('reportActionsView.usePlusButton', {additionalText})}}
>
);
diff --git a/src/components/RoomHeaderAvatars.js b/src/components/RoomHeaderAvatars.tsx
similarity index 70%
rename from src/components/RoomHeaderAvatars.js
rename to src/components/RoomHeaderAvatars.tsx
index 64cc9ac7abf3..9298062aa6f9 100644
--- a/src/components/RoomHeaderAvatars.js
+++ b/src/components/RoomHeaderAvatars.tsx
@@ -1,63 +1,60 @@
-import PropTypes from 'prop-types';
import React, {memo} from 'react';
import {View} from 'react-native';
-import _ from 'underscore';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
+import type {Icon} from '@src/types/onyx/OnyxCommon';
import Avatar from './Avatar';
-import avatarPropTypes from './avatarPropTypes';
import PressableWithoutFocus from './Pressable/PressableWithoutFocus';
import Text from './Text';
-const propTypes = {
- icons: PropTypes.arrayOf(avatarPropTypes),
- reportID: PropTypes.string,
+type RoomHeaderAvatarsProps = {
+ icons: Icon[];
+ reportID: string;
};
-const defaultProps = {
- icons: [],
- reportID: '',
-};
-
-function RoomHeaderAvatars(props) {
- const navigateToAvatarPage = (icon) => {
+function RoomHeaderAvatars({icons, reportID}: RoomHeaderAvatarsProps) {
+ const navigateToAvatarPage = (icon: Icon) => {
if (icon.type === CONST.ICON_TYPE_WORKSPACE) {
- Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(props.reportID));
+ Navigation.navigate(ROUTES.REPORT_AVATAR.getRoute(reportID));
return;
}
- Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(icon.id));
+
+ if (icon.id) {
+ Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(icon.id));
+ }
};
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- if (!props.icons.length) {
+
+ if (!icons.length) {
return null;
}
- if (props.icons.length === 1) {
+ if (icons.length === 1) {
return (
navigateToAvatarPage(props.icons[0])}
+ style={styles.noOutline}
+ onPress={() => navigateToAvatarPage(icons[0])}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
- accessibilityLabel={props.icons[0].name}
+ accessibilityLabel={icons[0].name ?? ''}
>
);
}
- const iconsToDisplay = props.icons.slice(0, CONST.REPORT.MAX_PREVIEW_AVATARS);
+ const iconsToDisplay = icons.slice(0, CONST.REPORT.MAX_PREVIEW_AVATARS);
const iconStyle = [
styles.roomHeaderAvatar,
@@ -68,8 +65,9 @@ function RoomHeaderAvatars(props) {
return (
- {_.map(iconsToDisplay, (icon, index) => (
+ {iconsToDisplay.map((icon, index) => (
@@ -77,7 +75,7 @@ function RoomHeaderAvatars(props) {
style={[styles.mln4, StyleUtils.getAvatarBorderRadius(CONST.AVATAR_SIZE.LARGE_BORDERED, icon.type)]}
onPress={() => navigateToAvatarPage(icon)}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
- accessibilityLabel={icon.name}
+ accessibilityLabel={icon.name ?? ''}
>
- {index === CONST.REPORT.MAX_PREVIEW_AVATARS - 1 && props.icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS !== 0 && (
+ {index === CONST.REPORT.MAX_PREVIEW_AVATARS - 1 && icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS !== 0 && (
<>
- {`+${props.icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS}`}
+ {`+${icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS}`}
>
)}
@@ -110,8 +108,6 @@ function RoomHeaderAvatars(props) {
);
}
-RoomHeaderAvatars.defaultProps = defaultProps;
-RoomHeaderAvatars.propTypes = propTypes;
RoomHeaderAvatars.displayName = 'RoomHeaderAvatars';
export default memo(RoomHeaderAvatars);
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index 4c1f208ce11d..596951374099 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -85,7 +85,7 @@ function BaseListItem({
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
disabled={isDisabled || item.isDisabledCheckbox}
onPress={handleCheckboxPress}
- style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]}
+ style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3]}
>
{item.isSelected && (
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index ac48b0fa08a9..015fd284c0b4 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -9,6 +9,7 @@ import Button from '@components/Button';
import Checkbox from '@components/Checkbox';
import FixedFooter from '@components/FixedFooter';
import OptionsListSkeletonView from '@components/OptionsListSkeletonView';
+import {PressableWithFeedback} from '@components/Pressable';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import SectionList from '@components/SectionList';
import ShowMoreButton from '@components/ShowMoreButton';
@@ -454,11 +455,22 @@ function BaseSelectionList(
});
/** Calls confirm action when pressing CTRL (CMD) + Enter */
- useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm ?? selectFocusedOption, {
- captureOnInputs: true,
- shouldBubble: !flattenedSections.allOptions[focusedIndex],
- isActive: !disableKeyboardShortcuts && isFocused,
- });
+ useKeyboardShortcut(
+ CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER,
+ (e) => {
+ const focusedOption = flattenedSections.allOptions[focusedIndex];
+ if (onConfirm) {
+ onConfirm(e, focusedOption);
+ return;
+ }
+ selectFocusedOption();
+ },
+ {
+ captureOnInputs: true,
+ shouldBubble: !flattenedSections.allOptions[focusedIndex],
+ isActive: !disableKeyboardShortcuts && isFocused,
+ },
+ );
return (
(
) : (
<>
{!headerMessage && canSelectMultiple && shouldShowSelectAll && (
-
-
- {customListHeader ?? (
-
- {translate('workspace.people.selectAll')}
-
- )}
+
+
+
+ {!customListHeader && (
+ e.preventDefault() : undefined}
+ >
+ {translate('workspace.people.selectAll')}
+
+ )}
+
+ {customListHeader}
)}
{!headerMessage && !canSelectMultiple && customListHeader}
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index 9e9ba7e5fc27..e691a5bdb191 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -107,6 +107,9 @@ type ListItem = {
/** Whether to wrap long text up to 2 lines */
isMultilineSupported?: boolean;
+
+ /** The search value from the selection list */
+ searchText?: string | null;
};
type ListItemProps = CommonListItemProps & {
@@ -227,7 +230,7 @@ type BaseSelectionListProps = Partial & {
confirmButtonText?: string;
/** Callback to fire when the confirm button is pressed */
- onConfirm?: (e?: GestureResponderEvent | KeyboardEvent | undefined) => void;
+ onConfirm?: (e?: GestureResponderEvent | KeyboardEvent | undefined, option?: TItem) => void;
/** Whether to show the vertical scroll indicator */
showScrollIndicator?: boolean;
diff --git a/src/components/SwipeInterceptPanResponder.tsx b/src/components/SwipeInterceptPanResponder.tsx
index fe1545d2f14b..6a3d14b3b24b 100644
--- a/src/components/SwipeInterceptPanResponder.tsx
+++ b/src/components/SwipeInterceptPanResponder.tsx
@@ -1,6 +1,7 @@
import {PanResponder} from 'react-native';
const SwipeInterceptPanResponder = PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
});
diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx
index dad7117bef67..61a13d271e7d 100644
--- a/src/components/TaxPicker.tsx
+++ b/src/components/TaxPicker.tsx
@@ -53,10 +53,10 @@ function TaxPicker({selectedTaxRate = '', taxRates, insets, onSubmit}: TaxPicker
];
}, [selectedTaxRate, getTaxName]);
- const sections = useMemo(() => {
- const taxRatesOptions = OptionsListUtils.getTaxRatesSection(taxRates, selectedOptions as OptionsListUtils.Category[], searchValue, selectedTaxRate);
- return taxRatesOptions;
- }, [taxRates, searchValue, selectedOptions, selectedTaxRate]);
+ const sections = useMemo(
+ () => OptionsListUtils.getTaxRatesSection(taxRates, selectedOptions as OptionsListUtils.Category[], searchValue, selectedTaxRate),
+ [taxRates, searchValue, selectedOptions, selectedTaxRate],
+ );
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(sections[0].data.length > 0, searchValue);
diff --git a/src/components/VideoPlayer/BaseVideoPlayer.js b/src/components/VideoPlayer/BaseVideoPlayer.js
index 92d829e9d0db..2043f5620912 100644
--- a/src/components/VideoPlayer/BaseVideoPlayer.js
+++ b/src/components/VideoPlayer/BaseVideoPlayer.js
@@ -15,6 +15,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import CONST from '@src/CONST';
import {videoPlayerDefaultProps, videoPlayerPropTypes} from './propTypes';
import shouldReplayVideo from './shouldReplayVideo';
+import * as VideoUtils from './utils';
import VideoPlayerControls from './VideoPlayerControls';
const isMobileSafari = Browser.isMobileSafari();
@@ -49,7 +50,8 @@ function BaseVideoPlayer({
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isBuffering, setIsBuffering] = useState(true);
- const [sourceURL] = useState(url.includes('blob:') || url.includes('file:///') ? url : addEncryptedAuthTokenToURL(url));
+ // we add "#t=0.001" at the end of the URL to skip first milisecond of the video and always be able to show proper video preview when video is paused at the beginning
+ const [sourceURL] = useState(VideoUtils.addSkipTimeTagToURL(url.includes('blob:') || url.includes('file:///') ? url : addEncryptedAuthTokenToURL(url), 0.001));
const [isPopoverVisible, setIsPopoverVisible] = useState(false);
const [popoverAnchorPosition, setPopoverAnchorPosition] = useState({horizontal: 0, vertical: 0});
const videoPlayerRef = useRef(null);
diff --git a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.js b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
similarity index 68%
rename from src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.js
rename to src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
index c6eb1a179726..f9dd09db59f4 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.js
+++ b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
@@ -1,25 +1,27 @@
-import PropTypes from 'prop-types';
import React, {useEffect, useState} from 'react';
+import type {LayoutChangeEvent, ViewStyle} from 'react-native';
+import type {GestureStateChangeEvent, GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import Animated, {runOnJS, useAnimatedStyle, useSharedValue} from 'react-native-reanimated';
import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext';
import useThemeStyles from '@hooks/useThemeStyles';
-const propTypes = {
- duration: PropTypes.number.isRequired,
+type ProgressBarProps = {
+ /** Total duration of a video. */
+ duration: number;
- position: PropTypes.number.isRequired,
+ /** Position of progress pointer on the bar. */
+ position: number;
- seekPosition: PropTypes.func.isRequired,
+ /** Function to seek to a specific position in the video. */
+ seekPosition: (newPosition: number) => void;
};
-const defaultProps = {};
-
-function getProgress(currentPosition, maxPosition) {
+function getProgress(currentPosition: number, maxPosition: number): number {
return Math.min(Math.max((currentPosition / maxPosition) * 100, 0), 100);
}
-function ProgressBar({duration, position, seekPosition}) {
+function ProgressBar({duration, position, seekPosition}: ProgressBarProps) {
const styles = useThemeStyles();
const {pauseVideo, playVideo, checkVideoPlaying} = usePlaybackContext();
const [sliderWidth, setSliderWidth] = useState(1);
@@ -27,18 +29,18 @@ function ProgressBar({duration, position, seekPosition}) {
const progressWidth = useSharedValue(0);
const wasVideoPlayingOnCheck = useSharedValue(false);
- const onCheckVideoPlaying = (isPlaying) => {
+ const onCheckVideoPlaying = (isPlaying: boolean) => {
wasVideoPlayingOnCheck.value = isPlaying;
};
- const progressBarInteraction = (event) => {
+ const progressBarInteraction = (event: GestureUpdateEvent | GestureStateChangeEvent) => {
const progress = getProgress(event.x, sliderWidth);
progressWidth.value = progress;
runOnJS(seekPosition)((progress * duration) / 100);
};
- const onSliderLayout = (e) => {
- setSliderWidth(e.nativeEvent.layout.width);
+ const onSliderLayout = (event: LayoutChangeEvent) => {
+ setSliderWidth(event.nativeEvent.layout.width);
};
const pan = Gesture.Pan()
@@ -66,7 +68,7 @@ function ProgressBar({duration, position, seekPosition}) {
progressWidth.value = getProgress(position, duration);
}, [duration, isSliderPressed, position, progressWidth]);
- const progressBarStyle = useAnimatedStyle(() => ({width: `${progressWidth.value}%`}));
+ const progressBarStyle: ViewStyle = useAnimatedStyle(() => ({width: `${progressWidth.value}%`}));
return (
@@ -85,8 +87,6 @@ function ProgressBar({duration, position, seekPosition}) {
);
}
-ProgressBar.propTypes = propTypes;
-ProgressBar.defaultProps = defaultProps;
ProgressBar.displayName = 'ProgressBar';
export default ProgressBar;
diff --git a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx
similarity index 81%
rename from src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
rename to src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx
index 45f47eb87c36..011391ed4c71 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
+++ b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx
@@ -1,6 +1,7 @@
-import PropTypes from 'prop-types';
import React, {memo, useCallback, useState} from 'react';
+import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
+import type {GestureStateChangeEvent, GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import Animated, {runOnJS, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
import Hoverable from '@components/Hoverable';
@@ -10,18 +11,16 @@ import {useVolumeContext} from '@components/VideoPlayerContexts/VolumeContext';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as NumberUtils from '@libs/NumberUtils';
-import stylePropTypes from '@styles/stylePropTypes';
-const propTypes = {
- style: stylePropTypes.isRequired,
- small: PropTypes.bool,
-};
+type VolumeButtonProps = {
+ /** Style for the volume button. */
+ style?: StyleProp;
-const defaultProps = {
- small: false,
+ /** Is button icon small. */
+ small?: boolean;
};
-const getVolumeIcon = (volume) => {
+const getVolumeIcon = (volume: number) => {
if (volume === 0) {
return Expensicons.Mute;
}
@@ -31,7 +30,7 @@ const getVolumeIcon = (volume) => {
return Expensicons.VolumeHigh;
};
-function VolumeButton({style, small}) {
+function VolumeButton({style, small = false}: VolumeButtonProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {updateVolume, volume} = useVolumeContext();
@@ -39,12 +38,12 @@ function VolumeButton({style, small}) {
const [volumeIcon, setVolumeIcon] = useState({icon: getVolumeIcon(volume.value)});
const [isSliderBeingUsed, setIsSliderBeingUsed] = useState(false);
- const onSliderLayout = useCallback((e) => {
- setSliderHeight(e.nativeEvent.layout.height);
+ const onSliderLayout = useCallback((event: LayoutChangeEvent) => {
+ setSliderHeight(event.nativeEvent.layout.height);
}, []);
const changeVolumeOnPan = useCallback(
- (event) => {
+ (event: GestureStateChangeEvent | GestureUpdateEvent) => {
const val = NumberUtils.roundToTwoDecimalPlaces(1 - event.y / sliderHeight);
volume.value = NumberUtils.clamp(val, 0, 1);
},
@@ -65,7 +64,7 @@ function VolumeButton({style, small}) {
const progressBarStyle = useAnimatedStyle(() => ({height: `${volume.value * 100}%`}));
- const updateIcon = useCallback((vol) => {
+ const updateIcon = useCallback((vol: number) => {
setVolumeIcon({icon: getVolumeIcon(vol)});
}, []);
@@ -98,7 +97,6 @@ function VolumeButton({style, small}) {
tooltipText={volume.value === 0 ? translate('videoPlayer.unmute') : translate('videoPlayer.mute')}
onPress={() => updateVolume(volume.value === 0 ? 1 : 0)}
src={volumeIcon.icon}
- fill={styles.white}
small={small}
shouldForceRenderingTooltipBelow
/>
@@ -108,8 +106,6 @@ function VolumeButton({style, small}) {
);
}
-VolumeButton.propTypes = propTypes;
-VolumeButton.defaultProps = defaultProps;
VolumeButton.displayName = 'VolumeButton';
export default memo(VolumeButton);
diff --git a/src/components/VideoPlayer/VideoPlayerControls/index.js b/src/components/VideoPlayer/VideoPlayerControls/index.tsx
similarity index 73%
rename from src/components/VideoPlayer/VideoPlayerControls/index.js
rename to src/components/VideoPlayer/VideoPlayerControls/index.tsx
index 5a926123feef..7c61721b67b7 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/index.js
+++ b/src/components/VideoPlayer/VideoPlayerControls/index.tsx
@@ -1,55 +1,58 @@
-import PropTypes from 'prop-types';
+import type {Video} from 'expo-av';
+import type {MutableRefObject} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
+import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Animated from 'react-native-reanimated';
import * as Expensicons from '@components/Icon/Expensicons';
-import refPropTypes from '@components/refPropTypes';
import Text from '@components/Text';
import IconButton from '@components/VideoPlayer/IconButton';
-import convertMillisecondsToTime from '@components/VideoPlayer/utils';
+import {convertMillisecondsToTime} from '@components/VideoPlayer/utils';
import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import stylePropTypes from '@styles/stylePropTypes';
import CONST from '@src/CONST';
import ProgressBar from './ProgressBar';
import VolumeButton from './VolumeButton';
-const propTypes = {
- duration: PropTypes.number.isRequired,
+type VideoPlayerControlsProps = {
+ /** Duration of a video. */
+ duration: number;
- position: PropTypes.number.isRequired,
+ /** Position of progress pointer. */
+ position: number;
- url: PropTypes.string.isRequired,
+ /** Url of a video. */
+ url: string;
- videoPlayerRef: refPropTypes.isRequired,
+ /** Ref for video player. */
+ videoPlayerRef: MutableRefObject
@@ -734,7 +740,6 @@ function ReportActionItem({
@@ -881,10 +886,6 @@ export default withOnyx({
},
initialValue: {} as OnyxTypes.Report,
},
- policyReportFields: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID ?? 0}`,
- initialValue: {},
- },
policy: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID ?? 0}`,
initialValue: {} as OnyxTypes.Policy,
@@ -925,8 +926,7 @@ export default withOnyx({
prevProps.report?.total === nextProps.report?.total &&
prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal &&
prevProps.linkedReportActionID === nextProps.linkedReportActionID &&
- lodashIsEqual(prevProps.policyReportFields, nextProps.policyReportFields) &&
- lodashIsEqual(prevProps.report.reportFields, nextProps.report.reportFields) &&
+ lodashIsEqual(prevProps.report.fieldList, nextProps.report.fieldList) &&
lodashIsEqual(prevProps.policy, nextProps.policy) &&
lodashIsEqual(prevParentReportAction, nextParentReportAction)
);
diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx
index 366b04634eb0..bcbb7a98c8c5 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -8,6 +8,7 @@ import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StylePr
import type {OnyxEntry} from 'react-native-onyx';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import InvertedFlatList from '@components/InvertedFlatList';
+import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList';
import {usePersonalDetails} from '@components/OnyxProvider';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
@@ -17,6 +18,7 @@ import useReportScrollManager from '@hooks/useReportScrollManager';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import DateUtils from '@libs/DateUtils';
+import Navigation from '@libs/Navigation/Navigation';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import Visibility from '@libs/Visibility';
@@ -24,10 +26,12 @@ import type {CentralPaneNavigatorParamList} from '@navigation/types';
import variables from '@styles/variables';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import FloatingMessageCounter from './FloatingMessageCounter';
+import getInitialNumToRender from './getInitialNumReportActionsToRender';
import ListBoundaryLoader from './ListBoundaryLoader';
import ReportActionsListItemRenderer from './ReportActionsListItemRenderer';
@@ -65,10 +69,19 @@ type ReportActionsListProps = WithCurrentUserPersonalDetailsProps & {
loadOlderChats: () => void;
/** Function to load newer chats */
- loadNewerChats: LoadNewerChats;
+ loadNewerChats: () => void;
/** Whether the composer is in full size */
isComposerFullSize?: boolean;
+
+ /** ID of the list */
+ listID: number;
+
+ /** Callback executed on content size change */
+ onContentSizeChange: (w: number, h: number) => void;
+
+ /** Should enable auto scroll to top threshold */
+ shouldEnableAutoScrollToTopThreshold?: boolean;
};
const VERTICAL_OFFSET_THRESHOLD = 200;
@@ -124,6 +137,9 @@ function ReportActionsList({
loadOlderChats,
onLayout,
isComposerFullSize,
+ listID,
+ onContentSizeChange,
+ shouldEnableAutoScrollToTopThreshold,
}: ReportActionsListProps) {
const personalDetailsList = usePersonalDetails() || CONST.EMPTY_OBJECT;
const styles = useThemeStyles();
@@ -132,6 +148,7 @@ function ReportActionsList({
const {isOffline} = useNetwork();
const route = useRoute>();
const opacity = useSharedValue(0);
+ const reportScrollManager = useReportScrollManager();
const userActiveSince = useRef(null);
const lastMessageTime = useRef(null);
@@ -152,7 +169,6 @@ function ReportActionsList({
}
return cacheUnreadMarkers.get(report.reportID);
};
- const reportScrollManager = useReportScrollManager();
const [currentUnreadMarker, setCurrentUnreadMarker] = useState(markerInit);
const scrollingVerticalOffset = useRef(0);
const readActionSkipped = useRef(false);
@@ -162,14 +178,21 @@ function ReportActionsList({
const lastReadTimeRef = useRef(report.lastReadTime);
const sortedVisibleReportActions = useMemo(
- () => sortedReportActions.filter((reportAction) => isOffline || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors),
+ () =>
+ sortedReportActions.filter(
+ (reportAction) =>
+ (isOffline || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) &&
+ ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID),
+ ),
[sortedReportActions, isOffline],
);
const lastActionIndex = sortedVisibleReportActions[0]?.reportActionID;
const reportActionSize = useRef(sortedVisibleReportActions.length);
+ const hasNewestReportAction = sortedReportActions?.[0].created === report.lastVisibleActionCreated;
const previousLastIndex = useRef(lastActionIndex);
+ const isLastPendingActionIsDelete = sortedReportActions?.[0]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
const linkedReportActionID = route.params?.reportActionID ?? '';
// This state is used to force a re-render when the user manually marks a message as unread
@@ -185,12 +208,17 @@ function ReportActionsList({
}, [opacity]);
useEffect(() => {
- if (previousLastIndex.current !== lastActionIndex && reportActionSize.current > sortedVisibleReportActions.length) {
+ if (
+ scrollingVerticalOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD &&
+ previousLastIndex.current !== lastActionIndex &&
+ reportActionSize.current > sortedVisibleReportActions.length &&
+ hasNewestReportAction
+ ) {
reportScrollManager.scrollToBottom();
}
previousLastIndex.current = lastActionIndex;
reportActionSize.current = sortedVisibleReportActions.length;
- }, [lastActionIndex, sortedVisibleReportActions.length, reportScrollManager]);
+ }, [lastActionIndex, sortedVisibleReportActions, reportScrollManager, hasNewestReportAction, linkedReportActionID]);
useEffect(() => {
// If the reportID changes, we reset the userActiveSince to null, we need to do it because
@@ -273,12 +301,27 @@ function ReportActionsList({
}, [report.reportID]);
useEffect(() => {
+ if (linkedReportActionID) {
+ return;
+ }
InteractionManager.runAfterInteractions(() => {
reportScrollManager.scrollToBottom();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ const scrollToBottomForCurrentUserAction = useCallback(
+ (isFromCurrentUser: boolean) => {
+ // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where
+ // they are now in the list.
+ if (!isFromCurrentUser || !hasNewestReportAction) {
+ return;
+ }
+ InteractionManager.runAfterInteractions(() => reportScrollManager.scrollToBottom());
+ },
+ [hasNewestReportAction, reportScrollManager],
+ );
+
useEffect(() => {
// Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function?
// Answer: On web, when navigating to another report screen, the previous report screen doesn't get unmounted,
@@ -294,14 +337,7 @@ function ReportActionsList({
// This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.js. This allows us to maintain
// a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props.
- const unsubscribe = Report.subscribeToNewActionEvent(report.reportID, (isFromCurrentUser) => {
- // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where
- // they are now in the list.
- if (!isFromCurrentUser) {
- return;
- }
- InteractionManager.runAfterInteractions(() => reportScrollManager.scrollToBottom());
- });
+ const unsubscribe = Report.subscribeToNewActionEvent(report.reportID, scrollToBottomForCurrentUserAction);
const cleanup = () => {
if (unsubscribe) {
@@ -343,6 +379,11 @@ function ReportActionsList({
};
const scrollToBottomAndMarkReportAsRead = () => {
+ if (!hasNewestReportAction) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID));
+ Report.openReport(report.reportID);
+ return;
+ }
reportScrollManager.scrollToBottom();
readActionSkipped.current = false;
Report.readNewestAction(report.reportID);
@@ -355,9 +396,12 @@ function ReportActionsList({
const initialNumToRender = useMemo((): number | undefined => {
const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight;
const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight);
- const itemsToRender = Math.ceil(availableHeight / minimumReportActionHeight);
- return itemsToRender > 0 ? itemsToRender : undefined;
- }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight]);
+ const numToRender = Math.ceil(availableHeight / minimumReportActionHeight);
+ if (linkedReportActionID) {
+ return getInitialNumToRender(numToRender);
+ }
+ return numToRender || undefined;
+ }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]);
/**
* Thread's divider line should hide when the first chat in the thread is marked as unread.
@@ -488,10 +532,11 @@ function ReportActionsList({
const extraData = useMemo(() => [isSmallScreenWidth ? currentUnreadMarker : undefined, ReportUtils.isArchivedRoom(report)], [currentUnreadMarker, isSmallScreenWidth, report]);
const hideComposer = !ReportUtils.canUserPerformWriteAction(report);
const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize;
+ const canShowHeader = !isOffline && !hasHeaderRendered.current && scrollingVerticalOffset.current > VERTICAL_OFFSET_THRESHOLD;
const contentContainerStyle: StyleProp = useMemo(
- () => [styles.chatContentScrollView, isLoadingNewerReportActions ? styles.chatContentScrollViewWithHeaderLoader : {}],
- [isLoadingNewerReportActions, styles.chatContentScrollView, styles.chatContentScrollViewWithHeaderLoader],
+ () => [styles.chatContentScrollView, isLoadingNewerReportActions && canShowHeader ? styles.chatContentScrollViewWithHeaderLoader : {}],
+ [isLoadingNewerReportActions, styles.chatContentScrollView, styles.chatContentScrollViewWithHeaderLoader, canShowHeader],
);
const lastReportAction: OnyxTypes.ReportAction | EmptyObject = useMemo(() => sortedReportActions.at(-1) ?? {}, [sortedReportActions]);
@@ -521,9 +566,15 @@ function ReportActionsList({
},
[onLayout],
);
+ const onContentSizeChangeInner = useCallback(
+ (w: number, h: number) => {
+ onContentSizeChange(w, h);
+ },
+ [onContentSizeChange],
+ );
const listHeaderComponent = useCallback(() => {
- if (!isOffline && !hasHeaderRendered.current) {
+ if (!canShowHeader) {
hasHeaderRendered.current = true;
return null;
}
@@ -534,12 +585,15 @@ function ReportActionsList({
isLoadingNewerReportActions={isLoadingNewerReportActions}
/>
);
- }, [isLoadingNewerReportActions, isOffline]);
+ }, [isLoadingNewerReportActions, canShowHeader]);
+ // When performing comment linking, initially 25 items are added to the list. Subsequent fetches add 15 items from the cache or 50 items from the server.
+ // This is to ensure that the user is able to see the 'scroll to newer comments' button when they do comment linking and have not reached the end of the list yet.
+ const canScrollToNewerComments = !isLoadingInitialReportActions && !hasNewestReportAction && sortedReportActions.length > 25 && !isLastPendingActionIsDelete;
return (
<>
@@ -548,7 +602,7 @@ function ReportActionsList({
ref={reportScrollManager.ref}
testID="report-actions-list"
style={styles.overscrollBehaviorContain}
- data={sortedReportActions}
+ data={sortedVisibleReportActions}
renderItem={renderItem}
contentContainerStyle={contentContainerStyle}
keyExtractor={keyExtractor}
@@ -561,9 +615,12 @@ function ReportActionsList({
ListHeaderComponent={listHeaderComponent}
keyboardShouldPersistTaps="handled"
onLayout={onLayoutInner}
+ onContentSizeChange={onContentSizeChangeInner}
onScroll={trackVerticalScrolling}
onScrollToIndexFailed={onScrollToIndexFailed}
extraData={extraData}
+ key={listID}
+ shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScrollToTopThreshold}
/>
>
@@ -574,4 +631,4 @@ ReportActionsList.displayName = 'ReportActionsList';
export default withCurrentUserPersonalDetails(memo(ReportActionsList));
-export type {LoadNewerChats};
+export type {LoadNewerChats, ReportActionsListProps};
diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx
index 3153fd1061ff..c74bb40a18b6 100755
--- a/src/pages/home/report/ReportActionsView.tsx
+++ b/src/pages/home/report/ReportActionsView.tsx
@@ -1,7 +1,7 @@
-import {useIsFocused} from '@react-navigation/native';
+import type {RouteProp} from '@react-navigation/native';
+import {useIsFocused, useRoute} from '@react-navigation/native';
import lodashIsEqual from 'lodash/isEqual';
-import lodashThrottle from 'lodash/throttle';
-import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
+import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {InteractionManager} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
@@ -11,20 +11,23 @@ import useNetwork from '@hooks/useNetwork';
import usePrevious from '@hooks/usePrevious';
import useWindowDimensions from '@hooks/useWindowDimensions';
import getIsReportFullyVisible from '@libs/getIsReportFullyVisible';
+import type {CentralPaneNavigatorParamList} from '@libs/Navigation/types';
+import {generateNewRandomInt} from '@libs/NumberUtils';
import Performance from '@libs/Performance';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import {isUserCreatedPolicyRoom} from '@libs/ReportUtils';
import {didUserLogInDuringSession} from '@libs/SessionUtils';
+import shouldFetchReport from '@libs/shouldFetchReport';
import {ReactionListContext} from '@pages/home/ReportScreenContext';
import * as Report from '@userActions/Report';
import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import getInitialPaginationSize from './getInitialPaginationSize';
import PopoverReactionList from './ReactionList/PopoverReactionList';
import ReportActionsList from './ReportActionsList';
-import type {LoadNewerChats} from './ReportActionsList';
type ReportActionsViewOnyxProps = {
/** Session info for the currently logged in user. */
@@ -49,48 +52,148 @@ type ReportActionsViewProps = ReportActionsViewOnyxProps & {
/** The report actions are loading newer data */
isLoadingNewerReportActions?: boolean;
+
+ /** Whether the report is ready for comment linking */
+ isReadyForCommentLinking?: boolean;
};
+const DIFF_BETWEEN_SCREEN_HEIGHT_AND_LIST = 120;
+const SPACER = 16;
+
+let listOldID = Math.round(Math.random() * 100);
+
function ReportActionsView({
report,
session,
parentReportAction,
- reportActions = [],
+ reportActions: allReportActions = [],
isLoadingInitialReportActions = false,
isLoadingOlderReportActions = false,
isLoadingNewerReportActions = false,
+ isReadyForCommentLinking = false,
}: ReportActionsViewProps) {
useCopySelectionHelper();
const reactionListRef = useContext(ReactionListContext);
+ const route = useRoute>();
+ const reportActionID = route?.params?.reportActionID;
const didLayout = useRef(false);
const didSubscribeToReportTypingEvents = useRef(false);
- const isFirstRender = useRef(true);
- const hasCachedActions = useInitialValue(() => reportActions.length > 0);
- const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActions), [reportActions]);
+
+ // triggerListID is used when navigating to a chat with messages loaded from LHN. Typically, these include thread actions, task actions, etc. Since these messages aren't the latest,we don't maintain their position and instead trigger a recalculation of their positioning in the list.
+ // we don't set currentReportActionID on initial render as linkedID as it should trigger visibleReportActions after linked message was positioned
+ const [currentReportActionID, setCurrentReportActionID] = useState('');
+ const isFirstLinkedActionRender = useRef(true);
+
const network = useNetwork();
- const {isSmallScreenWidth} = useWindowDimensions();
+ const {isSmallScreenWidth, windowHeight} = useWindowDimensions();
+ const contentListHeight = useRef(0);
+ const isFocused = useIsFocused();
const prevNetworkRef = useRef(network);
const prevAuthTokenType = usePrevious(session?.authTokenType);
-
+ const [isNavigatingToLinkedMessage, setNavigatingToLinkedMessage] = useState(!!reportActionID);
const prevIsSmallScreenWidthRef = useRef(isSmallScreenWidth);
-
- const isFocused = useIsFocused();
const reportID = report.reportID;
- const hasNewestReportAction = reportActions[0]?.isNewestReportAction;
+ const isLoading = (!!reportActionID && isLoadingInitialReportActions) || !isReadyForCommentLinking;
+
+ /**
+ * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently
+ * displaying.
+ */
+ const fetchNewerAction = useCallback(
+ (newestReportAction: OnyxTypes.ReportAction) => {
+ if (isLoadingNewerReportActions || isLoadingInitialReportActions) {
+ return;
+ }
+
+ Report.getNewerActions(reportID, newestReportAction.reportActionID);
+ },
+ [isLoadingNewerReportActions, isLoadingInitialReportActions, reportID],
+ );
const isReportFullyVisible = useMemo((): boolean => getIsReportFullyVisible(isFocused), [isFocused]);
const openReportIfNecessary = () => {
- const createChatError = report.errorFields?.createChat;
- // If the report is optimistic (AKA not yet created) we don't need to call openReport again
- if (!!report.isOptimisticReport || !isEmptyObject(createChatError)) {
+ if (!shouldFetchReport(report)) {
return;
}
- Report.openReport(reportID);
+ Report.openReport(reportID, reportActionID);
};
+ const reconnectReportIfNecessary = () => {
+ if (!shouldFetchReport(report)) {
+ return;
+ }
+
+ Report.reconnect(reportID);
+ };
+
+ useLayoutEffect(() => {
+ setCurrentReportActionID('');
+ }, [route]);
+
+ const listID = useMemo(() => {
+ isFirstLinkedActionRender.current = true;
+ const newID = generateNewRandomInt(listOldID, 1, Number.MAX_SAFE_INTEGER);
+ listOldID = newID;
+ return newID;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [route, isLoadingInitialReportActions]);
+
+ const indexOfLinkedAction = useMemo(() => {
+ if (!reportActionID || isLoading) {
+ return -1;
+ }
+
+ return allReportActions.findIndex((obj) => String(obj.reportActionID) === String(isFirstLinkedActionRender.current ? reportActionID : currentReportActionID));
+ }, [allReportActions, currentReportActionID, reportActionID, isLoading]);
+
+ const reportActions = useMemo(() => {
+ if (!reportActionID) {
+ return allReportActions;
+ }
+ if (isLoading || indexOfLinkedAction === -1) {
+ return [];
+ }
+
+ if (isFirstLinkedActionRender.current) {
+ return allReportActions.slice(indexOfLinkedAction);
+ }
+ const paginationSize = getInitialPaginationSize;
+ return allReportActions.slice(Math.max(indexOfLinkedAction - paginationSize, 0));
+ // currentReportActionID is needed to trigger batching once the report action has been positioned
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [reportActionID, allReportActions, indexOfLinkedAction, isLoading, currentReportActionID]);
+
+ const hasMoreCached = reportActions.length < allReportActions.length;
+ const newestReportAction = useMemo(() => reportActions?.[0], [reportActions]);
+ const handleReportActionPagination = useCallback(
+ ({firstReportActionID}: {firstReportActionID: string}) => {
+ // This function is a placeholder as the actual pagination is handled by visibleReportActions
+ if (!hasMoreCached) {
+ isFirstLinkedActionRender.current = false;
+ fetchNewerAction(newestReportAction);
+ }
+ if (isFirstLinkedActionRender.current) {
+ isFirstLinkedActionRender.current = false;
+ }
+ setCurrentReportActionID(firstReportActionID);
+ },
+ [fetchNewerAction, hasMoreCached, newestReportAction],
+ );
+
+ const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActions), [reportActions]);
+ const hasCachedActionOnFirstRender = useInitialValue(() => reportActions.length > 0);
+ const hasNewestReportAction = reportActions[0]?.created === report.lastVisibleActionCreated;
+
+ const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]);
+ const hasCreatedAction = oldestReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED;
+
useEffect(() => {
+ if (reportActionID) {
+ return;
+ }
+
const interactionTask = InteractionManager.runAfterInteractions(() => {
openReportIfNecessary();
});
@@ -100,9 +203,22 @@ function ReportActionsView({
interactionTask.cancel();
};
}
+
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ useEffect(() => {
+ if (!reportActionID) {
+ return;
+ }
+
+ // This function is triggered when a user clicks on a link to navigate to a report.
+ // For each link click, we retrieve the report data again, even though it may already be cached.
+ // There should be only one openReport execution per page start or navigating
+ Report.openReport(reportID, reportActionID);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [route]);
+
useEffect(() => {
const prevNetwork = prevNetworkRef.current;
// When returning from offline to online state we want to trigger a request to OpenReport which
@@ -113,7 +229,7 @@ function ReportActionsView({
if (isReportFullyVisible) {
openReportIfNecessary();
} else {
- Report.reconnect(reportID);
+ reconnectReportIfNecessary();
}
}
// update ref with current network state
@@ -127,7 +243,7 @@ function ReportActionsView({
if (isReportFullyVisible) {
openReportIfNecessary();
} else {
- Report.reconnect(reportID);
+ reconnectReportIfNecessary();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -164,7 +280,11 @@ function ReportActionsView({
}
}, [report.pendingFields, didSubscribeToReportTypingEvents, reportID]);
- const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]);
+ const onContentSizeChange = useCallback((w: number, h: number) => {
+ contentListHeight.current = h;
+ }, []);
+
+ const checkIfContentSmallerThanList = useCallback(() => windowHeight - DIFF_BETWEEN_SCREEN_HEIGHT_AND_LIST - SPACER > contentListHeight.current, [windowHeight]);
/**
* Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently
@@ -172,48 +292,44 @@ function ReportActionsView({
*/
const loadOlderChats = useCallback(() => {
// Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline.
- if (!!network.isOffline || isLoadingOlderReportActions) {
+ if (!!network.isOffline || isLoadingOlderReportActions || isLoadingInitialReportActions) {
return;
}
// Don't load more chats if we're already at the beginning of the chat history
- if (!oldestReportAction || oldestReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) {
+ if (!oldestReportAction || hasCreatedAction) {
return;
}
// Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments
- Report.getOlderActions(reportID);
- }, [isLoadingOlderReportActions, network.isOffline, oldestReportAction, reportID]);
+ Report.getOlderActions(reportID, oldestReportAction.reportActionID);
+ }, [network.isOffline, isLoadingOlderReportActions, isLoadingInitialReportActions, oldestReportAction, hasCreatedAction, reportID]);
- /**
- * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently
- * displaying.
- */
- const loadNewerChats: LoadNewerChats = useMemo(
- () =>
- lodashThrottle(({distanceFromStart}) => {
- if (isLoadingNewerReportActions || isLoadingInitialReportActions || hasNewestReportAction) {
- return;
- }
-
- // Ideally, we wouldn't need to use the 'distanceFromStart' variable. However, due to the low value set for 'maxToRenderPerBatch',
- // the component undergoes frequent re-renders. This frequent re-rendering triggers the 'onStartReached' callback multiple times.
- //
- // To mitigate this issue, we use 'CONST.CHAT_HEADER_LOADER_HEIGHT' as a threshold. This ensures that 'onStartReached' is not
- // triggered unnecessarily when the chat is initially opened or when the user has reached the end of the list but hasn't scrolled further.
- //
- // Additionally, we use throttling on the 'onStartReached' callback to further reduce the frequency of its invocation.
- // This should be removed once the issue of frequent re-renders is resolved.
- //
- // onStartReached is triggered during the first render. Since we use OpenReport on the first render and are confident about the message ordering, we can safely skip this call
- if (isFirstRender.current || distanceFromStart <= CONST.CHAT_HEADER_LOADER_HEIGHT) {
- isFirstRender.current = false;
- return;
- }
-
- Report.getNewerActions(reportID);
- }, 500),
- [isLoadingNewerReportActions, isLoadingInitialReportActions, reportID, hasNewestReportAction],
- );
+ const loadNewerChats = useCallback(() => {
+ if (isLoadingInitialReportActions || isLoadingOlderReportActions || network.isOffline || newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
+ return;
+ }
+ // Determines if loading older reports is necessary when the content is smaller than the list
+ // and there are fewer than 23 items, indicating we've reached the oldest message.
+ const isLoadingOlderReportsFirstNeeded = checkIfContentSmallerThanList() && reportActions.length > 23;
+
+ if (
+ (reportActionID && indexOfLinkedAction > -1 && !hasNewestReportAction && !isLoadingOlderReportsFirstNeeded) ||
+ (!reportActionID && !hasNewestReportAction && !isLoadingOlderReportsFirstNeeded)
+ ) {
+ handleReportActionPagination({firstReportActionID: newestReportAction?.reportActionID});
+ }
+ }, [
+ isLoadingInitialReportActions,
+ isLoadingOlderReportActions,
+ checkIfContentSmallerThanList,
+ reportActionID,
+ indexOfLinkedAction,
+ hasNewestReportAction,
+ handleReportActionPagination,
+ network.isOffline,
+ reportActions.length,
+ newestReportAction,
+ ]);
/**
* Runs when the FlatList finishes laying out
@@ -224,7 +340,7 @@ function ReportActionsView({
}
didLayout.current = true;
- Timing.end(CONST.TIMING.SWITCH_REPORT, hasCachedActions ? CONST.TIMING.WARM : CONST.TIMING.COLD);
+ Timing.end(CONST.TIMING.SWITCH_REPORT, hasCachedActionOnFirstRender ? CONST.TIMING.WARM : CONST.TIMING.COLD);
// Capture the init measurement only once not per each chat switch as the value gets overwritten
if (!ReportActionsView.initMeasured) {
@@ -233,12 +349,68 @@ function ReportActionsView({
} else {
Performance.markEnd(CONST.TIMING.SWITCH_REPORT);
}
- }, [hasCachedActions]);
+ }, [hasCachedActionOnFirstRender]);
+
+ useEffect(() => {
+ // Temporary solution for handling REPORTPREVIEW. More details: https://expensify.slack.com/archives/C035J5C9FAP/p1705417778466539?thread_ts=1705035404.136629&cid=C035J5C9FAP
+ // This code should be removed once REPORTPREVIEW is no longer repositioned.
+ // We need to call openReport for gaps created by moving REPORTPREVIEW, which causes mismatches in previousReportActionID and reportActionID of adjacent reportActions. The server returns the correct sequence, allowing us to overwrite incorrect data with the correct one.
+ const shouldOpenReport =
+ newestReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW &&
+ !hasCreatedAction &&
+ isReadyForCommentLinking &&
+ reportActions.length < 24 &&
+ reportActions.length >= 1 &&
+ !isLoadingInitialReportActions &&
+ !isLoadingOlderReportActions &&
+ !isLoadingNewerReportActions;
+
+ if (shouldOpenReport) {
+ Report.openReport(reportID, reportActionID);
+ }
+ }, [
+ hasCreatedAction,
+ reportID,
+ reportActions,
+ reportActionID,
+ newestReportAction?.actionName,
+ isReadyForCommentLinking,
+ isLoadingOlderReportActions,
+ isLoadingNewerReportActions,
+ isLoadingInitialReportActions,
+ ]);
+
+ // Check if the first report action in the list is the one we're currently linked to
+ const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID;
+
+ useEffect(() => {
+ let timerID: NodeJS.Timeout;
+
+ if (isTheFirstReportActionIsLinked) {
+ setNavigatingToLinkedMessage(true);
+ } else {
+ // After navigating to the linked reportAction, apply this to correctly set
+ // `autoscrollToTopThreshold` prop when linking to a specific reportAction.
+ InteractionManager.runAfterInteractions(() => {
+ // Using a short delay to ensure the view is updated after interactions
+ timerID = setTimeout(() => setNavigatingToLinkedMessage(false), 10);
+ });
+ }
+
+ return () => {
+ if (!timerID) {
+ return;
+ }
+ clearTimeout(timerID);
+ };
+ }, [isTheFirstReportActionIsLinked]);
// Comments have not loaded at all yet do nothing
if (!reportActions.length) {
return null;
}
+ // AutoScroll is disabled when we do linking to a specific reportAction
+ const shouldEnableAutoScroll = hasNewestReportAction && (!reportActionID || !isNavigatingToLinkedMessage);
return (
<>
@@ -253,6 +425,9 @@ function ReportActionsView({
isLoadingInitialReportActions={isLoadingInitialReportActions}
isLoadingOlderReportActions={isLoadingOlderReportActions}
isLoadingNewerReportActions={isLoadingNewerReportActions}
+ listID={listID}
+ onContentSizeChange={onContentSizeChange}
+ shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScroll}
/>
>
@@ -263,6 +438,9 @@ ReportActionsView.displayName = 'ReportActionsView';
ReportActionsView.initMeasured = false;
function arePropsEqual(oldProps: ReportActionsViewProps, newProps: ReportActionsViewProps): boolean {
+ if (!lodashIsEqual(oldProps.isReadyForCommentLinking, newProps.isReadyForCommentLinking)) {
+ return false;
+ }
if (!lodashIsEqual(oldProps.reportActions, newProps.reportActions)) {
return false;
}
diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx
index 951888a443c1..7ff413f554b8 100644
--- a/src/pages/home/report/comment/TextCommentFragment.tsx
+++ b/src/pages/home/report/comment/TextCommentFragment.tsx
@@ -16,6 +16,7 @@ import CONST from '@src/CONST';
import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage';
import type {Message} from '@src/types/onyx/ReportAction';
import RenderCommentHTML from './RenderCommentHTML';
+import shouldRenderAsText from './shouldRenderAsText';
type TextCommentFragmentProps = {
/** The reportAction's source */
@@ -47,17 +48,17 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
- // If the only difference between fragment.text and fragment.html is
tags
- // we render it as text, not as html.
- // This is done to render emojis with line breaks between them as text.
- const differByLineBreaksOnly = Str.replaceAll(html, '
', '\n') === text;
-
- // Only render HTML if we have html in the fragment
- if (!differByLineBreaksOnly) {
+ // If the only difference between fragment.text and fragment.html is
tags and emoji tag
+ // on native, we render it as text, not as html
+ // on other device, only render it as text if the only difference is
tag
+ const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text);
+ if (!shouldRenderAsText(html, text) && !(containsOnlyEmojis && styleAsDeleted)) {
const editedTag = fragment.isEdited ? `` : '';
- const htmlContent = styleAsDeleted ? `${html}` : html;
+ const htmlWithDeletedTag = styleAsDeleted ? `${html}` : html;
+ const htmlContent = containsOnlyEmojis ? Str.replaceAll(htmlWithDeletedTag, '', '') : htmlWithDeletedTag;
let htmlWithTag = editedTag ? `${htmlContent}${editedTag}` : htmlContent;
+
if (styleAsMuted) {
htmlWithTag = `${htmlWithTag}`;
}
@@ -70,7 +71,6 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so
);
}
- const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text);
const message = isEmpty(iouMessage) ? text : iouMessage;
return (
diff --git a/src/pages/home/report/comment/shouldRenderAsText/index.native.ts b/src/pages/home/report/comment/shouldRenderAsText/index.native.ts
new file mode 100644
index 000000000000..7c5758f8720d
--- /dev/null
+++ b/src/pages/home/report/comment/shouldRenderAsText/index.native.ts
@@ -0,0 +1,12 @@
+import Str from 'expensify-common/lib/str';
+
+/**
+ * Whether to render the report action as text
+ */
+export default function shouldRenderAsText(html: string, text: string): boolean {
+ // On native, we render emoji as text to prevent the large emoji is cut off when the action is edited.
+ // More info: https://github.com/Expensify/App/pull/35838#issuecomment-1964839350
+ const htmlWithoutLineBreak = Str.replaceAll(html, '
', '\n');
+ const htmlWithoutEmojiOpenTag = Str.replaceAll(htmlWithoutLineBreak, '', '');
+ return Str.replaceAll(htmlWithoutEmojiOpenTag, '', '') === text;
+}
diff --git a/src/pages/home/report/comment/shouldRenderAsText/index.ts b/src/pages/home/report/comment/shouldRenderAsText/index.ts
new file mode 100644
index 000000000000..f26f43c528eb
--- /dev/null
+++ b/src/pages/home/report/comment/shouldRenderAsText/index.ts
@@ -0,0 +1,8 @@
+import Str from 'expensify-common/lib/str';
+
+/**
+ * Whether to render the report action as text
+ */
+export default function shouldRenderAsText(html: string, text: string): boolean {
+ return Str.replaceAll(html, '
', '\n') === text;
+}
diff --git a/src/pages/home/report/getInitialNumReportActionsToRender/index.native.ts b/src/pages/home/report/getInitialNumReportActionsToRender/index.native.ts
new file mode 100644
index 000000000000..4d0986216e59
--- /dev/null
+++ b/src/pages/home/report/getInitialNumReportActionsToRender/index.native.ts
@@ -0,0 +1,4 @@
+function getInitialNumToRender(numToRender: number): number {
+ return numToRender;
+}
+export default getInitialNumToRender;
diff --git a/src/pages/home/report/getInitialNumReportActionsToRender/index.ts b/src/pages/home/report/getInitialNumReportActionsToRender/index.ts
new file mode 100644
index 000000000000..cb1f0dfdcded
--- /dev/null
+++ b/src/pages/home/report/getInitialNumReportActionsToRender/index.ts
@@ -0,0 +1,7 @@
+const DEFAULT_NUM_TO_RENDER = 50;
+
+function getInitialNumToRender(numToRender: number): number {
+ // For web and desktop environments, it's crucial to set this value equal to or higher than the maxToRenderPerBatch setting. If it's set lower, the 'onStartReached' event will be triggered excessively, every time an additional item enters the virtualized list.
+ return Math.max(numToRender, DEFAULT_NUM_TO_RENDER);
+}
+export default getInitialNumToRender;
diff --git a/src/pages/home/report/getInitialPaginationSize/index.native.ts b/src/pages/home/report/getInitialPaginationSize/index.native.ts
new file mode 100644
index 000000000000..195448f7e450
--- /dev/null
+++ b/src/pages/home/report/getInitialPaginationSize/index.native.ts
@@ -0,0 +1,3 @@
+import CONST from '@src/CONST';
+
+export default CONST.MOBILE_PAGINATION_SIZE;
diff --git a/src/pages/home/report/getInitialPaginationSize/index.ts b/src/pages/home/report/getInitialPaginationSize/index.ts
new file mode 100644
index 000000000000..87ec6856aa20
--- /dev/null
+++ b/src/pages/home/report/getInitialPaginationSize/index.ts
@@ -0,0 +1,3 @@
+import CONST from '@src/CONST';
+
+export default CONST.WEB_PAGINATION_SIZE;
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
index ec27112ab4b7..abf932eff96d 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
@@ -9,6 +9,7 @@ import withNavigation from '@components/withNavigation';
import withNavigationFocus from '@components/withNavigationFocus';
import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
@@ -75,6 +76,7 @@ function FloatingActionButtonAndPopover(props) {
const {translate} = useLocalize();
const [isCreateMenuActive, setIsCreateMenuActive] = useState(false);
const fabRef = useRef(null);
+ const {canUseTrackExpense} = usePermissions();
const prevIsFocused = usePrevious(props.isFocused);
@@ -187,13 +189,28 @@ function FloatingActionButtonAndPopover(props) {
),
),
},
- ...[
- {
- icon: Expensicons.Task,
- text: translate('newTaskPage.assignTask'),
- onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()),
- },
- ],
+ ...(canUseTrackExpense
+ ? [
+ {
+ icon: Expensicons.DocumentPlus,
+ text: translate('iou.trackExpense'),
+ onSelected: () =>
+ interceptAnonymousUser(() =>
+ IOU.startMoneyRequest(
+ CONST.IOU.TYPE.TRACK_EXPENSE,
+ // When starting to create a track expense from the global FAB, we need to retrieve selfDM reportID.
+ // If it doesn't exist, we generate a random optimistic reportID and use it for all of the routes in the creation flow.
+ ReportUtils.findSelfDMReportID() || ReportUtils.generateReportID(),
+ ),
+ ),
+ },
+ ]
+ : []),
+ {
+ icon: Expensicons.Task,
+ text: translate('newTaskPage.assignTask'),
+ onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()),
+ },
{
icon: Expensicons.Heart,
text: translate('sidebarScreen.saveTheWorld'),
diff --git a/src/pages/iou/MoneyRequestWaypointPage.tsx b/src/pages/iou/MoneyRequestWaypointPage.tsx
index c21aae7cf063..dd65b76c8d38 100644
--- a/src/pages/iou/MoneyRequestWaypointPage.tsx
+++ b/src/pages/iou/MoneyRequestWaypointPage.tsx
@@ -20,6 +20,7 @@ function MoneyRequestWaypointPage({transactionID = '', route}: MoneyRequestWaypo
// Put the transactionID into the route params so that WaypointEdit behaves the same when creating a new waypoint
// or editing an existing waypoint.
route={{
+ ...route,
params: {
...route.params,
transactionID,
diff --git a/src/pages/iou/request/IOURequestStartPage.js b/src/pages/iou/request/IOURequestStartPage.js
index 589808824285..cb078fac133c 100644
--- a/src/pages/iou/request/IOURequestStartPage.js
+++ b/src/pages/iou/request/IOURequestStartPage.js
@@ -78,6 +78,7 @@ function IOURequestStartPage({
[CONST.IOU.TYPE.REQUEST]: translate('iou.requestMoney'),
[CONST.IOU.TYPE.SEND]: translate('iou.sendMoney'),
[CONST.IOU.TYPE.SPLIT]: translate('iou.splitBill'),
+ [CONST.IOU.TYPE.TRACK_EXPENSE]: translate('iou.trackExpense'),
};
const transactionRequestType = useRef(TransactionUtils.getRequestType(transaction));
const previousIOURequestType = usePrevious(transactionRequestType.current);
@@ -109,7 +110,7 @@ function IOURequestStartPage({
const isExpenseChat = ReportUtils.isPolicyExpenseChat(report);
const isExpenseReport = ReportUtils.isExpenseReport(report);
- const shouldDisplayDistanceRequest = canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || isFromGlobalCreate;
+ const shouldDisplayDistanceRequest = iouType !== CONST.IOU.TYPE.TRACK_EXPENSE && (canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || isFromGlobalCreate);
// Allow the user to create the request if we are creating the request in global menu or the report can create the request
const isAllowedToCreateRequest = _.isEmpty(report.reportID) || ReportUtils.canCreateRequest(report, policy, iouType);
@@ -157,7 +158,7 @@ function IOURequestStartPage({
title={tabTitles[iouType]}
onBackButtonPress={navigateBack}
/>
- {iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SPLIT ? (
+ {iouType !== CONST.IOU.TYPE.SEND ? (
{
- onParticipantsAdded([
- {
- ..._.pick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText'),
- selected: true,
- },
- ]);
- onFinish();
- };
+ const addSingleParticipant = useCallback(
+ (option) => {
+ onParticipantsAdded([
+ {
+ ..._.pick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText'),
+ selected: true,
+ },
+ ]);
+ onFinish();
+ },
+ [onFinish, onParticipantsAdded],
+ );
/**
* Removes a selected option from list if already selected. If not already selected add this option to the list.
@@ -257,13 +260,22 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({
const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant;
const isAllowedToSplit = (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && iouType !== CONST.IOU.TYPE.SEND;
- const handleConfirmSelection = useCallback(() => {
- if (shouldShowSplitBillErrorMessage) {
- return;
- }
+ const handleConfirmSelection = useCallback(
+ (keyEvent, option) => {
+ const shouldAddSingleParticipant = option && !participants.length;
+ if (shouldShowSplitBillErrorMessage || (!participants.length && !option)) {
+ return;
+ }
- onFinish(CONST.IOU.TYPE.SPLIT);
- }, [shouldShowSplitBillErrorMessage, onFinish]);
+ if (shouldAddSingleParticipant) {
+ addSingleParticipant(option);
+ return;
+ }
+
+ onFinish(CONST.IOU.TYPE.SPLIT);
+ },
+ [shouldShowSplitBillErrorMessage, onFinish, addSingleParticipant, participants],
+ );
const footerContent = useMemo(
() => (
@@ -360,8 +372,7 @@ MoneyTemporaryForRefactorRequestParticipantsSelector.displayName = 'MoneyTempora
export default withOnyx({
dismissedReferralBanners: {
- key: ONYXKEYS.ACCOUNT,
- selector: (data) => data.dismissedReferralBanners || {},
+ key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS,
},
reports: {
key: ONYXKEYS.COLLECTION.REPORT,
diff --git a/src/pages/iou/request/step/IOURequestStepCategory.js b/src/pages/iou/request/step/IOURequestStepCategory.js
index 38f3f8803c53..4f0c77480c04 100644
--- a/src/pages/iou/request/step/IOURequestStepCategory.js
+++ b/src/pages/iou/request/step/IOURequestStepCategory.js
@@ -15,6 +15,8 @@ import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import * as TransactionUtils from '@libs/TransactionUtils';
+import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
import reportPropTypes from '@pages/reportPropTypes';
import * as IOU from '@userActions/IOU';
import CONST from '@src/CONST';
@@ -47,6 +49,18 @@ const propTypes = {
/** Collection of tags attached to a policy */
policyTags: tagPropTypes,
+
+ /** The actions from the parent report */
+ reportActions: PropTypes.shape(reportActionPropTypes),
+
+ /** Session info for the currently logged in user. */
+ session: PropTypes.shape({
+ /** Currently logged in user accountID */
+ accountID: PropTypes.number,
+
+ /** Currently logged in user email */
+ email: PropTypes.string,
+ }).isRequired,
};
const defaultProps = {
@@ -56,18 +70,21 @@ const defaultProps = {
policy: null,
policyTags: null,
policyCategories: null,
+ reportActions: {},
};
function IOURequestStepCategory({
report,
route: {
- params: {transactionID, backTo, action, iouType},
+ params: {transactionID, backTo, action, iouType, reportActionID},
},
transaction,
splitDraftTransaction,
policy,
policyTags,
policyCategories,
+ session,
+ reportActions,
}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -75,9 +92,12 @@ function IOURequestStepCategory({
const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT;
const {category: transactionCategory} = ReportUtils.getTransactionDetails(isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction);
- const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report);
+ const reportAction = reportActions[report.parentReportActionID || reportActionID];
+ const shouldShowCategory = ReportUtils.isGroupPolicy(report) && (transactionCategory || OptionsListUtils.hasEnabledOptions(_.values(policyCategories)));
+ const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT;
+ const canEditSplitBill = isSplitBill && reportAction && session.accountID === reportAction.actorAccountID && TransactionUtils.areRequiredFieldsEmpty(transaction);
// eslint-disable-next-line rulesdir/no-negated-variables
- const shouldShowNotFoundPage = !isPolicyExpenseChat || (!transactionCategory && !OptionsListUtils.hasEnabledOptions(_.values(policyCategories)));
+ const shouldShowNotFoundPage = !shouldShowCategory || (isEditing && (isSplitBill ? !canEditSplitBill : !ReportUtils.canEditMoneyRequest(reportAction)));
const navigateBack = () => {
Navigation.goBack(backTo);
@@ -149,5 +169,23 @@ export default compose(
policyTags: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`,
},
+ reportActions: {
+ key: ({
+ report,
+ route: {
+ params: {action, iouType},
+ },
+ }) => {
+ let reportID = '0';
+ if (action === CONST.IOU.ACTION.EDIT) {
+ reportID = iouType === CONST.IOU.TYPE.SPLIT ? report.reportID : report.parentReportID;
+ }
+ return `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`;
+ },
+ canEvict: false,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
}),
)(IOURequestStepCategory);
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js
index 2c869354d96f..3dd6f08c0ce0 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.js
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js
@@ -100,6 +100,9 @@ function IOURequestStepConfirmation({
if (iouType === CONST.IOU.TYPE.SPLIT) {
return translate('iou.split');
}
+ if (iouType === CONST.IOU.TYPE.TRACK_EXPENSE) {
+ return translate('iou.trackExpense');
+ }
if (iouType === CONST.IOU.TYPE.SEND) {
return translate('common.send');
}
@@ -109,8 +112,8 @@ function IOURequestStepConfirmation({
const participants = useMemo(
() =>
_.map(transaction.participants, (participant) => {
- const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false);
- return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails);
+ const participantReportID = lodashGet(participant, 'reportID', '');
+ return participantReportID ? OptionsListUtils.getReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails);
}),
[transaction.participants, personalDetails],
);
@@ -131,7 +134,7 @@ function IOURequestStepConfirmation({
if (policyExpenseChat) {
Policy.openDraftWorkspaceRequest(policyExpenseChat.policyID);
}
- }, [participants, transaction.billable, policy, transactionID]);
+ }, [isOffline, participants, transaction.billable, policy, transactionID]);
const defaultBillable = lodashGet(policy, 'defaultBillable', false);
useEffect(() => {
@@ -187,13 +190,6 @@ function IOURequestStepConfirmation({
IOU.navigateToStartStepIfScanFileCannotBeRead(receiptFilename, receiptPath, onSuccess, requestType, iouType, transactionID, reportID, receiptType);
}, [receiptType, receiptPath, receiptFilename, requestType, iouType, transactionID, reportID]);
- useEffect(() => {
- const policyExpenseChat = _.find(participants, (participant) => participant.isPolicyExpenseChat);
- if (policyExpenseChat) {
- Policy.openDraftWorkspaceRequest(policyExpenseChat.policyID);
- }
- }, [isOffline, participants, transaction.billable, policy]);
-
/**
* @param {Array} selectedParticipants
* @param {String} trimmedComment
@@ -226,6 +222,54 @@ function IOURequestStepConfirmation({
[report, transaction, transactionTaxCode, transactionTaxAmount, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, policy, policyTags, policyCategories],
);
+ /**
+ * @param {Array} selectedParticipants
+ * @param {String} trimmedComment
+ * @param {File} [receiptObj]
+ */
+ const trackExpense = useCallback(
+ (selectedParticipants, trimmedComment, receiptObj, gpsPoints) => {
+ IOU.trackExpense(
+ report,
+ transaction.amount,
+ transaction.currency,
+ transaction.created,
+ transaction.merchant,
+ currentUserPersonalDetails.login,
+ currentUserPersonalDetails.accountID,
+ selectedParticipants[0],
+ trimmedComment,
+ receiptObj,
+ transaction.category,
+ transaction.tag,
+ transactionTaxCode,
+ transactionTaxAmount,
+ transaction.billable,
+ policy,
+ policyTags,
+ policyCategories,
+ gpsPoints,
+ );
+ },
+ [
+ report,
+ transaction.amount,
+ transaction.currency,
+ transaction.created,
+ transaction.merchant,
+ transaction.category,
+ transaction.tag,
+ transaction.billable,
+ currentUserPersonalDetails.login,
+ currentUserPersonalDetails.accountID,
+ transactionTaxCode,
+ transactionTaxAmount,
+ policy,
+ policyTags,
+ policyCategories,
+ ],
+ );
+
/**
* @param {Array} selectedParticipants
* @param {String} trimmedComment
@@ -319,6 +363,41 @@ function IOURequestStepConfirmation({
return;
}
+ if (iouType === CONST.IOU.TYPE.TRACK_EXPENSE) {
+ if (receiptFile) {
+ // If the transaction amount is zero, then the money is being requested through the "Scan" flow and the GPS coordinates need to be included.
+ if (transaction.amount === 0) {
+ getCurrentPosition(
+ (successData) => {
+ trackExpense(selectedParticipants, trimmedComment, receiptFile, {
+ lat: successData.coords.latitude,
+ long: successData.coords.longitude,
+ });
+ },
+ (errorData) => {
+ Log.info('[IOURequestStepConfirmation] getCurrentPosition failed', false, errorData);
+ // When there is an error, the money can still be requested, it just won't include the GPS coordinates
+ trackExpense(selectedParticipants, trimmedComment, receiptFile);
+ },
+ {
+ // It's OK to get a cached location that is up to an hour old because the only accuracy needed is the country the user is in
+ maximumAge: 1000 * 60 * 60,
+
+ // 15 seconds, don't wait too long because the server can always fall back to using the IP address
+ timeout: 15000,
+ },
+ );
+ return;
+ }
+
+ // Otherwise, the money is being requested through the "Manual" flow with an attached image and the GPS coordinates are not needed.
+ trackExpense(selectedParticipants, trimmedComment, receiptFile);
+ return;
+ }
+ trackExpense(selectedParticipants, trimmedComment, receiptFile);
+ return;
+ }
+
if (receiptFile) {
// If the transaction amount is zero, then the money is being requested through the "Scan" flow and the GPS coordinates need to be included.
if (transaction.amount === 0) {
@@ -357,7 +436,18 @@ function IOURequestStepConfirmation({
requestMoney(selectedParticipants, trimmedComment);
},
- [iouType, transaction, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, report, requestType, createDistanceRequest, requestMoney, receiptFile],
+ [
+ transaction,
+ iouType,
+ receiptFile,
+ requestType,
+ requestMoney,
+ currentUserPersonalDetails.login,
+ currentUserPersonalDetails.accountID,
+ report.reportID,
+ trackExpense,
+ createDistanceRequest,
+ ],
);
/**
@@ -417,7 +507,7 @@ function IOURequestStepConfirmation({
`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`,
},
+ reportActions: {
+ key: ({
+ report,
+ route: {
+ params: {action, iouType},
+ },
+ }) => {
+ let reportID = '0';
+ if (action === CONST.IOU.ACTION.EDIT) {
+ reportID = iouType === CONST.IOU.TYPE.SPLIT ? report.reportID : report.parentReportID;
+ }
+ return `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`;
+ },
+ canEvict: false,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
}),
)(IOURequestStepDescription);
diff --git a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/index.js b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/index.js
index 10b16da13b6e..37223915f4a2 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/index.js
+++ b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/index.js
@@ -1,61 +1,20 @@
import PropTypes from 'prop-types';
-import React, {useEffect, useRef} from 'react';
+import React from 'react';
import {View} from 'react-native';
import Webcam from 'react-webcam';
import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus';
const propTypes = {
- /** Flag to turn on/off the torch/flashlight - if available */
- torchOn: PropTypes.bool,
-
/** The index of the tab that contains this camera */
cameraTabIndex: PropTypes.number.isRequired,
-
- /** Callback function when media stream becomes available - user granted camera permissions and camera starts to work */
- onUserMedia: PropTypes.func,
-
- /** Callback function passing torch/flashlight capability as bool param of the browser */
- onTorchAvailability: PropTypes.func,
-};
-
-const defaultProps = {
- onUserMedia: undefined,
- onTorchAvailability: undefined,
- torchOn: false,
};
// Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused.
-const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, cameraTabIndex, ...props}, ref) => {
- const trackRef = useRef(null);
+const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, ...props}, ref) => {
const shouldShowCamera = useTabNavigatorFocus({
tabIndex: cameraTabIndex,
});
- const handleOnUserMedia = (stream) => {
- if (props.onUserMedia) {
- props.onUserMedia(stream);
- }
-
- const [track] = stream.getVideoTracks();
- const capabilities = track.getCapabilities();
- if (capabilities.torch) {
- trackRef.current = track;
- }
- if (onTorchAvailability) {
- onTorchAvailability(!!capabilities.torch);
- }
- };
-
- useEffect(() => {
- if (!trackRef.current) {
- return;
- }
-
- trackRef.current.applyConstraints({
- advanced: [{torch: torchOn}],
- });
- }, [torchOn]);
-
if (!shouldShowCamera) {
return null;
}
@@ -67,7 +26,6 @@ const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, c
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={ref}
- onUserMedia={handleOnUserMedia}
/>
);
@@ -75,6 +33,5 @@ const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, c
NavigationAwareCamera.propTypes = propTypes;
NavigationAwareCamera.displayName = 'NavigationAwareCamera';
-NavigationAwareCamera.defaultProps = defaultProps;
export default NavigationAwareCamera;
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.js b/src/pages/iou/request/step/IOURequestStepScan/index.js
index 7b1a5936a4ef..6bf517c30eb0 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.js
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.js
@@ -76,6 +76,9 @@ function IOURequestStepScan({
const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false);
const [isTorchAvailable, setIsTorchAvailable] = useState(false);
const cameraRef = useRef(null);
+ const trackRef = useRef(null);
+
+ const getScreenshotTimeoutRef = useRef(null);
const [videoConstraints, setVideoConstraints] = useState(null);
const tabIndex = 1;
@@ -90,38 +93,42 @@ function IOURequestStepScan({
return;
}
- navigator.mediaDevices.getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}}).then((stream) => {
- _.forEach(stream.getTracks(), (track) => track.stop());
- // Only Safari 17+ supports zoom constraint
- if (Browser.isMobileSafari() && stream.getTracks().length > 0) {
- const deviceId = _.chain(stream.getTracks())
- .map((track) => track.getSettings())
- .find((setting) => setting.zoom === 1)
- .get('deviceId')
- .value();
- if (deviceId) {
- setVideoConstraints({deviceId});
- return;
+ const defaultConstraints = {facingMode: {exact: 'environment'}};
+ navigator.mediaDevices
+ .getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}})
+ .then((stream) => {
+ _.forEach(stream.getTracks(), (track) => track.stop());
+ // Only Safari 17+ supports zoom constraint
+ if (Browser.isMobileSafari() && stream.getTracks().length > 0) {
+ const deviceId = _.chain(stream.getTracks())
+ .map((track) => track.getSettings())
+ .find((setting) => setting.zoom === 1)
+ .get('deviceId')
+ .value();
+ if (deviceId) {
+ setVideoConstraints({deviceId});
+ return;
+ }
}
- }
- if (!navigator.mediaDevices.enumerateDevices) {
- setVideoConstraints({facingMode: {exact: 'environment'}});
- return;
- }
- navigator.mediaDevices.enumerateDevices().then((devices) => {
- const lastBackDeviceId = _.chain(devices)
- .filter((item) => item.kind === 'videoinput')
- .last()
- .get('deviceId', '')
- .value();
-
- if (!lastBackDeviceId) {
- setVideoConstraints({facingMode: {exact: 'environment'}});
+ if (!navigator.mediaDevices.enumerateDevices) {
+ setVideoConstraints(defaultConstraints);
return;
}
- setVideoConstraints({deviceId: lastBackDeviceId});
- });
- });
+ navigator.mediaDevices.enumerateDevices().then((devices) => {
+ const lastBackDeviceId = _.chain(devices)
+ .filter((item) => item.kind === 'videoinput')
+ .last()
+ .get('deviceId', '')
+ .value();
+
+ if (!lastBackDeviceId) {
+ setVideoConstraints(defaultConstraints);
+ return;
+ }
+ setVideoConstraints({deviceId: lastBackDeviceId});
+ });
+ })
+ .catch(() => setVideoConstraints(defaultConstraints));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTabActive]);
@@ -172,7 +179,7 @@ function IOURequestStepScan({
}
// If the transaction was created from the global create, the person needs to select participants, so take them there.
- if (isFromGlobalCreate) {
+ if (isFromGlobalCreate && iouType !== CONST.IOU.TYPE.TRACK_EXPENSE) {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
return;
}
@@ -212,11 +219,24 @@ function IOURequestStepScan({
navigateToConfirmationStep();
};
- const capturePhoto = useCallback(() => {
- if (!cameraRef.current.getScreenshot) {
+ const setupCameraPermissionsAndCapabilities = (stream) => {
+ setCameraPermissionState('granted');
+
+ const [track] = stream.getVideoTracks();
+ const capabilities = track.getCapabilities();
+ if (capabilities.torch) {
+ trackRef.current = track;
+ }
+ setIsTorchAvailable(!!capabilities.torch);
+ };
+
+ const getScreenshot = useCallback(() => {
+ if (!cameraRef.current) {
return;
}
+
const imageBase64 = cameraRef.current.getScreenshot();
+
const filename = `receipt_${Date.now()}.png`;
const file = FileUtils.base64ToFile(imageBase64, filename);
const source = URL.createObjectURL(file);
@@ -228,7 +248,34 @@ function IOURequestStepScan({
}
navigateToConfirmationStep();
- }, [cameraRef, action, transactionID, updateScanAndNavigate, navigateToConfirmationStep]);
+ }, [action, transactionID, updateScanAndNavigate, navigateToConfirmationStep]);
+
+ const clearTorchConstraints = useCallback(() => {
+ if (!trackRef.current) {
+ return;
+ }
+ trackRef.current.applyConstraints({
+ advanced: [{torch: false}],
+ });
+ }, []);
+
+ const capturePhoto = useCallback(() => {
+ if (trackRef.current && isFlashLightOn) {
+ trackRef.current
+ .applyConstraints({
+ advanced: [{torch: true}],
+ })
+ .then(() => {
+ getScreenshotTimeoutRef.current = setTimeout(() => {
+ getScreenshot();
+ clearTorchConstraints();
+ }, 2000);
+ });
+ return;
+ }
+
+ getScreenshot();
+ }, [isFlashLightOn, getScreenshot, clearTorchConstraints]);
const panResponder = useRef(
PanResponder.create({
@@ -236,6 +283,16 @@ function IOURequestStepScan({
}),
).current;
+ useEffect(
+ () => () => {
+ if (!getScreenshotTimeoutRef.current) {
+ return;
+ }
+ clearTimeout(getScreenshotTimeoutRef.current);
+ },
+ [],
+ );
+
const mobileCameraView = () => (
<>
@@ -260,14 +317,12 @@ function IOURequestStepScan({
)}
{!_.isEmpty(videoConstraints) && (
setCameraPermissionState('granted')}
+ onUserMedia={setupCameraPermissionsAndCapabilities}
onUserMediaError={() => setCameraPermissionState('denied')}
style={{...styles.videoContainer, display: cameraPermissionState !== 'granted' ? 'none' : 'block'}}
ref={cameraRef}
screenshotFormat="image/png"
videoConstraints={videoConstraints}
- torchOn={isFlashLightOn}
- onTorchAvailability={setIsTorchAvailable}
forceScreenshotSourceSize
cameraTabIndex={tabIndex}
/>
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
index 8dc8c634508c..03eb12fc3b03 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
@@ -187,7 +187,7 @@ function IOURequestStepScan({
}
// If the transaction was created from the global create, the person needs to select participants, so take them there.
- if (isFromGlobalCreate) {
+ if (isFromGlobalCreate && iouType !== CONST.IOU.TYPE.TRACK_EXPENSE) {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
return;
}
diff --git a/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js
index 29263d92078f..7a75e9f48805 100644
--- a/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js
+++ b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js
@@ -131,6 +131,7 @@ function IOURequestStepTaxAmountPage({
// inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight
// to the confirm step.
if (report.reportID) {
+ // TODO: Is this really needed at all?
IOU.setMoneyRequestParticipantsFromReport(transactionID, report);
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(iouType, transactionID, reportID));
return;
diff --git a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
index 8375d9122340..c645bc70ede0 100644
--- a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
+++ b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
@@ -3,9 +3,8 @@ import React, {useMemo, useRef, useState} from 'react';
import type {TextInput} from 'react-native';
import {View} from 'react-native';
import type {Place} from 'react-native-google-places-autocomplete';
-import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import type {ValueOf} from 'type-fest';
+import type {OnyxEntry} from 'react-native-onyx';
import AddressSearch from '@components/AddressSearch';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import ConfirmModal from '@components/ConfirmModal';
@@ -27,12 +26,12 @@ import * as Transaction from '@userActions/Transaction';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Route as Routes} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
import type {Waypoint} from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
+import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound';
import withWritableReportOrNotFound from './withWritableReportOrNotFound';
type IOURequestStepWaypointOnyxProps = {
@@ -43,18 +42,9 @@ type IOURequestStepWaypointOnyxProps = {
};
type IOURequestStepWaypointProps = {
- route: {
- params: {
- iouType: ValueOf;
- transactionID: string;
- reportID: string;
- backTo: Routes | undefined;
- action: ValueOf;
- pageIndex: string;
- };
- };
transaction: OnyxEntry;
-} & IOURequestStepWaypointOnyxProps;
+} & IOURequestStepWaypointOnyxProps &
+ WithWritableReportOrNotFoundProps;
function IOURequestStepWaypoint({
route: {
diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.js b/src/pages/iou/request/step/withWritableReportOrNotFound.js
deleted file mode 100644
index 978b84f321d1..000000000000
--- a/src/pages/iou/request/step/withWritableReportOrNotFound.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React from 'react';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
-import getComponentDisplayName from '@libs/getComponentDisplayName';
-import * as ReportUtils from '@libs/ReportUtils';
-import reportPropTypes from '@pages/reportPropTypes';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import IOURequestStepRoutePropTypes from './IOURequestStepRoutePropTypes';
-
-const propTypes = {
- /** The HOC takes an optional ref as a prop and passes it as a ref to the wrapped component.
- * That way, if a ref is passed to a component wrapped in the HOC, the ref is a reference to the wrapped component, not the HOC. */
- forwardedRef: PropTypes.func,
-
- /** The report corresponding to the reportID in the route params */
- report: reportPropTypes,
-
- route: IOURequestStepRoutePropTypes.isRequired,
-};
-
-const defaultProps = {
- forwardedRef: () => {},
- report: {},
-};
-
-export default function (WrappedComponent) {
- // eslint-disable-next-line rulesdir/no-negated-variables
- function WithWritableReportOrNotFound({forwardedRef, ...props}) {
- const {
- route: {
- params: {iouType},
- },
- report,
- } = props;
-
- const iouTypeParamIsInvalid = !_.contains(_.values(CONST.IOU.TYPE), iouType);
- const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report);
- if (iouTypeParamIsInvalid || !canUserPerformWriteAction) {
- return ;
- }
-
- return (
-
- );
- }
-
- WithWritableReportOrNotFound.propTypes = propTypes;
- WithWritableReportOrNotFound.defaultProps = defaultProps;
- WithWritableReportOrNotFound.displayName = `withWritableReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`;
-
- // eslint-disable-next-line rulesdir/no-negated-variables
- const WithWritableReportOrNotFoundWithRef = React.forwardRef((props, ref) => (
-
- ));
-
- WithWritableReportOrNotFoundWithRef.displayName = 'WithWritableReportOrNotFoundWithRef';
-
- return withOnyx({
- report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '0')}`,
- },
- })(WithWritableReportOrNotFoundWithRef);
-}
diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx
new file mode 100644
index 000000000000..d5d27d8268b1
--- /dev/null
+++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx
@@ -0,0 +1,55 @@
+import type {RouteProp} from '@react-navigation/core';
+import type {ComponentType, ForwardedRef, RefAttributes} from 'react';
+import React, {forwardRef} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import getComponentDisplayName from '@libs/getComponentDisplayName';
+import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types';
+import * as ReportUtils from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import type {Report} from '@src/types/onyx';
+
+type WithWritableReportOrNotFoundOnyxProps = {
+ /** The report corresponding to the reportID in the route params */
+ report: OnyxEntry;
+};
+
+type Route = RouteProp;
+
+type WithWritableReportOrNotFoundProps = WithWritableReportOrNotFoundOnyxProps & {route: Route};
+
+export default function (
+ WrappedComponent: ComponentType>,
+): React.ComponentType, keyof WithWritableReportOrNotFoundOnyxProps>> {
+ // eslint-disable-next-line rulesdir/no-negated-variables
+ function WithWritableReportOrNotFound(props: TProps, ref: ForwardedRef) {
+ const {report = {reportID: ''}, route} = props;
+ const iouTypeParamIsInvalid = !Object.values(CONST.IOU.TYPE).includes(route.params?.iouType);
+ const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report);
+
+ if (iouTypeParamIsInvalid || !canUserPerformWriteAction) {
+ return ;
+ }
+
+ return (
+
+ );
+ }
+
+ WithWritableReportOrNotFound.displayName = `withWritableReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`;
+
+ return withOnyx, WithWritableReportOrNotFoundOnyxProps>({
+ report: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params?.reportID ?? '0'}`,
+ },
+ })(forwardRef(WithWritableReportOrNotFound));
+}
+
+export type {WithWritableReportOrNotFoundProps};
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
index fd869973d36a..f64270726f2d 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
@@ -196,25 +196,28 @@ function MoneyRequestParticipantsSelector({
*
* @param {Object} option
*/
- const addSingleParticipant = (option) => {
- if (participants.length) {
- return;
- }
- onAddParticipants(
- [
- {
- accountID: option.accountID,
- login: option.login,
- isPolicyExpenseChat: option.isPolicyExpenseChat,
- reportID: option.reportID,
- selected: true,
- searchText: option.searchText,
- },
- ],
- false,
- );
- navigateToRequest();
- };
+ const addSingleParticipant = useCallback(
+ (option) => {
+ if (participants.length) {
+ return;
+ }
+ onAddParticipants(
+ [
+ {
+ accountID: option.accountID,
+ login: option.login,
+ isPolicyExpenseChat: option.isPolicyExpenseChat,
+ reportID: option.reportID,
+ selected: true,
+ searchText: option.searchText,
+ },
+ ],
+ false,
+ );
+ navigateToRequest();
+ },
+ [navigateToRequest, onAddParticipants, participants.length],
+ );
/**
* Removes a selected option from list if already selected. If not already selected add this option to the list.
@@ -275,13 +278,23 @@ function MoneyRequestParticipantsSelector({
const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant;
const isAllowedToSplit = (canUseP2PDistanceRequests || !isDistanceRequest) && iouType !== CONST.IOU.TYPE.SEND;
- const handleConfirmSelection = useCallback(() => {
- if (shouldShowSplitBillErrorMessage) {
- return;
- }
+ const handleConfirmSelection = useCallback(
+ (keyEvent, option) => {
+ const shouldAddSingleParticipant = option && !participants.length;
- navigateToSplit();
- }, [shouldShowSplitBillErrorMessage, navigateToSplit]);
+ if (shouldShowSplitBillErrorMessage || (!participants.length && !option)) {
+ return;
+ }
+
+ if (shouldAddSingleParticipant) {
+ addSingleParticipant(option);
+ return;
+ }
+
+ navigateToSplit();
+ },
+ [shouldShowSplitBillErrorMessage, navigateToSplit, addSingleParticipant, participants.length],
+ );
const footerContent = useMemo(
() => (
@@ -373,8 +386,7 @@ MoneyRequestParticipantsSelector.defaultProps = defaultProps;
export default withOnyx({
dismissedReferralBanners: {
- key: ONYXKEYS.ACCOUNT,
- selector: (data) => data.dismissedReferralBanners || {},
+ key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS,
},
reports: {
key: ONYXKEYS.COLLECTION.REPORT,
diff --git a/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx b/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx
new file mode 100644
index 000000000000..3bcdc1fe3303
--- /dev/null
+++ b/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx
@@ -0,0 +1,74 @@
+/* eslint-disable rulesdir/no-negated-variables */
+import React, {useEffect} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import Navigation from '@libs/Navigation/Navigation';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as Policy from '@userActions/Policy';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type * as OnyxTypes from '@src/types/onyx';
+import type {PolicyFeatureName} from '@src/types/onyx/Policy';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+type FeatureEnabledAccessOrNotFoundOnyxProps = {
+ /** The report currently being looked at */
+ policy: OnyxEntry;
+
+ /** Indicated whether the report data is loading */
+ isLoadingReportData: OnyxEntry;
+};
+
+type FeatureEnabledAccessOrNotFoundComponentProps = FeatureEnabledAccessOrNotFoundOnyxProps & {
+ /** The children to render */
+ children: ((props: FeatureEnabledAccessOrNotFoundOnyxProps) => React.ReactNode) | React.ReactNode;
+
+ /** The report currently being looked at */
+ policyID: string;
+
+ /** The current feature name that the user tries to get access */
+ featureName: PolicyFeatureName;
+};
+
+function FeatureEnabledAccessOrNotFoundComponent(props: FeatureEnabledAccessOrNotFoundComponentProps) {
+ const isPolicyIDInRoute = !!props.policyID?.length;
+ const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.policy ?? {}).length || !props.policy?.id);
+ const shouldShowNotFoundPage = isEmptyObject(props.policy) || !props.policy?.id || !PolicyUtils.isPolicyFeatureEnabled(props.policy, props.featureName);
+
+ useEffect(() => {
+ if (!isPolicyIDInRoute || !isEmptyObject(props.policy)) {
+ // If the workspace is not required or is already loaded, we don't need to call the API
+ return;
+ }
+
+ Policy.openWorkspace(props.policyID, []);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isPolicyIDInRoute, props.policyID]);
+
+ if (shouldShowFullScreenLoadingIndicator) {
+ return ;
+ }
+
+ if (shouldShowNotFoundPage) {
+ return (
+ Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
+ shouldForceFullScreen
+ />
+ );
+ }
+
+ return typeof props.children === 'function' ? props.children(props) : props.children;
+}
+
+export default withOnyx({
+ policy: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID ?? ''}`,
+ },
+ isLoadingReportData: {
+ key: ONYXKEYS.IS_LOADING_REPORT_DATA,
+ },
+})(FeatureEnabledAccessOrNotFoundComponent);
diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx
index 69f2d74b6be7..d92c650fa9c7 100644
--- a/src/pages/workspace/WorkspaceNewRoomPage.tsx
+++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx
@@ -35,7 +35,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {NewRoomForm} from '@src/types/form/NewRoomForm';
import INPUT_IDS from '@src/types/form/NewRoomForm';
-import type {Account, Policy, Report as ReportType, Session} from '@src/types/onyx';
+import type {Policy, Report as ReportType, Session} from '@src/types/onyx';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
@@ -53,7 +53,7 @@ type WorkspaceNewRoomPageOnyxProps = {
session: OnyxEntry;
/** policyID for main workspace */
- activePolicyID: OnyxEntry['activePolicyID']>;
+ activePolicyID: OnyxEntry>;
};
type WorkspaceNewRoomPageProps = WorkspaceNewRoomPageOnyxProps;
@@ -343,8 +343,7 @@ export default withOnyx account?.activePolicyID ?? null,
+ key: ONYXKEYS.NVP_ACTIVE_POLICY_ID,
initialValue: null,
},
})(WorkspaceNewRoomPage);
diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx
index 4904a4f35193..244b9f85b79a 100644
--- a/src/pages/workspace/WorkspacePageWithSections.tsx
+++ b/src/pages/workspace/WorkspacePageWithSections.tsx
@@ -10,6 +10,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import ScrollViewWithContext from '@components/ScrollViewWithContext';
import useNetwork from '@hooks/useNetwork';
+import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import BankAccount from '@libs/models/BankAccount';
@@ -127,6 +128,7 @@ function WorkspacePageWithSections({
const {isSmallScreenWidth} = useWindowDimensions();
const firstRender = useRef(true);
const isFocused = useIsFocused();
+ const prevPolicy = usePrevious(policy);
useEffect(() => {
// Because isLoading is false before merging in Onyx, we need firstRender ref to display loading page as well before isLoading is change to true
@@ -143,7 +145,11 @@ function WorkspacePageWithSections({
return true;
}
- return (!isEmptyObject(policy) && !PolicyUtils.isPolicyAdmin(policy) && !shouldShowNonAdmin) || PolicyUtils.isPendingDeletePolicy(policy);
+ // We check isPendingDelete for both policy and prevPolicy to prevent the NotFound view from showing right after we delete the workspace
+ return (
+ (!isEmptyObject(policy) && !PolicyUtils.isPolicyAdmin(policy) && !shouldShowNonAdmin) ||
+ (PolicyUtils.isPendingDeletePolicy(policy) && PolicyUtils.isPendingDeletePolicy(prevPolicy))
+ );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [policy, shouldShowNonAdmin]);
diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx
index d110a5752382..d8b407d5cee9 100644
--- a/src/pages/workspace/WorkspaceProfilePage.tsx
+++ b/src/pages/workspace/WorkspaceProfilePage.tsx
@@ -20,6 +20,7 @@ import useLocalize from '@hooks/useLocalize';
import useThemeIllustrations from '@hooks/useThemeIllustrations';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
@@ -139,7 +140,7 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi
type={CONST.ICON_TYPE_WORKSPACE}
fallbackIcon={Expensicons.FallbackWorkspaceAvatar}
style={[
- isSmallScreenWidth ? styles.mb1 : styles.mb3,
+ policy?.errorFields?.avatar ?? isSmallScreenWidth ? styles.mb1 : styles.mb3,
isSmallScreenWidth ? styles.mtn17 : styles.mtn20,
styles.alignItemsStart,
styles.sectionMenuItemTopDescription,
@@ -157,7 +158,7 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi
originalFileName={policy?.originalFileName}
disabled={readOnly}
disabledStyle={styles.cursorDefault}
- errorRowStyles={undefined}
+ errorRowStyles={styles.mt3}
/>
{(!StringUtils.isEmptyString(policy?.description ?? '') || !readOnly) && (
-
+ Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.DESCRIPTION)}
+ >
)}
-
+ Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.GENERAL_SETTINGS)}
+ errorRowStyles={[styles.mt2]}
+ >
(null);
const {isSmallScreenWidth} = useWindowDimensions();
+ const session = useSession();
const policyName = policy?.name ?? '';
const id = policy?.id ?? '';
+ const adminEmail = session?.email ?? '';
const urlWithTrailingSlash = Url.addTrailingForwardSlash(environmentURL);
- const url = `${urlWithTrailingSlash}${ROUTES.WORKSPACE_PROFILE.getRoute(id)}`;
+ const url = `${urlWithTrailingSlash}${ROUTES.WORKSPACE_JOIN_USER.getRoute(id, adminEmail)}`;
+
return (
-
-
- setDeleteCategoryConfirmModalVisible(false)}
- title={translate('workspace.categories.deleteCategory')}
- prompt={translate('workspace.categories.deleteCategoryPrompt')}
- confirmText={translate('common.delete')}
- cancelText={translate('common.cancel')}
- danger
- />
-
- Policy.clearCategoryErrors(route.params.policyID, route.params.categoryName)}
- >
-
-
- {translate('workspace.categories.enableCategory')}
-
-
-
-
-
+
+ setDeleteCategoryConfirmModalVisible(false)}
+ title={translate('workspace.categories.deleteCategory')}
+ prompt={translate('workspace.categories.deleteCategoryPrompt')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
/>
-
-
+
+ Policy.clearCategoryErrors(route.params.policyID, route.params.categoryName)}
+ >
+
+
+ {translate('workspace.categories.enableCategory')}
+
+
+
+
+
+
+
+
);
diff --git a/src/pages/workspace/categories/CreateCategoryPage.tsx b/src/pages/workspace/categories/CreateCategoryPage.tsx
index 80370d2197fa..b31207e73208 100644
--- a/src/pages/workspace/categories/CreateCategoryPage.tsx
+++ b/src/pages/workspace/categories/CreateCategoryPage.tsx
@@ -10,8 +10,10 @@ import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import * as Policy from '@userActions/Policy';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {PolicyCategories} from '@src/types/onyx';
@@ -38,21 +40,26 @@ function CreateCategoryPage({route, policyCategories}: CreateCategoryPageProps)
return (
-
-
-
-
+
+
+
+
+
);
diff --git a/src/pages/workspace/categories/EditCategoryPage.tsx b/src/pages/workspace/categories/EditCategoryPage.tsx
index 11df2f195f9d..0e5ed0589934 100644
--- a/src/pages/workspace/categories/EditCategoryPage.tsx
+++ b/src/pages/workspace/categories/EditCategoryPage.tsx
@@ -10,8 +10,10 @@ import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import * as Policy from '@userActions/Policy';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
@@ -40,22 +42,27 @@ function EditCategoryPage({route, policyCategories}: EditCategoryPageProps) {
return (
-
- Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, route.params.categoryName))}
- />
-
-
+
+ Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, route.params.categoryName))}
+ />
+
+
+
);
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
index 3f929e46ab67..f3456c3875f5 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -28,6 +28,7 @@ import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
@@ -263,62 +264,67 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
return (
-
-
- {!isSmallScreenWidth && getHeaderButtons()}
-
- setDeleteCategoriesConfirmModalVisible(false)}
- title={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories')}
- prompt={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategoryPrompt' : 'workspace.categories.deleteCategoriesPrompt')}
- confirmText={translate('common.delete')}
- cancelText={translate('common.cancel')}
- danger
- />
- {isSmallScreenWidth && {getHeaderButtons()}}
-
- {translate('workspace.categories.subtitle')}
-
- {isLoading && (
-
- )}
- {shouldShowEmptyState && (
-
- )}
- {!shouldShowEmptyState && (
-
+ {!isSmallScreenWidth && getHeaderButtons()}
+
+ setDeleteCategoriesConfirmModalVisible(false)}
+ title={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories')}
+ prompt={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategoryPrompt' : 'workspace.categories.deleteCategoriesPrompt')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
/>
- )}
-
+ {isSmallScreenWidth && {getHeaderButtons()}}
+
+ {translate('workspace.categories.subtitle')}
+
+ {isLoading && (
+
+ )}
+ {shouldShowEmptyState && (
+
+ )}
+ {!shouldShowEmptyState && (
+
+ )}
+
+
);
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx
index 6939bac56894..0ec937b19ba2 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx
@@ -11,7 +11,9 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {setWorkspaceRequiresCategory} from '@libs/actions/Policy';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import CONST from '@src/CONST';
import type SCREENS from '@src/SCREENS';
type WorkspaceCategoriesSettingsPageProps = StackScreenProps;
@@ -27,34 +29,39 @@ function WorkspaceCategoriesSettingsPage({route}: WorkspaceCategoriesSettingsPag
return (
- {({policy}) => (
-
-
-
-
-
-
- {translate('workspace.categories.requiresCategory')}
-
+
+ {({policy}) => (
+
+
+
+
+
+
+ {translate('workspace.categories.requiresCategory')}
+
+
-
-
-
-
- )}
+
+
+
+ )}
+
);
diff --git a/src/pages/workspace/distanceRates/CreateDistanceRatePage.tsx b/src/pages/workspace/distanceRates/CreateDistanceRatePage.tsx
index d6f9ea29ac83..0a361f3f8e85 100644
--- a/src/pages/workspace/distanceRates/CreateDistanceRatePage.tsx
+++ b/src/pages/workspace/distanceRates/CreateDistanceRatePage.tsx
@@ -15,6 +15,7 @@ import validateRateValue from '@libs/PolicyDistanceRatesUtils';
import Navigation from '@navigation/Navigation';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import {createPolicyDistanceRate, generateCustomUnitID} from '@userActions/Policy';
import CONST from '@src/CONST';
@@ -33,6 +34,7 @@ type CreateDistanceRatePageProps = CreateDistanceRatePageOnyxProps & StackScreen
function CreateDistanceRatePage({policy, route}: CreateDistanceRatePageProps) {
const styles = useThemeStyles();
const {translate, toLocaleDigit} = useLocalize();
+ const policyID = route.params.policyID;
const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD;
const customUnits = policy?.customUnits ?? {};
const customUnitID = customUnits[Object.keys(customUnits)[0]]?.customUnitID ?? '';
@@ -53,40 +55,45 @@ function CreateDistanceRatePage({policy, route}: CreateDistanceRatePageProps) {
enabled: true,
};
- createPolicyDistanceRate(route.params.policyID, customUnitID, newRate);
+ createPolicyDistanceRate(policyID, customUnitID, newRate);
Navigation.goBack();
};
return (
-
-
-
+
+
-
-
-
-
-
+
+
+
+
+
+
);
diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx
new file mode 100644
index 000000000000..965096ffa529
--- /dev/null
+++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx
@@ -0,0 +1,176 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import ConfirmModal from '@components/ConfirmModal';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Switch from '@components/Switch';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as CurrencyUtils from '@libs/CurrencyUtils';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import * as Policy from '@userActions/Policy';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type * as OnyxTypes from '@src/types/onyx';
+import type {Rate} from '@src/types/onyx/Policy';
+
+type PolicyDistanceRateDetailsPageOnyxProps = {
+ /** Policy details */
+ policy: OnyxEntry;
+};
+
+type PolicyDistanceRateDetailsPageProps = PolicyDistanceRateDetailsPageOnyxProps & StackScreenProps;
+
+function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetailsPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {windowWidth} = useWindowDimensions();
+ const [isWarningModalVisible, setIsWarningModalVisible] = useState(false);
+ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
+
+ const policyID = route.params.policyID;
+ const rateID = route.params.rateID;
+ const customUnits = policy?.customUnits ?? {};
+ const customUnit = customUnits[Object.keys(customUnits)[0]];
+ const rate = customUnit.rates[rateID];
+ const currency = rate.currency ?? CONST.CURRENCY.USD;
+ const canDeleteRate = Object.values(customUnit.rates).filter((distanceRate) => distanceRate.enabled).length > 1 || !rate.enabled;
+ const canDisableRate = Object.values(customUnit.rates).filter((distanceRate) => distanceRate.enabled).length > 1;
+ const errorFields = rate.errorFields;
+
+ const editRateValue = () => {
+ Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_EDIT.getRoute(policyID, rateID));
+ };
+
+ const toggleRate = () => {
+ if (!rate.enabled || canDisableRate) {
+ Policy.setPolicyDistanceRatesEnabled(policyID, customUnit, [{...rate, enabled: !rate.enabled}]);
+ } else {
+ setIsWarningModalVisible(true);
+ }
+ };
+
+ const deleteRate = () => {
+ Navigation.goBack();
+ Policy.deletePolicyDistanceRates(policyID, customUnit, [rateID]);
+ setIsDeleteModalVisible(false);
+ };
+
+ const rateValueToDisplay = CurrencyUtils.convertAmountToDisplayString(rate.rate, currency);
+ const unitToDisplay = translate(`common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`);
+
+ const threeDotsMenuItems = [
+ {
+ icon: Expensicons.Trashcan,
+ text: translate('workspace.distanceRates.deleteDistanceRate'),
+ onSelected: () => {
+ if (canDeleteRate) {
+ setIsDeleteModalVisible(true);
+ return;
+ }
+ setIsWarningModalVisible(true);
+ },
+ },
+ ];
+
+ const clearErrorFields = (fieldName: keyof Rate) => {
+ Policy.clearPolicyDistanceRateErrorFields(policyID, customUnit.customUnitID, rateID, {...errorFields, [fieldName]: null});
+ };
+
+ return (
+
+
+
+
+
+
+ clearErrorFields('enabled')}
+ >
+
+ {translate('workspace.distanceRates.enableRate')}
+
+
+
+ clearErrorFields('rate')}
+ >
+
+
+ setIsWarningModalVisible(false)}
+ isVisible={isWarningModalVisible}
+ title={translate('workspace.distanceRates.oopsNotSoFast')}
+ prompt={translate('workspace.distanceRates.workspaceNeeds')}
+ confirmText={translate('common.buttonConfirm')}
+ shouldShowCancelButton={false}
+ />
+ setIsDeleteModalVisible(false)}
+ prompt={translate('workspace.distanceRates.areYouSureDelete', {count: 1})}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
+
+
+
+
+
+ );
+}
+
+PolicyDistanceRateDetailsPage.displayName = 'PolicyDistanceRateDetailsPage';
+
+export default withOnyx({
+ policy: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`,
+ },
+})(PolicyDistanceRateDetailsPage);
diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx
new file mode 100644
index 000000000000..be85ee680d36
--- /dev/null
+++ b/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx
@@ -0,0 +1,112 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback} from 'react';
+import {Keyboard} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import AmountForm from '@components/AmountForm';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapperWithRef from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import validateRateValue from '@libs/PolicyDistanceRatesUtils';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import * as Policy from '@userActions/Policy';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import INPUT_IDS from '@src/types/form/PolicyDistanceRateEditForm';
+import type * as OnyxTypes from '@src/types/onyx';
+
+type PolicyDistanceRateEditPageOnyxProps = {
+ /** Policy details */
+ policy: OnyxEntry;
+};
+
+type PolicyDistanceRateEditPageProps = PolicyDistanceRateEditPageOnyxProps & StackScreenProps;
+
+function PolicyDistanceRateEditPage({policy, route}: PolicyDistanceRateEditPageProps) {
+ const styles = useThemeStyles();
+ const {translate, toLocaleDigit} = useLocalize();
+ const {inputCallbackRef} = useAutoFocusInput();
+
+ const policyID = route.params.policyID;
+ const rateID = route.params.rateID;
+ const customUnits = policy?.customUnits ?? {};
+ const customUnit = customUnits[Object.keys(customUnits)[0]];
+ const rate = customUnit.rates[rateID];
+ const currency = rate.currency ?? CONST.CURRENCY.USD;
+ const currentRateValue = (rate.rate ?? 0).toString();
+
+ const submitRate = (values: FormOnyxValues) => {
+ Policy.updatePolicyDistanceRateValue(policyID, customUnit, [{...rate, rate: Number(values.rate) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET}]);
+ Keyboard.dismiss();
+ Navigation.goBack();
+ };
+
+ const validate = useCallback(
+ (values: FormOnyxValues) => validateRateValue(values, currency, toLocaleDigit),
+ [currency, toLocaleDigit],
+ );
+
+ return (
+
+
+
+
+ Navigation.goBack()}
+ />
+
+
+
+
+
+
+
+ );
+}
+
+PolicyDistanceRateEditPage.displayName = 'PolicyDistanceRateEditPage';
+
+export default withOnyx({
+ policy: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`,
+ },
+})(PolicyDistanceRateEditPage);
diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx
index 0b2ef794a4ca..93accdb10b28 100644
--- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx
+++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx
@@ -24,6 +24,7 @@ import * as CurrencyUtils from '@libs/CurrencyUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import * as Policy from '@userActions/Policy';
import ButtonWithDropdownMenu from '@src/components/ButtonWithDropdownMenu';
@@ -50,6 +51,7 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps)
const {translate} = useLocalize();
const [selectedDistanceRates, setSelectedDistanceRates] = useState([]);
const [isWarningModalVisible, setIsWarningModalVisible] = useState(false);
+ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const dropdownButtonRef = useRef(null);
const policyID = route.params.policyID;
@@ -58,6 +60,10 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps)
[policy?.customUnits],
);
const customUnitRates: Record = useMemo(() => customUnit?.rates ?? {}, [customUnit]);
+ const canDeleteSelectedRates = selectedDistanceRates.length !== Object.values(customUnitRates).length;
+ const canDisableSelectedRates = Object.values(customUnitRates)
+ .filter((rate: Rate) => !selectedDistanceRates.includes(rate))
+ .some((rate) => rate.enabled);
function fetchDistanceRates() {
Policy.openPolicyDistanceRatesPage(policyID);
@@ -65,9 +71,14 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps)
const dismissError = useCallback(
(item: RateForList) => {
+ if (customUnitRates[item.value].errors) {
+ Policy.clearDeleteDistanceRateError(policyID, customUnit?.customUnitID ?? '', item.value);
+ return;
+ }
+
Policy.clearCreateDistanceRateItemAndError(policyID, customUnit?.customUnitID ?? '', item.value);
},
- [customUnit?.customUnitID, policyID],
+ [customUnit?.customUnitID, customUnitRates, policyID],
);
const {isOffline} = useNetwork({onReconnect: fetchDistanceRates});
@@ -86,7 +97,8 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps)
)}`,
keyForList: value.customUnitRateID ?? '',
isSelected: selectedDistanceRates.find((rate) => rate.customUnitRateID === value.customUnitRateID) !== undefined,
- pendingAction: value.pendingAction,
+ isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ pendingAction: value.pendingAction ?? value.pendingFields?.rate ?? value.pendingFields?.enabled,
errors: value.errors ?? undefined,
rightElement: (
@@ -113,30 +125,49 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps)
Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID));
};
- const editRate = () => {
- // Navigation.navigate(ROUTES.WORKSPACE_EDIT_DISTANCE_RATE.getRoute(policyID, rateID));
+ const openRateDetails = (rate: RateForList) => {
+ setSelectedDistanceRates([]);
+ Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_DETAILS.getRoute(policyID, rate.value));
};
const disableRates = () => {
- if (selectedDistanceRates.length !== Object.values(customUnitRates).length) {
- // run enableWorkspaceDistanceRates for all selected rows
+ if (customUnit === undefined) {
return;
}
- setIsWarningModalVisible(true);
+ Policy.setPolicyDistanceRatesEnabled(
+ policyID,
+ customUnit,
+ selectedDistanceRates.filter((rate) => rate.enabled).map((rate) => ({...rate, enabled: false})),
+ );
+ setSelectedDistanceRates([]);
};
const enableRates = () => {
- // run enableWorkspaceDistanceRates for all selected rows
+ if (customUnit === undefined) {
+ return;
+ }
+
+ Policy.setPolicyDistanceRatesEnabled(
+ policyID,
+ customUnit,
+ selectedDistanceRates.filter((rate) => !rate.enabled).map((rate) => ({...rate, enabled: true})),
+ );
+ setSelectedDistanceRates([]);
};
const deleteRates = () => {
- if (selectedDistanceRates.length !== Object.values(customUnitRates).length) {
- // run deleteWorkspaceDistanceRates for all selected rows
+ if (customUnit === undefined) {
return;
}
- setIsWarningModalVisible(true);
+ Policy.deletePolicyDistanceRates(
+ policyID,
+ customUnit,
+ selectedDistanceRates.map((rate) => rate.customUnitRateID ?? ''),
+ );
+ setSelectedDistanceRates([]);
+ setIsDeleteModalVisible(false);
};
const toggleRate = (rate: RateForList) => {
@@ -151,7 +182,7 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps)
if (selectedDistanceRates.length === Object.values(customUnitRates).length) {
setSelectedDistanceRates([]);
} else {
- setSelectedDistanceRates([...Object.values(customUnitRates)]);
+ setSelectedDistanceRates([...Object.values(customUnitRates).filter((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)]);
}
};
@@ -165,27 +196,27 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps)
const getBulkActionsButtonOptions = () => {
const options: Array> = [
{
- text: translate(`workspace.distanceRates.${selectedDistanceRates.length <= 1 ? 'deleteRate' : 'deleteRates'}`),
+ text: translate('workspace.distanceRates.deleteRates', {count: selectedDistanceRates.length}),
value: CONST.POLICY.DISTANCE_RATES_BULK_ACTION_TYPES.DELETE,
icon: Expensicons.Trashcan,
- onSelected: deleteRates,
+ onSelected: () => (canDeleteSelectedRates ? setIsDeleteModalVisible(true) : setIsWarningModalVisible(true)),
},
];
const enabledRates = selectedDistanceRates.filter((rate) => rate.enabled);
if (enabledRates.length > 0) {
options.push({
- text: translate(`workspace.distanceRates.${enabledRates.length <= 1 ? 'disableRate' : 'disableRates'}`),
+ text: translate('workspace.distanceRates.disableRates', {count: enabledRates.length}),
value: CONST.POLICY.DISTANCE_RATES_BULK_ACTION_TYPES.DISABLE,
icon: Expensicons.DocumentSlash,
- onSelected: disableRates,
+ onSelected: () => (canDisableSelectedRates ? disableRates() : setIsWarningModalVisible(true)),
});
}
const disabledRates = selectedDistanceRates.filter((rate) => !rate.enabled);
if (disabledRates.length > 0) {
options.push({
- text: translate(`workspace.distanceRates.${disabledRates.length <= 1 ? 'enableRate' : 'enableRates'}`),
+ text: translate('workspace.distanceRates.enableRates', {count: disabledRates.length}),
value: CONST.POLICY.DISTANCE_RATES_BULK_ACTION_TYPES.ENABLE,
icon: Expensicons.DocumentSlash,
onSelected: enableRates,
@@ -237,53 +268,68 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps)
return (
-
-
- {!isSmallScreenWidth && headerButtons}
-
- {isSmallScreenWidth && {headerButtons}}
-
- {translate('workspace.distanceRates.centrallyManage')}
-
- {isLoading && (
-
+ {!isSmallScreenWidth && headerButtons}
+
+ {isSmallScreenWidth && {headerButtons}}
+
+ {translate('workspace.distanceRates.centrallyManage')}
+
+ {isLoading && (
+
+ )}
+ {Object.values(customUnitRates).length > 0 && (
+
+ )}
+ setIsWarningModalVisible(false)}
+ isVisible={isWarningModalVisible}
+ title={translate('workspace.distanceRates.oopsNotSoFast')}
+ prompt={translate('workspace.distanceRates.workspaceNeeds')}
+ confirmText={translate('common.buttonConfirm')}
+ shouldShowCancelButton={false}
/>
- )}
- {Object.values(customUnitRates).length > 0 && (
- setIsDeleteModalVisible(false)}
+ prompt={translate('workspace.distanceRates.areYouSureDelete', {count: selectedDistanceRates.length})}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
/>
- )}
- setIsWarningModalVisible(false)}
- isVisible={isWarningModalVisible}
- title={translate('workspace.distanceRates.oopsNotSoFast')}
- prompt={translate('workspace.distanceRates.workspaceNeeds')}
- confirmText={translate('common.buttonConfirm')}
- shouldShowCancelButton={false}
- />
-
+
+
);
diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
index f650a618250e..dbfb853b38a0 100644
--- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
+++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
@@ -1,5 +1,6 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React from 'react';
+import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -9,10 +10,13 @@ import type {ListItem} from '@components/SelectionList/types';
import type {UnitItemType} from '@components/UnitPicker';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import * as Policy from '@userActions/Policy';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
@@ -45,7 +49,14 @@ function PolicyDistanceRatesSettingsPage({policy, route}: PolicyDistanceRatesSet
};
const setNewCategory = (category: ListItem) => {
- Policy.setPolicyDistanceRatesDefaultCategory(policyID, customUnit, {...customUnit, defaultCategory: category.text});
+ if (!category.searchText) {
+ return;
+ }
+
+ Policy.setPolicyDistanceRatesDefaultCategory(policyID, customUnit, {
+ ...customUnit,
+ defaultCategory: defaultCategory === category.searchText ? '' : category.searchText,
+ });
};
const clearErrorFields = (fieldName: keyof CustomUnit) => {
@@ -53,44 +64,51 @@ function PolicyDistanceRatesSettingsPage({policy, route}: PolicyDistanceRatesSet
};
return (
-
-
-
+
+
-
- clearErrorFields('attributes')}
+
-
-
- {policy?.areCategoriesEnabled && (
- clearErrorFields('defaultCategory')}
- >
-
-
- )}
-
+
+
+ clearErrorFields('attributes')}
+ >
+
+
+ {policy?.areCategoriesEnabled && (
+ clearErrorFields('defaultCategory')}
+ >
+
+
+ )}
+
+
+
);
diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx
index d83f1b1d77a7..709e51cba383 100644
--- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx
+++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx
@@ -44,7 +44,7 @@ function WorkspaceRatePage(props: WorkspaceRatePageProps) {
const submit = (values: FormOnyxValues) => {
const rate = values.rate;
Policy.setRateForReimburseView((parseFloat(rate) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET).toFixed(1));
- Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? ''));
+ Navigation.goBack(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? ''));
};
const validate = useCallback(
diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx
index 36efc239fe69..1d30c068e30d 100644
--- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx
+++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx
@@ -38,7 +38,7 @@ function WorkspaceUnitPage(props: WorkspaceUnitPageProps) {
const updateUnit = (unit: UnitItemType) => {
Policy.setUnitForReimburseView(unit.value);
- Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? ''));
+ Navigation.goBack(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? ''));
};
const defaultValue = useMemo(() => {
diff --git a/src/pages/workspace/tags/EditTagPage.tsx b/src/pages/workspace/tags/EditTagPage.tsx
index c22ba9154146..92d7c0a11ac9 100644
--- a/src/pages/workspace/tags/EditTagPage.tsx
+++ b/src/pages/workspace/tags/EditTagPage.tsx
@@ -17,6 +17,7 @@ import * as PolicyUtils from '@libs/PolicyUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
@@ -66,36 +67,41 @@ function EditTagPage({route, policyTags}: EditTagPageProps) {
return (
-
-
-
-
-
-
+
+
+
+
+
);
diff --git a/src/pages/workspace/tags/TagSettingsPage.tsx b/src/pages/workspace/tags/TagSettingsPage.tsx
index 5f164a25e5fe..107689bc46b9 100644
--- a/src/pages/workspace/tags/TagSettingsPage.tsx
+++ b/src/pages/workspace/tags/TagSettingsPage.tsx
@@ -21,8 +21,10 @@ import * as PolicyUtils from '@libs/PolicyUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import * as Policy from '@userActions/Policy';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
@@ -67,61 +69,66 @@ function TagSettingsPage({route, policyTags}: TagSettingsPageProps) {
return (
-
- setIsDeleteTagModalOpen(true),
- },
- ]}
- />
- setIsDeleteTagModalOpen(false)}
- shouldSetModalVisibility={false}
- prompt={translate('workspace.tags.deleteTagConfirmation')}
- confirmText={translate('common.delete')}
- cancelText={translate('common.cancel')}
- danger
- />
-
- Policy.clearPolicyTagErrors(route.params.policyID, route.params.tagName)}
- >
-
-
- {translate('workspace.tags.enableTag')}
-
-
-
-
-
+ setIsDeleteTagModalOpen(true),
+ },
+ ]}
+ />
+ setIsDeleteTagModalOpen(false)}
+ shouldSetModalVisibility={false}
+ prompt={translate('workspace.tags.deleteTagConfirmation')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
/>
-
-
+
+ Policy.clearPolicyTagErrors(route.params.policyID, route.params.tagName)}
+ >
+
+
+ {translate('workspace.tags.enableTag')}
+
+
+
+
+
+
+
+
);
diff --git a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx
index 04c0cf8038d0..346d56891dd5 100644
--- a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx
@@ -18,6 +18,7 @@ import * as PolicyUtils from '@libs/PolicyUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
@@ -70,35 +71,40 @@ function CreateTagPage({route, policyTags}: CreateTagPageProps) {
return (
-
-
-
-
-
-
+
+
+
+
+
);
diff --git a/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx b/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx
index 98ae6f726d73..0072d37ef631 100644
--- a/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx
@@ -16,6 +16,9 @@ import * as Policy from '@libs/actions/Policy';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
@@ -52,33 +55,42 @@ function WorkspaceEditTagsPage({route, policyTags}: WorkspaceEditTagsPageProps)
);
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
index 126d548c2c8a..a355cc062f3d 100644
--- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx
@@ -27,6 +27,7 @@ import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
@@ -254,62 +255,67 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) {
return (
-
-
- {!isSmallScreenWidth && getHeaderButtons()}
-
- setDeleteTagsConfirmModalVisible(false)}
- title={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTag' : 'workspace.tags.deleteTags')}
- prompt={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTagConfirmation' : 'workspace.tags.deleteTagsConfirmation')}
- confirmText={translate('common.delete')}
- cancelText={translate('common.cancel')}
- danger
- />
- {isSmallScreenWidth && {getHeaderButtons()}}
-
- {translate('workspace.tags.subtitle')}
-
- {isLoading && (
-
- )}
- {tagList.length === 0 && !isLoading && (
-
- )}
- {tagList.length > 0 && (
- Policy.clearPolicyTagErrors(route.params.policyID, item.value)}
+
+ {!isSmallScreenWidth && getHeaderButtons()}
+
+ setDeleteTagsConfirmModalVisible(false)}
+ title={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTag' : 'workspace.tags.deleteTags')}
+ prompt={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTagConfirmation' : 'workspace.tags.deleteTagsConfirmation')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
/>
- )}
-
+ {isSmallScreenWidth && {getHeaderButtons()}}
+
+ {translate('workspace.tags.subtitle')}
+
+ {isLoading && (
+
+ )}
+ {tagList.length === 0 && !isLoading && (
+
+ )}
+ {tagList.length > 0 && (
+ Policy.clearPolicyTagErrors(route.params.policyID, item.value)}
+ />
+ )}
+
+
);
diff --git a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx
index 67b033b68f72..b421698b8f2f 100644
--- a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx
@@ -16,7 +16,9 @@ import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
@@ -39,49 +41,53 @@ function WorkspaceTagsSettingsPage({route, policyTags}: WorkspaceTagsSettingsPag
},
[route.params.policyID],
);
-
return (
- {({policy}) => (
-
-
-
-
-
-
- {translate('workspace.tags.requiresTag')}
-
+
+ {({policy}) => (
+
+
+
+
+
+
+ {translate('workspace.tags.requiresTag')}
+
+
-
-
-
- Navigation.navigate(ROUTES.WORKSPACE_EDIT_TAGS.getRoute(route.params.policyID))}
- shouldShowRightIcon
- />
-
-
-
- )}
+
+
+ Navigation.navigate(ROUTES.WORKSPACE_EDIT_TAGS.getRoute(route.params.policyID))}
+ shouldShowRightIcon
+ />
+
+
+
+ )}
+
);
diff --git a/src/pages/workspace/taxes/NamePage.tsx b/src/pages/workspace/taxes/NamePage.tsx
new file mode 100644
index 000000000000..1efb983be19e
--- /dev/null
+++ b/src/pages/workspace/taxes/NamePage.tsx
@@ -0,0 +1,120 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import ExpensiMark from 'expensify-common/lib/ExpensiMark';
+import React, {useCallback, useState} from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {renamePolicyTax, validateTaxName} from '@libs/actions/TaxRate';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import INPUT_IDS from '@src/types/form/WorkspaceTaxNameForm';
+
+type NamePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+
+const parser = new ExpensiMark();
+
+function NamePage({
+ route: {
+ params: {policyID, taxID},
+ },
+ policy,
+}: NamePageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const currentTaxRate = PolicyUtils.getTaxByID(policy, taxID);
+ const {inputCallbackRef} = useAutoFocusInput();
+
+ const [name, setName] = useState(() => parser.htmlToMarkdown(currentTaxRate?.name ?? ''));
+
+ const goBack = useCallback(() => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID ?? '', taxID)), [policyID, taxID]);
+
+ const submit = () => {
+ renamePolicyTax(policyID, taxID, name);
+ goBack();
+ };
+
+ const validate = useCallback(
+ (values: FormOnyxValues) => {
+ if (!policy) {
+ return {};
+ }
+ if (values[INPUT_IDS.NAME] === currentTaxRate?.name) {
+ return {};
+ }
+ return validateTaxName(policy, values);
+ },
+ [currentTaxRate?.name, policy],
+ );
+
+ if (!currentTaxRate) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+NamePage.displayName = 'NamePage';
+
+export default withPolicyAndFullscreenLoading(NamePage);
diff --git a/src/pages/workspace/taxes/ValuePage.tsx b/src/pages/workspace/taxes/ValuePage.tsx
new file mode 100644
index 000000000000..d008b11ecb15
--- /dev/null
+++ b/src/pages/workspace/taxes/ValuePage.tsx
@@ -0,0 +1,103 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback, useState} from 'react';
+import AmountForm from '@components/AmountForm';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {updatePolicyTaxValue, validateTaxValue} from '@libs/actions/TaxRate';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import INPUT_IDS from '@src/types/form/WorkspaceTaxValueForm';
+
+type ValuePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+
+function ValuePage({
+ route: {
+ params: {policyID, taxID},
+ },
+ policy,
+}: ValuePageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const currentTaxRate = PolicyUtils.getTaxByID(policy, taxID);
+ const [value, setValue] = useState(currentTaxRate?.value?.replace('%', ''));
+
+ const goBack = useCallback(() => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID ?? '', taxID)), [policyID, taxID]);
+
+ const submit = useCallback(
+ (values: FormOnyxValues) => {
+ updatePolicyTaxValue(policyID, taxID, Number(values.value));
+ goBack();
+ },
+ [goBack, policyID, taxID],
+ );
+
+ if (!currentTaxRate) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ %}
+ />
+
+
+
+
+
+ );
+}
+
+ValuePage.displayName = 'ValuePage';
+
+export default withPolicyAndFullscreenLoading(ValuePage);
diff --git a/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx
index c0790bb8abd8..ccc0d4ad9e7b 100644
--- a/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx
+++ b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx
@@ -11,11 +11,11 @@ import Text from '@components/Text';
import TextPicker from '@components/TextPicker';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import {createPolicyTax, getNextTaxCode, getTaxValueWithPercentage} from '@libs/actions/TaxRate';
+import {createPolicyTax, getNextTaxCode, getTaxValueWithPercentage, validateTaxName, validateTaxValue} from '@libs/actions/TaxRate';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
-import * as ValidationUtils from '@libs/ValidationUtils';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
@@ -36,25 +36,6 @@ function WorkspaceCreateTaxPage({
const styles = useThemeStyles();
const {translate} = useLocalize();
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.VALUE, INPUT_IDS.NAME]);
-
- const value = values[INPUT_IDS.VALUE];
- if (!ValidationUtils.isValidPercentage(value)) {
- errors[INPUT_IDS.VALUE] = 'workspace.taxes.errors.valuePercentageRange';
- }
-
- const name = values[INPUT_IDS.NAME];
- if (policy?.taxRates?.taxes && ValidationUtils.isExistingTaxName(name, policy.taxRates.taxes)) {
- errors[INPUT_IDS.NAME] = 'workspace.taxes.errors.taxRateAlreadyExists';
- }
-
- return errors;
- },
- [policy?.taxRates?.taxes],
- );
-
const submitForm = useCallback(
({value, ...values}: FormOnyxValues) => {
const taxRate = {
@@ -68,52 +49,70 @@ function WorkspaceCreateTaxPage({
[policy?.taxRates?.taxes, policyID],
);
+ const validateForm = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ if (!policy) {
+ return {};
+ }
+ return {
+ ...validateTaxName(policy, values),
+ ...validateTaxValue(values),
+ };
+ },
+ [policy],
+ );
+
return (
-
-
-
-
-
-
- (v ? getTaxValueWithPercentage(v) : '')}
- description={translate('workspace.taxes.value')}
- rightLabel={translate('common.required')}
- hideCurrencySymbol
- extraSymbol={%}
- />
-
-
-
-
+
+
+
+
+
+
+ (v ? getTaxValueWithPercentage(v) : '')}
+ description={translate('workspace.taxes.value')}
+ rightLabel={translate('common.required')}
+ hideCurrencySymbol
+ extraSymbol={%}
+ />
+
+
+
+
+
);
diff --git a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx
new file mode 100644
index 000000000000..ec04b77df3ca
--- /dev/null
+++ b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx
@@ -0,0 +1,163 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useMemo, useState} from 'react';
+import {View} from 'react-native';
+import ConfirmModal from '@components/ConfirmModal';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import type {ThreeDotsMenuItem} from '@components/HeaderWithBackButton/types';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Switch from '@components/Switch';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import {clearTaxRateFieldError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceEditTaxPageBaseProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
+
+function WorkspaceEditTaxPage({
+ route: {
+ params: {policyID, taxID},
+ },
+ policy,
+}: WorkspaceEditTaxPageBaseProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const currentTaxRate = PolicyUtils.getTaxByID(policy, taxID);
+ const {windowWidth} = useWindowDimensions();
+ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
+ const canEdit = policy && PolicyUtils.canEditTaxRate(policy, taxID);
+
+ const toggleTaxRate = () => {
+ if (!currentTaxRate) {
+ return;
+ }
+ setPolicyTaxesEnabled(policyID, [taxID], !!currentTaxRate.isDisabled);
+ };
+
+ const deleteTaxRate = () => {
+ if (!policyID) {
+ return;
+ }
+ deletePolicyTaxes(policyID, [taxID]);
+ setIsDeleteModalVisible(false);
+ Navigation.goBack();
+ };
+
+ const threeDotsMenuItems: ThreeDotsMenuItem[] = useMemo(
+ () => [
+ {
+ icon: Expensicons.Trashcan,
+ text: translate('common.delete'),
+ onSelected: () => setIsDeleteModalVisible(true),
+ },
+ ],
+ [translate],
+ );
+
+ if (!currentTaxRate) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+ clearTaxRateFieldError(policyID, taxID, 'isDisabled')}
+ >
+
+
+ {translate('workspace.taxes.actions.enable')}
+
+
+
+
+ clearTaxRateFieldError(policyID, taxID, 'name')}
+ >
+ Navigation.navigate(ROUTES.WORKSPACE_TAX_NAME.getRoute(`${policyID}`, taxID))}
+ />
+
+ clearTaxRateFieldError(policyID, taxID, 'value')}
+ >
+ Navigation.navigate(ROUTES.WORKSPACE_TAX_VALUE.getRoute(`${policyID}`, taxID))}
+ />
+
+
+ setIsDeleteModalVisible(false)}
+ prompt={translate('workspace.taxes.deleteTaxConfirmation')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
+
+
+
+
+ );
+}
+
+WorkspaceEditTaxPage.displayName = 'WorkspaceEditTaxPage';
+
+export default withPolicyAndFullscreenLoading(WorkspaceEditTaxPage);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
index 8eb730c0134f..bad82d827c5d 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx
@@ -1,7 +1,10 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {ActivityIndicator, View} from 'react-native';
import Button from '@components/Button';
+import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
+import type {DropdownOption, WorkspaceTaxRatesBulkActionType} from '@components/ButtonWithDropdownMenu/types';
+import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
@@ -17,10 +20,13 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {openPolicyTaxesPage} from '@libs/actions/Policy';
-import {clearTaxRateError} from '@libs/actions/TaxRate';
+import {clearTaxRateError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate';
+import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
+import * as PolicyUtils from '@libs/PolicyUtils';
import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
@@ -30,17 +36,24 @@ import type SCREENS from '@src/SCREENS';
type WorkspaceTaxesPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
-function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) {
+function WorkspaceTaxesPage({
+ policy,
+ route: {
+ params: {policyID},
+ },
+}: WorkspaceTaxesPageProps) {
const {isSmallScreenWidth} = useWindowDimensions();
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
const [selectedTaxesIDs, setSelectedTaxesIDs] = useState([]);
+ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const defaultExternalID = policy?.taxRates?.defaultExternalID;
const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault;
+ const dropdownButtonRef = useRef(null);
const fetchTaxes = () => {
- openPolicyTaxesPage(route.params.policyID);
+ openPolicyTaxesPage(policyID);
};
const {isOffline} = useNetwork({onReconnect: fetchTaxes});
@@ -66,34 +79,34 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) {
[defaultExternalID, foreignTaxDefault, translate],
);
- const taxesList = useMemo(
- () =>
- Object.entries(policy?.taxRates?.taxes ?? {})
- .map(([key, value]) => ({
- text: value.name,
- alternateText: textForDefault(key),
- keyForList: key,
- isSelected: !!selectedTaxesIDs.includes(key),
- isDisabledCheckbox: key === defaultExternalID,
- pendingAction: value.pendingAction,
- errors: value.errors,
- rightElement: (
-
-
- {value.isDisabled ? translate('workspace.common.disabled') : translate('workspace.common.enabled')}
-
-
-
-
+ const taxesList = useMemo(() => {
+ if (!policy) {
+ return [];
+ }
+ return Object.entries(policy.taxRates?.taxes ?? {})
+ .map(([key, value]) => ({
+ text: value.name,
+ alternateText: textForDefault(key),
+ keyForList: key,
+ isSelected: !!selectedTaxesIDs.includes(key),
+ isDisabledCheckbox: !PolicyUtils.canEditTaxRate(policy, key),
+ isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null),
+ errors: value.errors ?? ErrorUtils.getLatestErrorFieldForAnyField(value),
+ rightElement: (
+
+ {value.isDisabled ? translate('workspace.common.disabled') : translate('workspace.common.enabled')}
+
+
- ),
- }))
- .sort((a, b) => a.text.localeCompare(b.text)),
- [policy?.taxRates?.taxes, textForDefault, defaultExternalID, selectedTaxesIDs, styles, theme.icon, translate],
- );
+
+ ),
+ }))
+ .sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? ''));
+ }, [policy, textForDefault, selectedTaxesIDs, styles.flexRow, styles.disabledText, styles.alignSelfCenter, styles.p1, styles.pl2, translate, theme.icon]);
const isLoading = !isOffline && taxesList === undefined;
@@ -129,68 +142,159 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) {
);
- const headerButtons = (
+ const deleteTaxes = useCallback(() => {
+ if (!policyID) {
+ return;
+ }
+ deletePolicyTaxes(policyID, selectedTaxesIDs);
+ setSelectedTaxesIDs([]);
+ setIsDeleteModalVisible(false);
+ }, [policyID, selectedTaxesIDs]);
+
+ const toggleTaxes = useCallback(
+ (isEnabled: boolean) => {
+ if (!policyID) {
+ return;
+ }
+ setPolicyTaxesEnabled(policyID, selectedTaxesIDs, isEnabled);
+ setSelectedTaxesIDs([]);
+ },
+ [policyID, selectedTaxesIDs],
+ );
+
+ const navigateToEditTaxRate = (taxRate: ListItem) => {
+ if (!taxRate.keyForList) {
+ return;
+ }
+ setSelectedTaxesIDs([]);
+ Navigation.navigate(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxRate.keyForList));
+ };
+
+ const dropdownMenuOptions = useMemo(() => {
+ const isMultiple = selectedTaxesIDs.length > 1;
+ const options: Array> = [
+ {
+ icon: Expensicons.Trashcan,
+ text: isMultiple ? translate('workspace.taxes.actions.deleteMultiple') : translate('workspace.taxes.actions.delete'),
+ value: CONST.POLICY.TAX_RATES_BULK_ACTION_TYPES.DELETE,
+ onSelected: () => setIsDeleteModalVisible(true),
+ },
+ ];
+
+ // `Disable rates` when at least one enabled rate is selected.
+ if (selectedTaxesIDs.some((taxID) => !policy?.taxRates?.taxes[taxID]?.isDisabled)) {
+ options.push({
+ icon: Expensicons.DocumentSlash,
+ text: isMultiple ? translate('workspace.taxes.actions.disableMultiple') : translate('workspace.taxes.actions.disable'),
+ value: CONST.POLICY.TAX_RATES_BULK_ACTION_TYPES.DISABLE,
+ onSelected: () => toggleTaxes(false),
+ });
+ }
+
+ // `Enable rates` when at least one disabled rate is selected.
+ if (selectedTaxesIDs.some((taxID) => policy?.taxRates?.taxes[taxID]?.isDisabled)) {
+ options.push({
+ icon: Expensicons.Document,
+ text: isMultiple ? translate('workspace.taxes.actions.enableMultiple') : translate('workspace.taxes.actions.enable'),
+ value: CONST.POLICY.TAX_RATES_BULK_ACTION_TYPES.ENABLE,
+ onSelected: () => toggleTaxes(true),
+ });
+ }
+ return options;
+ }, [policy?.taxRates?.taxes, selectedTaxesIDs, toggleTaxes, translate]);
+
+ const headerButtons = !selectedTaxesIDs.length ? (
+ ) : (
+
+ buttonRef={dropdownButtonRef}
+ onPress={() => {}}
+ options={dropdownMenuOptions}
+ buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
+ customText={translate('workspace.common.selected', {selectedNumber: selectedTaxesIDs.length})}
+ shouldAlwaysShowDropdownMenu
+ pressOnEnter
+ style={[isSmallScreenWidth && styles.w50, isSmallScreenWidth && styles.mb3]}
+ />
);
return (
-
-
-
+
+
-
- {!isSmallScreenWidth && headerButtons}
-
+
+ {!isSmallScreenWidth && headerButtons}
+
- {isSmallScreenWidth && {headerButtons}}
+ {isSmallScreenWidth && {headerButtons}}
-
- {translate('workspace.taxes.subtitle')}
-
- {isLoading && (
-
+ {translate('workspace.taxes.subtitle')}
+
+ {isLoading && (
+
+ )}
+ (item.keyForList ? clearTaxRateError(policyID, item.keyForList, item.pendingAction) : undefined)}
+ />
+ setIsDeleteModalVisible(false)}
+ prompt={
+ selectedTaxesIDs.length > 1
+ ? translate('workspace.taxes.deleteMultipleTaxConfirmation', {taxAmount: selectedTaxesIDs.length})
+ : translate('workspace.taxes.deleteTaxConfirmation')
+ }
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
/>
- )}
- {}}
- onSelectAll={toggleAllTaxes}
- showScrollIndicator
- ListItem={TableListItem}
- customListHeader={getCustomListHeader()}
- listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
- onDismissError={(item) => (item.keyForList ? clearTaxRateError(route.params.policyID, item.keyForList, item.pendingAction) : undefined)}
- />
-
+
+
);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx
index 892434ce2d52..e9e359d9d059 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx
@@ -1,8 +1,9 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React from 'react';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';
@@ -12,7 +13,9 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {setPolicyCustomTaxName} from '@libs/actions/Policy';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as ValidationUtils from '@libs/ValidationUtils';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
@@ -35,6 +38,17 @@ function WorkspaceTaxesSettingsCustomTaxName({
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput();
+ const validate = useCallback((values: FormOnyxValues) => {
+ const errors: FormInputErrors = {};
+ const customTaxName = values[INPUT_IDS.NAME];
+
+ if (!ValidationUtils.isRequiredFulfilled(customTaxName)) {
+ errors.name = 'workspace.taxes.errors.customNameRequired';
+ }
+
+ return errors;
+ }, []);
+
const submit = ({name}: WorkspaceTaxCustomName) => {
setPolicyCustomTaxName(policyID, name);
Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID));
@@ -43,37 +57,43 @@ function WorkspaceTaxesSettingsCustomTaxName({
return (
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
index 4a6626a78286..91d543b51b09 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsForeignCurrency.tsx
@@ -11,9 +11,11 @@ import {setForeignCurrencyDefault} from '@libs/actions/Policy';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
@@ -37,27 +39,32 @@ function WorkspaceTaxesSettingsForeignCurrency({
return (
-
- {({insets}) => (
- <>
-
+
+ {({insets}) => (
+ <>
+
-
-
-
- >
- )}
-
+
+
+
+ >
+ )}
+
+
);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx
index 1fe6abb96b4c..0d1a8f1629c7 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx
@@ -11,9 +11,11 @@ import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
@@ -55,31 +57,36 @@ function WorkspaceTaxesSettingsPage({
return (
-
-
-
-
- {menuItems.map((item) => (
-
-
-
- ))}
-
-
-
+
+
+
+
+ {menuItems.map((item) => (
+
+
+
+ ))}
+
+
+
+
);
diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
index 68c50f3af830..2fe2985daa22 100644
--- a/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
+++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsWorkspaceCurrency.tsx
@@ -11,9 +11,11 @@ import {setWorkspaceCurrencyDefault} from '@libs/actions/Policy';
import Navigation from '@libs/Navigation/Navigation';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
@@ -37,27 +39,32 @@ function WorkspaceTaxesSettingsWorkspaceCurrency({
return (
-
- {({insets}) => (
- <>
-
+
+ {({insets}) => (
+ <>
+
-
-
-
- >
- )}
-
+
+
+
+ >
+ )}
+
+
);
diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx
index cf66af726a72..5d2297f47ddd 100644
--- a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx
+++ b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx
@@ -1,3 +1,4 @@
+import type {StackScreenProps} from '@react-navigation/stack';
import React, {useState} from 'react';
import {FlatList} from 'react-native-gesture-handler';
import type {ValueOf} from 'type-fest';
@@ -9,20 +10,24 @@ import ScreenWrapper from '@components/ScreenWrapper';
import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
import * as Localize from '@libs/Localize';
import Navigation from '@libs/Navigation/Navigation';
+import type {WorkspacesCentralPaneNavigatorParamList} from '@libs/Navigation/types';
import * as PolicyUtils from '@libs/PolicyUtils';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import withPolicy from '@pages/workspace/withPolicy';
import type {WithPolicyOnyxProps} from '@pages/workspace/withPolicy';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
type AutoReportingFrequencyKey = Exclude, 'instant'>;
type Locale = ValueOf;
-type WorkspaceAutoReportingFrequencyPageProps = WithPolicyOnyxProps;
+type WorkspaceAutoReportingFrequencyPageProps = WithPolicyOnyxProps & StackScreenProps;
type WorkspaceAutoReportingFrequencyPageItem = {
text: string;
@@ -41,7 +46,7 @@ const getAutoReportingFrequencyDisplayNames = (locale: Locale): AutoReportingFre
[CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL]: Localize.translate(locale, 'workflowsPage.frequencies.manually'),
});
-function WorkspaceAutoReportingFrequencyPage({policy}: WorkspaceAutoReportingFrequencyPageProps) {
+function WorkspaceAutoReportingFrequencyPage({policy, route}: WorkspaceAutoReportingFrequencyPageProps) {
const {translate, preferredLocale, toLocaleOrdinal} = useLocalize();
const styles = useThemeStyles();
const [isMonthlyFrequency, setIsMonthlyFrequency] = useState(policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY);
@@ -75,12 +80,20 @@ function WorkspaceAutoReportingFrequencyPage({policy}: WorkspaceAutoReportingFre
if (typeof policy?.autoReportingOffset === 'number') {
return toLocaleOrdinal(policy.autoReportingOffset);
}
+ if (typeof policy?.autoReportingOffset === 'string' && parseInt(policy?.autoReportingOffset, 10)) {
+ return toLocaleOrdinal(parseInt(policy.autoReportingOffset, 10));
+ }
return translate(`workflowsPage.frequencies.${policy?.autoReportingOffset}`);
};
const monthlyFrequencyDetails = () => (
-
+ Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING_OFFSET)}
+ errorRowStyles={[styles.ml7]}
+ >