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..9c5db608a846 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 1001045503
+ versionName "1.4.55-3"
}
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/expensify-classic/expenses/Export-expenses.md b/docs/articles/expensify-classic/expenses/Export-expenses.md
new file mode 100644
index 000000000000..14c1532f84b5
--- /dev/null
+++ b/docs/articles/expensify-classic/expenses/Export-expenses.md
@@ -0,0 +1,13 @@
+---
+title: Export expenses
+description: Export expenses to a CSV
+---
+
+
+1. Click the **Expenses** tab.
+2. Select the expenses you want to export by checking the box to the left of each expense or selecting them all.
+3. Click **Export To** in the right corner and select either:
+ - **Default CSV**: Use Expensify’s default template
+ - **Create new CSV export layout**: Create your own custom CSV template
+
+
diff --git a/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md
new file mode 100644
index 000000000000..9037e58661d1
--- /dev/null
+++ b/docs/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account.md
@@ -0,0 +1,30 @@
+---
+title: Assign billing owner and payment account
+description: Determine who will cover the cost of the workspace and link a payment method
+---
+
+
+The person who creates a workspace will automatically be responsible for the billing for that workspace. However, the existing billing owner can transfer the workspace’s billing ownership to any Admin on the workspace.
+
+{% include info.html %}
+There can only be one billing owner at a time. Assigning a new billing owner will automatically un-assign the existing billing owner. However, billing owners are also workspace admins by default, and the previous billing owner will remain a workspace admin unless manually updated.
+{% include end-info.html %}
+
+# Assign a new billing owner
+
+To assign a new billing owner, **the person who will take over responsibility for the workspace billing must complete the following process**:
+
+1. Hover over Settings, then click **Workspaces**.
+2. Click the desired workspace name.
+3. Under Workspace Overview, click **Take Over Billing**.
+
+# Add or update payment account
+
+Once you take over billing for a workspace, you must add a payment method to your account.
+
+1. Hover over Settings, then click **Account**.
+2. Click the **Payments** tab.
+3. Scroll down to the Payment Details sections and click **Add Payment Card**.
+4. Enter your credit or debit card information and click **Accept terms, add payment card, and pay $0.00** (the box will only show a balance if one is due).
+
+
diff --git a/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md
new file mode 100644
index 000000000000..b0b016afbcbb
--- /dev/null
+++ b/docs/articles/expensify-classic/workspaces/Create-a-group-workspace.md
@@ -0,0 +1,24 @@
+---
+title: Create a group workspace
+description: Create a workspace for your team's expense reports
+---
+
+
+A workspace is the set of rules, settings, and spending limits for expense reports in your organization. This includes the unique expense categories and tags, budgets, currency and tax settings, etc. that all workspace members will use. A workspace also defines the approval workflow for your employees, as well as the accounting connection if using an accounting software integration.
+
+Here are a couple examples of when you’d want to create different workspaces:
+
+- You have employees with expense reports in different currencies. For example, you may have a workspace for employees who live in the US and submit their reports in USD and a workspace for employees who live in Canada and submit in CAD.
+- You want to limit specific groups of people to their own set of expense coding options (categories/tags) then they can separate their employees by Sales, Marketing, Support, etc.
+
+To create a group workspace,
+
+1. Hover over Settings, then click **Workspaces**.
+2. Click the **Group** tab on the left.
+3. Click **New Workspace**.
+4. Enter the workspace name and select a workspace type.
+ - **Collect**: Ideal for small groups who only need basic features like expense approvals, reimbursement, corporate card management, and integration options.
+ - **Control**: For groups that need a deeper level of control and configurations, like multi-stage approval workflows, corporate card management, integrations, and more. This is the most popular option.
+5. Set up your workspace details including the workspace name, expense rules, categories, and more.
+
+
diff --git a/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md
new file mode 100644
index 000000000000..c8be9a2728d5
--- /dev/null
+++ b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md
@@ -0,0 +1,20 @@
+---
+title: Set up your individual workspace
+description: Capture your personal expenses
+---
+
+
+All Expensify accounts come with an individual workspace where you can track your personal expenses. If you want to connect your personal expenses to an accounting or travel integration, you can create a group workspace—even if you will be the only person in the group.
+
+To set up your individual workspace,
+
+1. Hover over Settings, then click **Workspaces**.
+2. Click the **Individual** tab on the left.
+3. Select the policy type that best fits your needs.
+4. Set up your workspace details including the workspace name, expense rules, categories, and more.
+
+{% include info.html %}
+You can create multiple group workspaces, but you can only create one individual workspace.
+{% include end-info.html %}
+
+
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..5e2ba1fcd614 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.55.0
+ 1.4.55.3
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index cc6c5cf4c86a..69472200e46d 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.55.0
+ 1.4.55.3
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 7c90acab4958..008ca16909b0 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.3
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..4bff5eaf6eb8 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-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.55-0",
+ "version": "1.4.55-3",
"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",
@@ -70,7 +70,7 @@
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-collapse": "^5.1.0",
- "react-content-loader": "^6.1.0",
+ "react-content-loader": "^7.0.0",
"react-dom": "18.1.0",
"react-error-boundary": "^4.0.11",
"react-map-gl": "^7.1.3",
@@ -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"
},
@@ -38827,8 +38828,9 @@
}
},
"node_modules/react-content-loader": {
- "version": "6.2.0",
- "license": "MIT",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/react-content-loader/-/react-content-loader-7.0.0.tgz",
+ "integrity": "sha512-xaBwpO7eiJyEc4ndym+g6wcruV9W2y3DKqbw4U48QFBsv0IeAVZO+aCUb8GptlDLWM8n5zi2HcFSGlj5r+53Tg==",
"engines": {
"node": ">=10"
},
diff --git a/package.json b/package.json
index 92a6b9cde5e1..53eb229d7b85 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.55-0",
+ "version": "1.4.55-3",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -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",
@@ -121,7 +121,7 @@
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-collapse": "^5.1.0",
- "react-content-loader": "^6.1.0",
+ "react-content-loader": "^7.0.0",
"react-dom": "18.1.0",
"react-error-boundary": "^4.0.11",
"react-map-gl": "^7.1.3",
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/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch b/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch
new file mode 100644
index 000000000000..e6a3822836f4
--- /dev/null
+++ b/patches/react-native-web+0.19.9+006+fixPointerEventDown.patch
@@ -0,0 +1,43 @@
+diff --git a/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js b/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js
+index 0aec2d6..a71aec2 100644
+--- a/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js
++++ b/node_modules/react-native-web/dist/modules/useResponderEvents/ResponderSystem.js
+@@ -133,7 +133,7 @@ to return true:wantsResponderID| |
+
+ import createResponderEvent from './createResponderEvent';
+ import { isCancelish, isEndish, isMoveish, isScroll, isSelectionChange, isStartish } from './ResponderEventTypes';
+-import { getLowestCommonAncestor, getResponderPaths, hasTargetTouches, hasValidSelection, isPrimaryPointerDown, setResponderId } from './utils';
++import { getLowestCommonAncestor, getResponderPaths, hasTargetTouches, hasValidSelection, isPrimaryOrSecondaryPointerDown, setResponderId } from './utils';
+ import { ResponderTouchHistoryStore } from './ResponderTouchHistoryStore';
+ import canUseDOM from '../canUseDom';
+
+@@ -225,7 +225,7 @@ function eventListener(domEvent) {
+ }
+ return;
+ }
+- var isStartEvent = isStartish(eventType) && isPrimaryPointerDown(domEvent);
++ var isStartEvent = isStartish(eventType) && isPrimaryOrSecondaryPointerDown(domEvent);
+ var isMoveEvent = isMoveish(eventType);
+ var isEndEvent = isEndish(eventType);
+ var isScrollEvent = isScroll(eventType);
+diff --git a/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js b/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js
+index 7382cdd..d88f6c0 100644
+--- a/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js
++++ b/node_modules/react-native-web/dist/modules/useResponderEvents/utils.js
+@@ -148,14 +148,14 @@ export function hasValidSelection(domEvent) {
+ /**
+ * Events are only valid if the primary button was used without specific modifier keys.
+ */
+-export function isPrimaryPointerDown(domEvent) {
++export function isPrimaryOrSecondaryPointerDown(domEvent) {
+ var altKey = domEvent.altKey,
+ button = domEvent.button,
+ buttons = domEvent.buttons,
+ ctrlKey = domEvent.ctrlKey,
+ type = domEvent.type;
+ var isTouch = type === 'touchstart' || type === 'touchmove';
+- var isPrimaryMouseDown = type === 'mousedown' && (button === 0 || buttons === 1);
++ var isPrimaryMouseDown = type === 'mousedown' && (button === 0 || buttons === 1 || buttons === 2);
+ var isPrimaryMouseMove = type === 'mousemove' && buttons === 1;
+ var noModifiers = altKey === false && ctrlKey === false;
+ if (isTouch || isPrimaryMouseDown && noModifiers || isPrimaryMouseMove && noModifiers) {
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..af4864c22a85 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',
@@ -628,6 +630,7 @@ const CONST = {
EXPORTEDTOQUICKBOOKS: 'EXPORTEDTOQUICKBOOKS', // OldDot Action
FORWARDED: 'FORWARDED', // OldDot Action
HOLD: 'HOLD',
+ HOLDCOMMENT: 'HOLDCOMMENT',
IOU: 'IOU',
INTEGRATIONSMESSAGE: 'INTEGRATIONSMESSAGE', // OldDot Action
MANAGERATTACHRECEIPT: 'MANAGERATTACHRECEIPT', // OldDot Action
@@ -1344,6 +1347,7 @@ const CONST = {
SEND: 'send',
SPLIT: 'split',
REQUEST: 'request',
+ TRACK_EXPENSE: 'track-expense',
},
REQUEST_TYPE: {
DISTANCE: 'distance',
@@ -1358,6 +1362,7 @@ const CONST = {
CANCEL: 'cancel',
DELETE: 'delete',
APPROVE: 'approve',
+ TRACK: 'track',
},
AMOUNT_MAX_LENGTH: 10,
RECEIPT_STATE: {
@@ -1472,6 +1477,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 +1501,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 +3419,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/Expensify.tsx b/src/Expensify.tsx
index 5681be838ca8..026025593aef 100644
--- a/src/Expensify.tsx
+++ b/src/Expensify.tsx
@@ -25,12 +25,10 @@ import Navigation from './libs/Navigation/Navigation';
import NavigationRoot from './libs/Navigation/NavigationRoot';
import NetworkConnection from './libs/NetworkConnection';
import PushNotification from './libs/Notification/PushNotification';
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
import './libs/Notification/PushNotification/subscribePushNotification';
import StartupTimer from './libs/StartupTimer';
// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-import UnreadIndicatorUpdater from './libs/UnreadIndicatorUpdater';
+import './libs/UnreadIndicatorUpdater';
import Visibility from './libs/Visibility';
import ONYXKEYS from './ONYXKEYS';
import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu';
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..c216d5ac288c 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',
@@ -204,7 +204,7 @@ const ROUTES = {
},
REPORT_ATTACHMENTS: {
route: 'r/:reportID/attachment',
- getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}` as const,
+ getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURIComponent(source)}` as const,
},
REPORT_PARTICIPANTS: {
route: 'r/:reportID/participants',
@@ -304,13 +304,10 @@ const ROUTES = {
route: ':iouType/new/receipt/:reportID?',
getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}` as const,
},
- MONEY_REQUEST_DISTANCE: {
- route: ':iouType/new/address/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}` as const,
- },
MONEY_REQUEST_CREATE: {
- route: 'create/:iouType/start/:transactionID/:reportID',
- getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}` as const,
+ route: ':action/:iouType/start/:transactionID/:reportID',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) =>
+ `create/${iouType}/start/${transactionID}/${reportID}` as const,
},
MONEY_REQUEST_STEP_CONFIRMATION: {
route: 'create/:iouType/confirmation/:transactionID/:reportID',
@@ -332,9 +329,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,14 +344,14 @@ 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',
- getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
- getUrlWithBackToParam(`create/${iouType}/distance/${transactionID}/${reportID}`, backTo),
+ route: ':action/:iouType/distance/:transactionID/:reportID',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
+ getUrlWithBackToParam(`${action}/${iouType}/distance/${transactionID}/${reportID}`, backTo),
},
MONEY_REQUEST_STEP_MERCHANT: {
route: ':action/:iouType/merchant/:transactionID/:reportID',
@@ -395,16 +392,19 @@ const ROUTES = {
getRoute: (iouType: ValueOf, iouRequestType: ValueOf) => `start/${iouType}/${iouRequestType}` as const,
},
MONEY_REQUEST_CREATE_TAB_DISTANCE: {
- route: 'create/:iouType/start/:transactionID/:reportID/distance',
- getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/distance` as const,
+ route: ':action/:iouType/start/:transactionID/:reportID/distance',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) =>
+ `create/${iouType}/start/${transactionID}/${reportID}/distance` as const,
},
MONEY_REQUEST_CREATE_TAB_MANUAL: {
- route: 'create/:iouType/start/:transactionID/:reportID/manual',
- getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/manual` as const,
+ route: ':action/:iouType/start/:transactionID/:reportID/manual',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) =>
+ `create/${iouType}/start/${transactionID}/${reportID}/manual` as const,
},
MONEY_REQUEST_CREATE_TAB_SCAN: {
- route: 'create/:iouType/start/:transactionID/:reportID/scan',
- getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/scan` as const,
+ route: ':action/:iouType/start/:transactionID/:reportID/scan',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string) =>
+ `create/${iouType}/start/${transactionID}/${reportID}/scan` as const,
},
IOU_REQUEST: 'request/new',
@@ -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..82fef0383918 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -155,7 +155,6 @@ const SCREENS = {
CURRENCY: 'Money_Request_Currency',
WAYPOINT: 'Money_Request_Waypoint',
EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint',
- DISTANCE: 'Money_Request_Distance',
RECEIPT: 'Money_Request_Receipt',
},
@@ -217,6 +216,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 +245,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/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
index b934bdfdd738..9524c5203110 100644
--- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
@@ -15,10 +15,19 @@ import CONST from '@src/CONST';
function extractAttachmentsFromReport(parentReportAction, reportActions) {
const actions = [parentReportAction, ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))];
const attachments = [];
+ // We handle duplicate image sources by considering the first instance as original. Selecting any duplicate
+ // and navigating back (<) shows the image preceding the first instance, not the selected duplicate's position.
+ const uniqueSources = new Set();
const htmlParser = new HtmlParser({
onopentag: (name, attribs) => {
if (name === 'video') {
+ const source = tryResolveUrlFromApiRoot(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]);
+ if (uniqueSources.has(source)) {
+ return;
+ }
+
+ uniqueSources.add(source);
const splittedUrl = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE].split('/');
attachments.unshift({
reportActionID: null,
@@ -35,7 +44,20 @@ function extractAttachmentsFromReport(parentReportAction, reportActions) {
if (name === 'img' && attribs.src) {
const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE];
const source = tryResolveUrlFromApiRoot(expensifySource || attribs.src);
- const fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`);
+ if (uniqueSources.has(source)) {
+ return;
+ }
+
+ uniqueSources.add(source);
+ let fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`);
+
+ // Public image URLs might lack a file extension in the source URL, without an extension our
+ // AttachmentView fails to recognize them as images and renders fallback content instead.
+ // We apply this small hack to add an image extension and ensure AttachmentView renders the image.
+ const fileInfo = FileUtils.splitExtensionFromFileName(fileName);
+ if (!fileInfo.fileExtension) {
+ fileName = `${fileInfo.fileName || 'image'}.jpg`;
+ }
// By iterating actions in chronological order and prepending each attachment
// we ensure correct order of attachments even across actions with multiple attachments.
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index 461548f0d2b1..9fe37734e8ee 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -79,6 +79,7 @@ const defaultProps = {
reportActionID: '',
isHovered: false,
optionalVideoDuration: 0,
+ fallbackSource: Expensicons.Gallery,
};
function AttachmentView({
@@ -201,6 +202,21 @@ function AttachmentView({
// We also check for numeric source since this is how static images (used for preview) are represented in RN.
const isImage = typeof source === 'number' || Str.isImage(source);
if (isImage || (file && Str.isImage(file.name))) {
+ if (imageError) {
+ // AttachmentViewImage can't handle icon fallbacks, so we need to handle it here
+ if (typeof fallbackSource === 'number' || _.isFunction(fallbackSource)) {
+ return (
+
+ );
+ }
+ }
+
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/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx
index 5f426f77b731..e89026137b67 100644
--- a/src/components/ButtonWithDropdownMenu/index.tsx
+++ b/src/components/ButtonWithDropdownMenu/index.tsx
@@ -116,7 +116,7 @@ function ButtonWithDropdownMenu({
success={success}
ref={buttonRef}
pressOnEnter={pressOnEnter}
- isDisabled={isDisabled}
+ isDisabled={isDisabled || !!options[0].disabled}
style={[styles.w100, style]}
isLoading={isLoading}
text={selectedItem.text}
diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts
index 798369292958..87db9a29d827 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;
@@ -20,6 +22,7 @@ type DropdownOption = {
iconHeight?: number;
iconDescription?: string;
onSelected?: () => void;
+ disabled?: boolean;
};
type ButtonWithDropdownMenuProps = {
@@ -73,4 +76,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/DistanceRequest/index.tsx b/src/components/DistanceRequest/index.tsx
deleted file mode 100644
index f9e8c0be12ff..000000000000
--- a/src/components/DistanceRequest/index.tsx
+++ /dev/null
@@ -1,320 +0,0 @@
-import type {RouteProp} from '@react-navigation/native';
-import lodashIsEqual from 'lodash/isEqual';
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import {View} from 'react-native';
-// eslint-disable-next-line no-restricted-imports
-import type {ScrollView} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
-import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
-import Button from '@components/Button';
-import DotIndicatorMessage from '@components/DotIndicatorMessage';
-import DraggableList from '@components/DraggableList';
-import type {DraggableListData} from '@components/DraggableList/types';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import ScreenWrapper from '@components/ScreenWrapper';
-import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
-import usePrevious from '@hooks/usePrevious';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ErrorUtils from '@libs/ErrorUtils';
-import * as IOUUtils from '@libs/IOUUtils';
-import Navigation from '@libs/Navigation/Navigation';
-import * as TransactionUtils from '@libs/TransactionUtils';
-import * as MapboxToken from '@userActions/MapboxToken';
-import * as TransactionUserActions from '@userActions/Transaction';
-import * as TransactionEdit from '@userActions/TransactionEdit';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import type {Report, Transaction} from '@src/types/onyx';
-import type {WaypointCollection} from '@src/types/onyx/Transaction';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
-import DistanceRequestFooter from './DistanceRequestFooter';
-import DistanceRequestRenderItem from './DistanceRequestRenderItem';
-
-type DistanceRequestOnyxProps = {
- transaction: OnyxEntry;
-};
-
-type DistanceRequestProps = DistanceRequestOnyxProps & {
- /** The TransactionID of this request */
- transactionID?: string;
-
- /** The report to which the distance request is associated */
- report: OnyxEntry;
-
- /** Are we editing an existing distance request, or creating a new one? */
- isEditingRequest?: boolean;
-
- /** Are we editing the distance while creating a new distance request */
- isEditingNewRequest?: boolean;
-
- /** Called on submit of this page */
- onSubmit: (waypoints?: WaypointCollection) => void;
-
- /** React Navigation route */
- route: RouteProp<{
- /** Params from the route */
- params: {
- /** The type of IOU report, i.e. bill, request, send */
- iouType: string;
- /** The report ID of the IOU */
- reportID: string;
- };
- }>;
-};
-
-function DistanceRequest({transactionID = '', report, transaction, route, isEditingRequest = false, isEditingNewRequest = false, onSubmit}: DistanceRequestProps) {
- const styles = useThemeStyles();
- const {isOffline} = useNetwork();
- const {translate} = useLocalize();
-
- const [optimisticWaypoints, setOptimisticWaypoints] = useState();
- const [hasError, setHasError] = useState(false);
- const reportID = report?.reportID ?? '';
- const waypoints: WaypointCollection = useMemo(() => optimisticWaypoints ?? transaction?.comment?.waypoints ?? {waypoint0: {}, waypoint1: {}}, [optimisticWaypoints, transaction]);
- const waypointsList = Object.keys(waypoints);
- const iouType = route?.params?.iouType ?? '';
- const previousWaypoints = usePrevious(waypoints);
- const numberOfWaypoints = Object.keys(waypoints).length;
- const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length;
- const scrollViewRef = useRef(null);
-
- const isLoadingRoute = transaction?.comment?.isLoading ?? false;
- const isLoading = transaction?.isLoading ?? false;
- const hasRouteError = Boolean(transaction?.errorFields?.route);
- const hasRoute = TransactionUtils.hasRoute((transaction ?? {}) as Transaction);
- const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints);
- const previousValidatedWaypoints = usePrevious(validatedWaypoints);
- const haveValidatedWaypointsChanged = !lodashIsEqual(previousValidatedWaypoints, validatedWaypoints);
- const isRouteAbsentWithoutErrors = !hasRoute && !hasRouteError;
- const shouldFetchRoute = (isRouteAbsentWithoutErrors || haveValidatedWaypointsChanged) && !isLoadingRoute && Object.keys(validatedWaypoints).length > 1;
- const transactionWasSaved = useRef(false);
-
- useEffect(() => {
- MapboxToken.init();
- return MapboxToken.stop;
- }, []);
-
- useEffect(() => {
- if (!isEditingNewRequest && !isEditingRequest) {
- return () => {};
- }
- // This effect runs when the component is mounted and unmounted. It's purpose is to be able to properly
- // discard changes if the user cancels out of making any changes. This is accomplished by backing up the
- // original transaction, letting the user modify the current transaction, and then if the user ever
- // cancels out of the modal without saving changes, the original transaction is restored from the backup.
-
- // On mount, create the backup transaction.
- TransactionEdit.createBackupTransaction(transaction);
-
- return () => {
- // If the user cancels out of the modal without without saving changes, then the original transaction
- // needs to be restored from the backup so that all changes are removed.
- if (transactionWasSaved.current) {
- return;
- }
- TransactionEdit.restoreOriginalTransactionFromBackup(transaction?.transactionID ?? '');
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- useEffect(() => {
- const transactionWaypoints = transaction?.comment?.waypoints ?? {};
- if (!transaction?.transactionID || Object.keys(transactionWaypoints).length) {
- return;
- }
-
- // Create the initial start and stop waypoints
- TransactionUserActions.createInitialWaypoints(transactionID);
- return () => {
- // Whenever we reset the transaction, we need to set errors as empty/false.
- setHasError(false);
- };
- }, [transaction, transactionID]);
-
- useEffect(() => {
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- if (isOffline || !shouldFetchRoute) {
- return;
- }
-
- TransactionUserActions.getRoute(transactionID, validatedWaypoints);
- }, [shouldFetchRoute, transactionID, validatedWaypoints, isOffline]);
-
- useEffect(() => {
- if (numberOfWaypoints <= numberOfPreviousWaypoints) {
- return;
- }
- scrollViewRef.current?.scrollToEnd({animated: true});
- }, [numberOfPreviousWaypoints, numberOfWaypoints]);
-
- useEffect(() => {
- // Whenever we change waypoints we need to remove the error or it will keep showing the error.
- if (lodashIsEqual(previousWaypoints, waypoints)) {
- return;
- }
- setHasError(false);
- }, [waypoints, previousWaypoints]);
-
- const navigateBack = () => {
- Navigation.goBack(isEditingNewRequest ? ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID) : ROUTES.HOME);
- };
-
- /**
- * Takes the user to the page for editing a specific waypoint
- */
- const navigateToWaypointEditPage = (index: number) => {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(
- CONST.IOU.ACTION.EDIT,
- CONST.IOU.TYPE.REQUEST,
- transactionID,
- report?.reportID ?? '',
- index.toString(),
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- };
-
- const getError = useCallback(() => {
- // Get route error if available else show the invalid number of waypoints error.
- if (hasRouteError) {
- return ErrorUtils.getLatestErrorField((transaction ?? {}) as Transaction, 'route');
- }
-
- if (Object.keys(validatedWaypoints).length < 2) {
- // eslint-disable-next-line @typescript-eslint/naming-convention
- return {0: 'iou.error.atLeastTwoDifferentWaypoints'};
- }
-
- if (Object.keys(validatedWaypoints).length < Object.keys(waypoints).length) {
- // eslint-disable-next-line @typescript-eslint/naming-convention
- return {0: translate('iou.error.duplicateWaypointsErrorMessage')};
- }
- }, [translate, transaction, hasRouteError, validatedWaypoints, waypoints]);
-
- const updateWaypoints = useCallback(
- ({data}: DraggableListData) => {
- if (lodashIsEqual(waypointsList, data)) {
- return;
- }
-
- const newWaypoints: WaypointCollection = {};
- let emptyWaypointIndex = -1;
- data.forEach((waypoint, index) => {
- newWaypoints[`waypoint${index}`] = waypoints?.[waypoint] ?? {};
- // Find waypoint that BECOMES empty after dragging
- if (isEmptyObject(newWaypoints[`waypoint${index}`]) && !isEmptyObject(waypoints[`waypoint${index}`])) {
- emptyWaypointIndex = index;
- }
- });
-
- setOptimisticWaypoints(newWaypoints);
- // eslint-disable-next-line rulesdir/no-thenable-actions-in-views
- Promise.all([TransactionUserActions.removeWaypoint(transaction, emptyWaypointIndex.toString()), TransactionUserActions.updateWaypoints(transactionID, newWaypoints)]).then(() => {
- setOptimisticWaypoints(undefined);
- });
- },
- [transactionID, transaction, waypoints, waypointsList],
- );
-
- const submitWaypoints = useCallback(() => {
- // If there is any error or loading state, don't let user go to next page.
- if (!isEmptyObject(getError()) || isLoadingRoute || (isLoading && !isOffline)) {
- setHasError(true);
- return;
- }
-
- if (isEditingNewRequest || isEditingRequest) {
- transactionWasSaved.current = true;
- }
-
- onSubmit(waypoints);
- }, [onSubmit, setHasError, getError, isLoadingRoute, isLoading, waypoints, isEditingNewRequest, isEditingRequest, isOffline]);
-
- const content = (
- <>
-
- item}
- shouldUsePortal
- onDragEnd={updateWaypoints}
- ref={scrollViewRef}
- renderItem={({item, drag, isActive, getIndex}) => (
- number}
- onPress={navigateToWaypointEditPage}
- disabled={isLoadingRoute}
- />
- )}
- ListFooterComponent={
-
- }
- />
-
-
- {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */}
- {((hasError && !isEmptyObject(getError())) || hasRouteError) && (
-
- )}
-
-
- >
- );
-
- if (!isEditingNewRequest) {
- return content;
- }
-
- return (
-
- {({safeAreaPaddingBottomStyle}) => (
-
-
-
- {content}
-
-
- )}
-
- );
-}
-
-DistanceRequest.displayName = 'DistanceRequest';
-export default withOnyx({
- transaction: {
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID || 0}`,
- },
-})(DistanceRequest);
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/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx
index fa4c89216d08..07a2cb4b71ee 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.tsx
+++ b/src/components/LHNOptionsList/LHNOptionsList.tsx
@@ -109,7 +109,7 @@ function LHNOptionsList({
],
);
- const extraData = useMemo(() => [reportActions, reports, policy, personalDetails], [reportActions, reports, policy, personalDetails]);
+ const extraData = useMemo(() => [reportActions, reports, policy, personalDetails, data.length], [reportActions, reports, policy, personalDetails, data.length]);
return (
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/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index 2bf346ec8de4..9c3d9b8640e7 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -67,9 +67,12 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, chatReport, policy), [moneyRequestReport, chatReport, policy]);
+ const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(moneyRequestReport);
+
const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton;
const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0;
+ const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(moneyRequestReport);
const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE;
const shouldShowNextStep = !ReportUtils.isClosedExpenseReportWithNoExpenses(moneyRequestReport) && isFromPaidPolicy && !!nextStep?.message?.length;
const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep;
@@ -121,6 +124,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
addBankAccountRoute={bankAccountRoute}
shouldHidePaymentOptions={!shouldShowPayButton}
shouldShowApproveButton={shouldShowApproveButton}
+ shouldDisableApproveButton={shouldDisableApproveButton}
style={[styles.pv2]}
formattedAmount={formattedAmount}
isDisabled={!canAllowSettlement}
@@ -135,6 +139,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
text={translate('common.submit')}
style={[styles.mnw120, styles.pv2, styles.pr0]}
onPress={() => IOU.submitReport(moneyRequestReport)}
+ isDisabled={shouldDisableSubmitButton}
/>
)}
@@ -153,6 +158,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
addBankAccountRoute={bankAccountRoute}
shouldHidePaymentOptions={!shouldShowPayButton}
shouldShowApproveButton={shouldShowApproveButton}
+ shouldDisableApproveButton={shouldDisableApproveButton}
formattedAmount={formattedAmount}
isDisabled={!canAllowSettlement}
/>
@@ -166,6 +172,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
text={translate('common.submit')}
style={[styles.w100, styles.pr0]}
onPress={() => IOU.submitReport(moneyRequestReport)}
+ isDisabled={shouldDisableSubmitButton}
/>
)}
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index 2e8f80175b56..4550a7aef5d2 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}
@@ -714,7 +721,17 @@ function MoneyRequestConfirmationList({
description={translate('common.distance')}
style={styles.moneyRequestMenuItem}
titleStyle={styles.flex1}
- onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(iouType, reportID))}
+ onPress={() =>
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ )
+ }
disabled={didConfirm || !canEditDistance}
interactive={!isReadOnly}
/>
@@ -757,6 +774,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..f27cd507d668 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;
@@ -306,7 +307,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false);
const navigateBack = () => {
- Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(iouType, transaction.transactionID, reportID));
+ Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID));
};
const shouldDisplayFieldError = useMemo(() => {
@@ -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) {
@@ -721,7 +729,11 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
description={translate('common.distance')}
style={[styles.moneyRequestMenuItem]}
titleStyle={styles.flex1}
- onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))}
+ onPress={() =>
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()),
+ )
+ }
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
disabled={didConfirm || !canEditDistance}
interactive={!isReadOnly}
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..8f54de5182f8 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -24,6 +24,9 @@ type PopoverMenuItem = MenuItemProps & {
/** Sub menu items to be rendered after a menu item is selected */
subMenuItems?: PopoverMenuItem[];
+
+ /** Determines whether the menu item is disabled or not */
+ disabled?: boolean;
};
type PopoverModalProps = Pick;
@@ -205,6 +208,7 @@ function PopoverMenu({
displayInDefaultIconColor={item.displayInDefaultIconColor}
shouldShowRightIcon={item.shouldShowRightIcon}
shouldPutLeftPaddingWhenNoIcon={item.shouldPutLeftPaddingWhenNoIcon}
+ disabled={item.disabled}
/>
))}
@@ -215,4 +219,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/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index 70c65d1d66ce..bb0308ee4509 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -327,7 +327,11 @@ function MoneyRequestView({
interactive={canEditDistance}
shouldShowRightIcon={canEditDistance}
titleStyle={styles.flex1}
- onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DISTANCE))}
+ onPress={() =>
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID),
+ )
+ }
/>
) : (
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index f1aa1751dd84..b843443be4af 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));
@@ -152,6 +153,7 @@ function ReportPreview({
});
const shouldShowSubmitButton = isOpenExpenseReport && reimbursableSpend !== 0;
+ const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(iouReport);
// The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on
const isWaitingForSubmissionFromCurrentUser = useMemo(
@@ -208,6 +210,8 @@ function ReportPreview({
const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, chatReport, policy), [iouReport, chatReport, policy]);
+ const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport);
+
const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton;
const shouldPromptUserToAddBankAccount = ReportUtils.hasMissingPaymentMethod(userWallet, iouReportID);
@@ -306,6 +310,7 @@ function ReportPreview({
addBankAccountRoute={bankAccountRoute}
shouldHidePaymentOptions={!shouldShowPayButton}
shouldShowApproveButton={shouldShowApproveButton}
+ shouldDisableApproveButton={shouldDisableApproveButton}
kycWallAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
@@ -323,6 +328,7 @@ function ReportPreview({
success={isWaitingForSubmissionFromCurrentUser}
text={translate('common.submit')}
onPress={() => iouReport && IOU.submitReport(iouReport)}
+ isDisabled={shouldDisableSubmitButton}
/>
)}
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/SettlementButton.tsx b/src/components/SettlementButton.tsx
index 6b6ad3af737a..0ea8ea308d6a 100644
--- a/src/components/SettlementButton.tsx
+++ b/src/components/SettlementButton.tsx
@@ -60,6 +60,9 @@ type SettlementButtonProps = SettlementButtonOnyxProps & {
/** Should we show the payment options? */
shouldShowApproveButton?: boolean;
+ /** Should approve button be disabled? */
+ shouldDisableApproveButton?: boolean;
+
/** The policyID of the report we are paying */
policyID?: string;
@@ -124,6 +127,7 @@ function SettlementButton({
policyID = '',
shouldHidePaymentOptions = false,
shouldShowApproveButton = false,
+ shouldDisableApproveButton = false,
style,
shouldShowPersonalBankAccountOption = false,
enterKeyEventListenerPriority = 0,
@@ -166,6 +170,7 @@ function SettlementButton({
text: translate('iou.approve'),
icon: Expensicons.ThumbsUp,
value: CONST.IOU.REPORT_ACTION_TYPE.APPROVE,
+ disabled: !!shouldDisableApproveButton,
};
const canUseWallet = !isExpenseReport && currency === CONST.CURRENCY.USD;
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/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 75%
rename from src/components/VideoPlayer/VideoPlayerControls/index.js
rename to src/components/VideoPlayer/VideoPlayerControls/index.tsx
index 262613ce0797..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 {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;
- isPlaying: PropTypes.bool.isRequired,
+ /** Is video playing. */
+ isPlaying: boolean;
- // Defines if component should have small icons and tighter spacing inline
- small: PropTypes.bool,
+ /** Defines if component should have small icons and tighter spacing inline. */
+ small?: boolean;
- style: stylePropTypes,
+ /** Style of video player controls. */
+ style?: StyleProp;
- showPopoverMenu: PropTypes.func.isRequired,
+ /** Function called to show popover menu. */
+ showPopoverMenu: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise;
- togglePlayCurrentVideo: PropTypes.func.isRequired,
+ /** Function to play and pause the video. */
+ togglePlayCurrentVideo: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise;
};
-const defaultProps = {
- small: false,
- style: undefined,
-};
-
-function VideoPlayerControls({duration, position, url, videoPlayerRef, isPlaying, small, style, showPopoverMenu, togglePlayCurrentVideo}) {
+function VideoPlayerControls({duration, position, url, videoPlayerRef, isPlaying, small = false, style, showPopoverMenu, togglePlayCurrentVideo}: VideoPlayerControlsProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {updateCurrentlyPlayingURL} = usePlaybackContext();
const [shouldShowTime, setShouldShowTime] = useState(false);
const iconSpacing = small ? styles.mr3 : styles.mr4;
- const onLayout = (e) => {
- setShouldShowTime(e.nativeEvent.layout.width > CONST.VIDEO_PLAYER.HIDE_TIME_TEXT_WIDTH);
+ const onLayout = (event: LayoutChangeEvent) => {
+ setShouldShowTime(event.nativeEvent.layout.width > CONST.VIDEO_PLAYER.HIDE_TIME_TEXT_WIDTH);
};
const enterFullScreenMode = useCallback(() => {
@@ -58,7 +61,7 @@ function VideoPlayerControls({duration, position, url, videoPlayerRef, isPlaying
}, [updateCurrentlyPlayingURL, url, videoPlayerRef]);
const seekPosition = useCallback(
- (newPosition) => {
+ (newPosition: number) => {
videoPlayerRef.current.setStatusAsync({positionMillis: newPosition});
},
[videoPlayerRef],
@@ -116,8 +119,6 @@ function VideoPlayerControls({duration, position, url, videoPlayerRef, isPlaying
);
}
-VideoPlayerControls.propTypes = propTypes;
-VideoPlayerControls.defaultProps = defaultProps;
VideoPlayerControls.displayName = 'VideoPlayerControls';
export default VideoPlayerControls;
diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx
similarity index 80%
rename from src/components/VideoPlayerPreview/VideoPlayerThumbnail.js
rename to src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx
index bcdc37a6ce90..9bbd9fe5fc62 100644
--- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js
+++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx
@@ -1,6 +1,6 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
+import type {GestureResponderEvent} from 'react-native';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import Image from '@components/Image';
@@ -13,19 +13,18 @@ import * as ReportUtils from '@libs/ReportUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
-const propTypes = {
- onPress: PropTypes.func.isRequired,
+type VideoPlayerThumbnailProps = {
+ /** Url of thumbnail image. */
+ thumbnailUrl?: string;
- accessibilityLabel: PropTypes.string.isRequired,
+ /** Callback executed on thumbnail press. */
+ onPress: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise;
- thumbnailUrl: PropTypes.string,
+ /** Accessibility label for the thumbnail. */
+ accessibilityLabel: string;
};
-const defaultProps = {
- thumbnailUrl: undefined,
-};
-
-function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}) {
+function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}: VideoPlayerThumbnailProps) {
const styles = useThemeStyles();
return (
@@ -48,9 +47,7 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}) {
onPress={onPress}
onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
onPressOut={() => ControlSelection.unblock()}
- onLongPress={(event) =>
- showContextMenuForReport(event, anchor, (report && report.reportID) || '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))
- }
+ onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))}
shouldUseHapticsOnLongPress
>
@@ -69,8 +66,6 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}) {
);
}
-VideoPlayerThumbnail.propTypes = propTypes;
-VideoPlayerThumbnail.defaultProps = defaultProps;
VideoPlayerThumbnail.displayName = 'VideoPlayerThumbnail';
export default VideoPlayerThumbnail;
diff --git a/src/components/VideoPlayerPreview/index.js b/src/components/VideoPlayerPreview/index.tsx
similarity index 68%
rename from src/components/VideoPlayerPreview/index.js
rename to src/components/VideoPlayerPreview/index.tsx
index 252bc53fc839..a1e9568cc2ad 100644
--- a/src/components/VideoPlayerPreview/index.js
+++ b/src/components/VideoPlayerPreview/index.tsx
@@ -1,6 +1,7 @@
-import PropTypes from 'prop-types';
+import type {VideoReadyForDisplayEvent} from 'expo-av';
import React, {useEffect, useState} from 'react';
import {View} from 'react-native';
+import type {GestureResponderEvent} from 'react-native';
import * as Expensicons from '@components/Icon/Expensicons';
import VideoPlayer from '@components/VideoPlayer';
import IconButton from '@components/VideoPlayer/IconButton';
@@ -9,33 +10,34 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import useThumbnailDimensions from '@hooks/useThumbnailDimensions';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import CONST from '@src/CONST';
import VideoPlayerThumbnail from './VideoPlayerThumbnail';
-const propTypes = {
- videoUrl: PropTypes.string.isRequired,
+type VideoDimensions = {
+ width: number;
+ height: number;
+};
- videoDimensions: PropTypes.shape({
- width: PropTypes.number.isRequired,
- height: PropTypes.number.isRequired,
- }),
+type VideoPlayerPreviewProps = {
+ /** Url to a video. */
+ videoUrl: string;
- videoDuration: PropTypes.number,
+ /** Dimension of a video. */
+ videoDimensions: VideoDimensions;
- thumbnailUrl: PropTypes.string,
+ /** Duration of a video. */
+ videoDuration: number;
- fileName: PropTypes.string.isRequired,
+ /** Url to a thumbnail image. */
+ thumbnailUrl?: string;
- onShowModalPress: PropTypes.func.isRequired,
-};
+ /** Name of a video file. */
+ fileName: string;
-const defaultProps = {
- videoDimensions: CONST.VIDEO_PLAYER.DEFAULT_VIDEO_DIMENSIONS,
- thumbnailUrl: undefined,
- videoDuration: 0,
+ /** Callback executed when modal is pressed. */
+ onShowModalPress: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise;
};
-function VideoPlayerPreview({videoUrl, thumbnailUrl, fileName, videoDimensions, videoDuration, onShowModalPress}) {
+function VideoPlayerPreview({videoUrl, thumbnailUrl, fileName, videoDimensions, videoDuration, onShowModalPress}: VideoPlayerPreviewProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {currentlyPlayingURL, updateCurrentlyPlayingURL} = usePlaybackContext();
@@ -44,8 +46,10 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, fileName, videoDimensions,
const [measuredDimensions, setMeasuredDimensions] = useState(videoDimensions);
const {thumbnailDimensionsStyles} = useThumbnailDimensions(measuredDimensions.width, measuredDimensions.height);
- const onVideoLoaded = (e) => {
- setMeasuredDimensions({width: e.srcElement.videoWidth, height: e.srcElement.videoHeight});
+ // `onVideoLoaded` is passed to VideoPlayerPreview's `Video` element which is displayed only on web.
+ // VideoReadyForDisplayEvent type is lacking srcElement, that's why it's added here
+ const onVideoLoaded = (event: VideoReadyForDisplayEvent & {srcElement: HTMLVideoElement}) => {
+ setMeasuredDimensions({width: event.srcElement.videoWidth, height: event.srcElement.videoHeight});
};
const handleOnPress = () => {
@@ -75,7 +79,7 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, fileName, videoDimensions,
void}
videoDuration={videoDuration}
shouldUseSmallVideoControls
style={[styles.w100, styles.h100]}
@@ -94,8 +98,6 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, fileName, videoDimensions,
);
}
-VideoPlayerPreview.propTypes = propTypes;
-VideoPlayerPreview.defaultProps = defaultProps;
VideoPlayerPreview.displayName = 'VideoPlayerPreview';
export default VideoPlayerPreview;
diff --git a/src/components/VideoPopoverMenu/index.js b/src/components/VideoPopoverMenu/index.js
deleted file mode 100644
index 01aaa8e35174..000000000000
--- a/src/components/VideoPopoverMenu/index.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import PopoverMenu from '@components/PopoverMenu';
-import {useVideoPopoverMenuContext} from '@components/VideoPlayerContexts/VideoPopoverMenuContext';
-
-const propTypes = {
- isPopoverVisible: PropTypes.bool,
-
- hidePopover: PropTypes.func,
-
- anchorPosition: PropTypes.shape({
- horizontal: PropTypes.number.isRequired,
- vertical: PropTypes.number.isRequired,
- }),
-};
-const defaultProps = {
- isPopoverVisible: false,
- anchorPosition: {
- horizontal: 0,
- vertical: 0,
- },
- hidePopover: () => {},
-};
-
-function VideoPopoverMenu({isPopoverVisible, hidePopover, anchorPosition}) {
- const {menuItems} = useVideoPopoverMenuContext();
-
- return (
-
- );
-}
-
-VideoPopoverMenu.propTypes = propTypes;
-VideoPopoverMenu.defaultProps = defaultProps;
-VideoPopoverMenu.displayName = 'VideoPopoverMenu';
-
-export default VideoPopoverMenu;
diff --git a/src/components/VideoPopoverMenu/index.tsx b/src/components/VideoPopoverMenu/index.tsx
new file mode 100644
index 000000000000..aad6364f91df
--- /dev/null
+++ b/src/components/VideoPopoverMenu/index.tsx
@@ -0,0 +1,44 @@
+import React, {useRef} from 'react';
+import type {View} from 'react-native';
+import type {PopoverMenuItem} from '@components/PopoverMenu';
+import PopoverMenu from '@components/PopoverMenu';
+import {useVideoPopoverMenuContext} from '@components/VideoPlayerContexts/VideoPopoverMenuContext';
+import type {AnchorPosition} from '@styles/index';
+
+type VideoPopoverMenuProps = {
+ /** Whether popover menu is visible. */
+ isPopoverVisible?: boolean;
+
+ /** Callback executed to hide popover when an item is selected. */
+ hidePopover?: (selectedItem?: PopoverMenuItem, index?: number) => void;
+
+ /** The horizontal and vertical anchors points for the popover. */
+ anchorPosition?: AnchorPosition;
+};
+
+function VideoPopoverMenu({
+ isPopoverVisible = false,
+ hidePopover = () => {},
+ anchorPosition = {
+ horizontal: 0,
+ vertical: 0,
+ },
+}: VideoPopoverMenuProps) {
+ const {menuItems} = useVideoPopoverMenuContext();
+ const videoPlayerMenuRef = useRef(null);
+
+ return (
+
+ );
+}
+VideoPopoverMenu.displayName = 'VideoPopoverMenu';
+
+export default VideoPopoverMenu;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index cd2d92e25b22..c3ad6d82d6b2 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -20,13 +20,13 @@ import type {
DeleteActionParams,
DeleteConfirmationParams,
DidSplitAmountMessageParams,
+ DistanceRateOperationsParams,
EditActionParams,
ElectronicFundsParams,
EnterMagicCodeParams,
FormattedMaxLengthParams,
GoBackMessageParams,
GoToRoomParams,
- HeldRequestParams,
InstantSummaryParams,
LocalTimeParams,
LoggedInAsParams,
@@ -462,9 +462,14 @@ export default {
copyEmailToClipboard: 'Copy email to clipboard',
markAsUnread: 'Mark as unread',
markAsRead: 'Mark as read',
- editAction: ({action}: EditActionParams) => `Edit ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'request' : 'comment'}`,
- deleteAction: ({action}: DeleteActionParams) => `Delete ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'request' : 'comment'}`,
- deleteConfirmation: ({action}: DeleteConfirmationParams) => `Are you sure you want to delete this ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'request' : 'comment'}?`,
+ editAction: ({action}: EditActionParams) =>
+ `Edit ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'expense' : 'request'}` : 'comment'}`,
+ deleteAction: ({action}: DeleteActionParams) =>
+ `Delete ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'expense' : 'request'}` : 'comment'}`,
+ deleteConfirmation: ({action}: DeleteConfirmationParams) =>
+ `Are you sure you want to delete this ${
+ action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'expense' : 'request'}` : 'comment'
+ }?`,
onlyVisible: 'Only visible to',
replyInThread: 'Reply in thread',
joinThread: 'Join thread',
@@ -503,6 +508,8 @@ export default {
send: 'send money',
split: 'split a bill',
request: 'request money',
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'track-expense': 'track an expense',
},
},
reportAction: {
@@ -593,6 +600,7 @@ export default {
participants: 'Participants',
requestMoney: 'Request money',
sendMoney: 'Send money',
+ trackExpense: 'Track expense',
pay: 'Pay',
cancelPayment: 'Cancel payment',
cancelPaymentConfirmation: 'Are you sure that you want to cancel this payment?',
@@ -624,6 +632,7 @@ export default {
finished: 'Finished',
requestAmount: ({amount}: RequestAmountParams) => `request ${amount}`,
requestedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `requested ${formattedAmount}${comment ? ` for ${comment}` : ''}`,
+ trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? ` for ${comment}` : ''}`,
splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`,
didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? ` for ${comment}` : ''}`,
amountEach: ({amount}: AmountEachParams) => `${amount} each`,
@@ -655,6 +664,7 @@ export default {
updatedTheDistance: ({newDistanceToDisplay, oldDistanceToDisplay, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceParams) =>
`changed the distance to ${newDistanceToDisplay} (previously ${oldDistanceToDisplay}), which updated the amount to ${newAmountToDisplay} (previously ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} ${comment ? `for ${comment}` : 'request'}`,
+ threadTrackReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Tracking ${formattedAmount} ${comment ? `for ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`,
tagSelection: 'Select a tag to better organize your spend.',
categorySelection: 'Select a category to better organize your spend.',
@@ -681,7 +691,7 @@ export default {
hold: 'Hold',
holdRequest: 'Hold request',
unholdRequest: 'Unhold request',
- heldRequest: ({comment}: HeldRequestParams) => `held this request with the comment: ${comment}`,
+ heldRequest: 'held this request',
unheldRequest: 'unheld this request',
explainHold: "Explain why you're holding this request.",
reason: 'Reason',
@@ -1071,6 +1081,14 @@ export default {
},
},
},
+ workflowsDelayedSubmissionPage: {
+ autoReportingErrorMessage: 'The delayed submission parameter could not be changed. Please try again or contact support.',
+ autoReportingFrequencyErrorMessage: 'The submission frequency could not be changed. Please try again or contact support.',
+ monthlyOffsetErrorMessage: 'The monthly frequency could not be changed. Please try again or contact support.',
+ },
+ workflowsApprovalPage: {
+ genericErrorMessage: 'The approver could not be changed. Please try again or contact support.',
+ },
workflowsPayerPage: {
title: 'Authorized payer',
genericErrorMessage: 'The authorized payer could not be changed. Please try again.',
@@ -1876,7 +1894,20 @@ export default {
errors: {
taxRateAlreadyExists: 'This tax name is already in use.',
valuePercentageRange: 'Please enter a valid percentage between 0 and 100.',
- genericFailureMessage: 'An error occurred while updating the tax rate, please try again.',
+ customNameRequired: 'Custom tax name is required.',
+ deleteFailureMessage: 'An error occurred while deleting the tax rate. Please try again or ask Concierge for help.',
+ updateFailureMessage: 'An error occurred while updating the tax rate. Please try again or ask Concierge for help.',
+ createFailureMessage: 'An error occurred while creating the tax rate. Please try again or ask Concierge for help.',
+ },
+ deleteTaxConfirmation: 'Are you sure you want to delete this tax?',
+ deleteMultipleTaxConfirmation: ({taxAmount}) => `Are you sure you want to delete ${taxAmount} taxes?`,
+ actions: {
+ delete: 'Delete rate',
+ deleteMultiple: 'Delete rates',
+ disable: 'Disable rate',
+ disableMultiple: 'Disable rates',
+ enable: 'Enable rate',
+ enableMultiple: 'Enable rates',
},
},
emptyWorkspace: {
@@ -2005,17 +2036,17 @@ export default {
centrallyManage: 'Centrally manage rates, choose to track in miles or kilometers, and set a default category.',
rate: 'Rate',
addRate: 'Add rate',
- deleteRate: 'Delete rate',
- deleteRates: 'Delete rates',
+ deleteRates: ({count}: DistanceRateOperationsParams) => `Delete ${Str.pluralize('rate', 'rates', count)}`,
+ enableRates: ({count}: DistanceRateOperationsParams) => `Enable ${Str.pluralize('rate', 'rates', count)}`,
+ disableRates: ({count}: DistanceRateOperationsParams) => `Disable ${Str.pluralize('rate', 'rates', count)}`,
enableRate: 'Enable rate',
- disableRate: 'Disable rate',
- disableRates: 'Disable rates',
- enableRates: 'Enable rates',
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled',
unit: 'Unit',
defaultCategory: 'Default category',
+ deleteDistanceRate: 'Delete distance rate',
+ areYouSureDelete: ({count}: DistanceRateOperationsParams) => `Are you sure you want to delete ${Str.pluralize('this rate', 'these rates', count)}?`,
},
editor: {
descriptionInputLabel: 'Description',
@@ -2315,6 +2346,7 @@ export default {
deletedReport: '[Deleted report]',
deletedMessage: '[Deleted message]',
deletedRequest: '[Deleted request]',
+ deletedExpense: '[Deleted expense]',
reversedTransaction: '[Reversed transaction]',
deletedTask: '[Deleted task]',
hiddenMessage: '[Hidden message]',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 3a50e332fd57..78b80adb16d4 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -18,6 +18,7 @@ import type {
DeleteActionParams,
DeleteConfirmationParams,
DidSplitAmountMessageParams,
+ DistanceRateOperationsParams,
EditActionParams,
ElectronicFundsParams,
EnglishTranslation,
@@ -25,7 +26,6 @@ import type {
FormattedMaxLengthParams,
GoBackMessageParams,
GoToRoomParams,
- HeldRequestParams,
InstantSummaryParams,
LocalTimeParams,
LoggedInAsParams,
@@ -453,10 +453,18 @@ export default {
copyEmailToClipboard: 'Copiar email al portapapeles',
markAsUnread: 'Marcar como no leído',
markAsRead: 'Marcar como leído',
- editAction: ({action}: EditActionParams) => `Editar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'solicitud' : 'comentario'}`,
- deleteAction: ({action}: DeleteActionParams) => `Eliminar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'solicitud' : 'comentario'}`,
+ editAction: ({action}: EditActionParams) =>
+ `Editar ${
+ action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'gastos' : 'solicitud'}` : 'comentario'
+ }`,
+ deleteAction: ({action}: DeleteActionParams) =>
+ `Eliminar ${
+ action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'gastos' : 'solicitud'}` : 'comentario'
+ }`,
deleteConfirmation: ({action}: DeleteConfirmationParams) =>
- `¿Estás seguro de que quieres eliminar esta ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'solicitud' : 'comentario'}`,
+ `¿Estás seguro de que quieres eliminar esta ${
+ action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'gastos' : 'solicitud'}` : 'comentario'
+ }`,
onlyVisible: 'Visible sólo para',
replyInThread: 'Responder en el hilo',
joinThread: 'Unirse al hilo',
@@ -496,6 +504,8 @@ export default {
send: 'enviar dinero',
split: 'dividir una factura',
request: 'pedir dinero',
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'track-expense': 'rastrear un gasto',
},
},
reportAction: {
@@ -586,6 +596,7 @@ export default {
participants: 'Participantes',
requestMoney: 'Pedir dinero',
sendMoney: 'Enviar dinero',
+ trackExpense: 'Seguimiento de gastos',
pay: 'Pagar',
cancelPayment: 'Cancelar el pago',
cancelPaymentConfirmation: '¿Estás seguro de que quieres cancelar este pago?',
@@ -617,6 +628,7 @@ export default {
finished: 'Finalizado',
requestAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`,
requestedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `solicité ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
+ trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `seguimiento ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`,
didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
amountEach: ({amount}: AmountEachParams) => `${amount} cada uno`,
@@ -650,6 +662,7 @@ export default {
updatedTheDistance: ({newDistanceToDisplay, oldDistanceToDisplay, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceParams) =>
`cambió la distancia a ${newDistanceToDisplay} (previamente ${oldDistanceToDisplay}), lo que cambió el importe a ${newAmountToDisplay} (previamente ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${comment ? `${formattedAmount} para ${comment}` : `Solicitud de ${formattedAmount}`}`,
+ threadTrackReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Seguimiento ${formattedAmount} ${comment ? `para ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`,
tagSelection: 'Selecciona una etiqueta para organizar mejor tu dinero.',
categorySelection: 'Seleccione una categoría para organizar mejor tu dinero.',
@@ -675,7 +688,7 @@ export default {
enableWallet: 'Habilitar Billetera',
holdRequest: 'Bloquear solicitud',
unholdRequest: 'Desbloquear solicitud',
- heldRequest: ({comment}: HeldRequestParams) => `bloqueó esta solicitud con el comentario: ${comment}`,
+ heldRequest: 'bloqueó esta solicitud',
unheldRequest: 'desbloqueó esta solicitud',
explainHold: 'Explica la razón para bloquear esta solicitud.',
reason: 'Razón',
@@ -1067,6 +1080,14 @@ export default {
},
},
},
+ workflowsDelayedSubmissionPage: {
+ autoReportingErrorMessage: 'El parámetro de envío retrasado no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.',
+ autoReportingFrequencyErrorMessage: 'La frecuencia de envío no pudo ser cambiada. Por favor, inténtelo de nuevo o contacte al soporte.',
+ monthlyOffsetErrorMessage: 'La frecuencia mensual no pudo ser cambiada. Por favor, inténtelo de nuevo o contacte al soporte.',
+ },
+ workflowsApprovalPage: {
+ genericErrorMessage: 'El aprobador no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.',
+ },
workflowsPayerPage: {
title: 'Pagador autorizado',
genericErrorMessage: 'El pagador autorizado no se pudo cambiar. Por favor, inténtalo mas tarde.',
@@ -1899,8 +1920,21 @@ export default {
value: 'Valor',
errors: {
taxRateAlreadyExists: 'Ya existe un impuesto con este nombre',
- valuePercentageRange: 'Introduzca un porcentaje válido entre 0 y 100',
- genericFailureMessage: 'Se produjo un error al actualizar el tipo impositivo, inténtelo nuevamente.',
+ customNameRequired: 'El nombre del impuesto es obligatorio.',
+ valuePercentageRange: 'Por favor, introduce un porcentaje entre 0 y 100',
+ deleteFailureMessage: 'Se ha producido un error al intentar eliminar la tasa de impuesto. Por favor, inténtalo más tarde.',
+ updateFailureMessage: 'Se ha producido un error al intentar modificar la tasa de impuesto. Por favor, inténtalo más tarde.',
+ createFailureMessage: 'Se ha producido un error al intentar crear la tasa de impuesto. Por favor, inténtalo más tarde.',
+ },
+ deleteTaxConfirmation: '¿Estás seguro de que quieres eliminar este impuesto?',
+ deleteMultipleTaxConfirmation: ({taxAmount}) => `¿Estás seguro de que quieres eliminar ${taxAmount} impuestos?`,
+ actions: {
+ delete: 'Eliminar tasa',
+ deleteMultiple: 'Eliminar tasas',
+ disable: 'Desactivar tasa',
+ disableMultiple: 'Desactivar tasas',
+ enable: 'Activar tasa',
+ enableMultiple: 'Activar tasas',
},
},
emptyWorkspace: {
@@ -2030,17 +2064,17 @@ export default {
centrallyManage: 'Gestiona centralizadamente las tasas, elige si contabilizar en millas o kilómetros, y define una categoría por defecto',
rate: 'Tasa',
addRate: 'Agregar tasa',
- deleteRate: 'Eliminar tasa',
- deleteRates: 'Eliminar tasas',
+ deleteRates: ({count}: DistanceRateOperationsParams) => `Eliminar ${Str.pluralize('tasa', 'tasas', count)}`,
+ enableRates: ({count}: DistanceRateOperationsParams) => `Activar ${Str.pluralize('tasa', 'tasas', count)}`,
+ disableRates: ({count}: DistanceRateOperationsParams) => `Desactivar ${Str.pluralize('tasa', 'tasas', count)}`,
enableRate: 'Activar tasa',
- disableRate: 'Desactivar tasa',
- disableRates: 'Desactivar tasas',
- enableRates: 'Activar tasas',
status: 'Estado',
enabled: 'Activada',
disabled: 'Desactivada',
unit: 'Unidad',
defaultCategory: 'Categoría predeterminada',
+ deleteDistanceRate: 'Eliminar tasa de distancia',
+ areYouSureDelete: ({count}: DistanceRateOperationsParams) => `¿Estás seguro de que quieres eliminar ${Str.pluralize('esta tasa', 'estas tasas', count)}?`,
},
editor: {
nameInputLabel: 'Nombre',
@@ -2803,6 +2837,7 @@ export default {
deletedReport: '[Informe eliminado]',
deletedMessage: '[Mensaje eliminado]',
deletedRequest: '[Solicitud eliminada]',
+ deletedExpense: '[Gasto eliminado]',
reversedTransaction: '[Transacción anulada]',
deletedTask: '[Tarea eliminada]',
hiddenMessage: '[Mensaje oculto]',
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 93438d76885d..c365363f84af 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -295,6 +295,8 @@ type LogSizeParams = {size: number};
type HeldRequestParams = {comment: string};
+type DistanceRateOperationsParams = {count: number};
+
export type {
AdminCanceledRequestParams,
ApprovedAmountParams,
@@ -313,6 +315,7 @@ export type {
DeleteActionParams,
DeleteConfirmationParams,
DidSplitAmountMessageParams,
+ DistanceRateOperationsParams,
EditActionParams,
ElectronicFundsParams,
EnglishTranslation,
diff --git a/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts
new file mode 100644
index 000000000000..d4f972ff9757
--- /dev/null
+++ b/src/libs/API/parameters/DeletePolicyDistanceRatesParams.ts
@@ -0,0 +1,7 @@
+type DeletePolicyDistanceRatesParams = {
+ policyID: string;
+ customUnitID: string;
+ customUnitRateID: string[];
+};
+
+export default DeletePolicyDistanceRatesParams;
diff --git a/src/libs/API/parameters/DeletePolicyTaxesParams.ts b/src/libs/API/parameters/DeletePolicyTaxesParams.ts
new file mode 100644
index 000000000000..9e0963cdcb28
--- /dev/null
+++ b/src/libs/API/parameters/DeletePolicyTaxesParams.ts
@@ -0,0 +1,11 @@
+type DeletePolicyTaxesParams = {
+ policyID: string;
+ /**
+ * Stringified JSON object with type of following structure:
+ * Array
+ * Each element is a tax name
+ */
+ taxNames: string;
+};
+
+export default DeletePolicyTaxesParams;
diff --git a/src/libs/API/parameters/GetRouteForDraftParams.ts b/src/libs/API/parameters/GetRouteForDraftParams.ts
deleted file mode 100644
index 5a213c3f2d49..000000000000
--- a/src/libs/API/parameters/GetRouteForDraftParams.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-type GetRouteForDraftParams = {
- transactionID: string;
- waypoints: string;
-};
-
-export default GetRouteForDraftParams;
diff --git a/src/libs/API/parameters/HoldMoneyRequestParams.ts b/src/libs/API/parameters/HoldMoneyRequestParams.ts
index 93cb1bd6c524..357194d7ae56 100644
--- a/src/libs/API/parameters/HoldMoneyRequestParams.ts
+++ b/src/libs/API/parameters/HoldMoneyRequestParams.ts
@@ -2,6 +2,7 @@ type HoldMoneyRequestParams = {
transactionID: string;
comment: string;
reportActionID: string;
+ commentReportActionID: string;
};
export default HoldMoneyRequestParams;
diff --git a/src/libs/API/parameters/OpenReportParams.ts b/src/libs/API/parameters/OpenReportParams.ts
index 477a002516de..8eaed6bc0fde 100644
--- a/src/libs/API/parameters/OpenReportParams.ts
+++ b/src/libs/API/parameters/OpenReportParams.ts
@@ -1,5 +1,6 @@
type OpenReportParams = {
reportID: string;
+ reportActionID?: string;
emailList?: string;
accountIDList?: string;
parentReportActionID?: string;
diff --git a/src/libs/API/parameters/RenamePolicyTaxParams.ts b/src/libs/API/parameters/RenamePolicyTaxParams.ts
new file mode 100644
index 000000000000..b722f14e7b6e
--- /dev/null
+++ b/src/libs/API/parameters/RenamePolicyTaxParams.ts
@@ -0,0 +1,7 @@
+type SetPolicyCurrencyDefaultParams = {
+ policyID: string;
+ taxCode: string;
+ newName: string;
+};
+
+export default SetPolicyCurrencyDefaultParams;
diff --git a/src/libs/API/parameters/SetPolicyDistanceRatesEnabledParams.ts b/src/libs/API/parameters/SetPolicyDistanceRatesEnabledParams.ts
new file mode 100644
index 000000000000..95f5e61448d4
--- /dev/null
+++ b/src/libs/API/parameters/SetPolicyDistanceRatesEnabledParams.ts
@@ -0,0 +1,7 @@
+type SetPolicyDistanceRatesEnabledParams = {
+ policyID: string;
+ customUnitID: string;
+ customUnitRateArray: string;
+};
+
+export default SetPolicyDistanceRatesEnabledParams;
diff --git a/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts b/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts
new file mode 100644
index 000000000000..4ed0a05cfdec
--- /dev/null
+++ b/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts
@@ -0,0 +1,10 @@
+type SetPolicyTaxesEnabledParams = {
+ policyID: string;
+ /**
+ * Stringified JSON object with type of following structure:
+ * Array<{taxCode: string, enabled: bool}>
+ */
+ taxFieldsArray: string;
+};
+
+export default SetPolicyTaxesEnabledParams;
diff --git a/src/libs/API/parameters/TrackExpenseParams.ts b/src/libs/API/parameters/TrackExpenseParams.ts
new file mode 100644
index 000000000000..f48c8666f109
--- /dev/null
+++ b/src/libs/API/parameters/TrackExpenseParams.ts
@@ -0,0 +1,30 @@
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+import type {Receipt} from '@src/types/onyx/Transaction';
+
+type TrackExpenseParams = {
+ amount: number;
+ currency: string;
+ comment: string;
+ created: string;
+ merchant: string;
+ iouReportID?: string;
+ chatReportID: string;
+ transactionID: string;
+ reportActionID: string;
+ createdChatReportActionID: string;
+ createdIOUReportActionID?: string;
+ reportPreviewReportActionID?: string;
+ receipt: Receipt;
+ receiptState?: ValueOf;
+ category?: string;
+ tag?: string;
+ taxCode: string;
+ taxAmount: number;
+ billable?: boolean;
+ gpsPoints?: string;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
+};
+
+export default TrackExpenseParams;
diff --git a/src/libs/API/parameters/UpdatePolicyDistanceRateValueParams.ts b/src/libs/API/parameters/UpdatePolicyDistanceRateValueParams.ts
new file mode 100644
index 000000000000..c16487b3da60
--- /dev/null
+++ b/src/libs/API/parameters/UpdatePolicyDistanceRateValueParams.ts
@@ -0,0 +1,7 @@
+type UpdatePolicyDistanceRateValueParams = {
+ policyID: string;
+ customUnitID: string;
+ customUnitRateArray: string;
+};
+
+export default UpdatePolicyDistanceRateValueParams;
diff --git a/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts b/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts
new file mode 100644
index 000000000000..1124755ea9ef
--- /dev/null
+++ b/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts
@@ -0,0 +1,7 @@
+type UpdatePolicyTaxValueParams = {
+ policyID: string;
+ taxCode: string;
+ taxAmount: number;
+};
+
+export default UpdatePolicyTaxValueParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 84ace32d6261..1895c2426e1a 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -20,7 +20,6 @@ export type {default as GetMissingOnyxMessagesParams} from './GetMissingOnyxMess
export type {default as GetNewerActionsParams} from './GetNewerActionsParams';
export type {default as GetOlderActionsParams} from './GetOlderActionsParams';
export type {default as GetReportPrivateNoteParams} from './GetReportPrivateNoteParams';
-export type {default as GetRouteForDraftParams} from './GetRouteForDraftParams';
export type {default as GetRouteParams} from './GetRouteParams';
export type {default as GetStatementPDFParams} from './GetStatementPDFParams';
export type {default as HandleRestrictedEventParams} from './HandleRestrictedEventParams';
@@ -164,6 +163,7 @@ export type {default as SetWorkspaceReimbursementParams} from './SetWorkspaceRei
export type {default as SetPolicyRequiresTag} from './SetPolicyRequiresTag';
export type {default as RenamePolicyTaglist} from './RenamePolicyTaglist';
export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams';
+export type {default as TrackExpenseParams} from './TrackExpenseParams';
export type {default as EnablePolicyCategoriesParams} from './EnablePolicyCategoriesParams';
export type {default as EnablePolicyConnectionsParams} from './EnablePolicyConnectionsParams';
export type {default as EnablePolicyDistanceRatesParams} from './EnablePolicyDistanceRatesParams';
@@ -183,9 +183,16 @@ export type {default as OpenPolicyMoreFeaturesPageParams} from './OpenPolicyMore
export type {default as CreatePolicyDistanceRateParams} from './CreatePolicyDistanceRateParams';
export type {default as SetPolicyDistanceRatesUnitParams} from './SetPolicyDistanceRatesUnitParams';
export type {default as SetPolicyDistanceRatesDefaultCategoryParams} from './SetPolicyDistanceRatesDefaultCategoryParams';
+export type {default as UpdatePolicyDistanceRateValueParams} from './UpdatePolicyDistanceRateValueParams';
+export type {default as SetPolicyDistanceRatesEnabledParams} from './SetPolicyDistanceRatesEnabledParams';
+export type {default as DeletePolicyDistanceRatesParams} from './DeletePolicyDistanceRatesParams';
export type {default as CreatePolicyTagsParams} from './CreatePolicyTagsParams';
+export type {default as SetPolicyTaxesEnabledParams} from './SetPolicyTaxesEnabledParams';
+export type {default as DeletePolicyTaxesParams} from './DeletePolicyTaxesParams';
+export type {default as UpdatePolicyTaxValueParams} from './UpdatePolicyTaxValueParams';
export type {default as RenamePolicyTagsParams} from './RenamePolicyTagsParams';
export type {default as DeletePolicyTagsParams} from './DeletePolicyTagsParams';
export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTaxNameParams';
export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams';
export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrencyDefaultParams';
+export type {default as RenamePolicyTaxParams} from './RenamePolicyTaxParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 8d359febfd0f..9d6e6b3929b8 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -10,7 +10,7 @@ type ApiRequest = ValueOf;
const WRITE_COMMANDS = {
SET_WORKSPACE_AUTO_REPORTING: 'SetWorkspaceAutoReporting',
SET_WORKSPACE_AUTO_REPORTING_FREQUENCY: 'SetWorkspaceAutoReportingFrequency',
- SET_WORKSPACE_AUTO_REPORTING_MONTHLY_OFFSET: 'UpdatePolicy',
+ SET_WORKSPACE_AUTO_REPORTING_MONTHLY_OFFSET: 'SetWorkspaceAutoReportingOffset',
SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode',
SET_WORKSPACE_PAYER: 'SetWorkspacePayer',
SET_WORKSPACE_REIMBURSEMENT: 'SetWorkspaceReimbursement',
@@ -168,6 +168,7 @@ const WRITE_COMMANDS = {
CANCEL_PAYMENT: 'CancelPayment',
ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT: 'AcceptACHContractForBankAccount',
SWITCH_TO_OLD_DOT: 'SwitchToOldDot',
+ TRACK_EXPENSE: 'TrackExpense',
ENABLE_POLICY_CATEGORIES: 'EnablePolicyCategories',
ENABLE_POLICY_CONNECTIONS: 'EnablePolicyConnections',
ENABLE_POLICY_DISTANCE_RATES: 'EnablePolicyDistanceRates',
@@ -182,9 +183,16 @@ const WRITE_COMMANDS = {
ACCEPT_JOIN_REQUEST: 'AcceptJoinRequest',
DECLINE_JOIN_REQUEST: 'DeclineJoinRequest',
CREATE_POLICY_TAX: 'CreatePolicyTax',
+ SET_POLICY_TAXES_ENABLED: 'SetPolicyTaxesEnabled',
+ DELETE_POLICY_TAXES: 'DeletePolicyTaxes',
+ UPDATE_POLICY_TAX_VALUE: 'UpdatePolicyTaxValue',
+ RENAME_POLICY_TAX: 'RenamePolicyTax',
CREATE_POLICY_DISTANCE_RATE: 'CreatePolicyDistanceRate',
SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit',
SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory',
+ UPDATE_POLICY_DISTANCE_RATE_VALUE: 'UpdatePolicyDistanceRateValue',
+ SET_POLICY_DISTANCE_RATES_ENABLED: 'SetPolicyDistanceRatesEnabled',
+ DELETE_POLICY_DISTANCE_RATES: 'DeletePolicyDistanceRates',
} as const;
type WriteCommand = ValueOf;
@@ -351,6 +359,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.SET_WORKSPACE_PAYER]: Parameters.SetWorkspacePayerParams;
[WRITE_COMMANDS.SET_WORKSPACE_REIMBURSEMENT]: Parameters.SetWorkspaceReimbursementParams;
[WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams;
+ [WRITE_COMMANDS.TRACK_EXPENSE]: Parameters.TrackExpenseParams;
[WRITE_COMMANDS.ENABLE_POLICY_CATEGORIES]: Parameters.EnablePolicyCategoriesParams;
[WRITE_COMMANDS.ENABLE_POLICY_CONNECTIONS]: Parameters.EnablePolicyConnectionsParams;
[WRITE_COMMANDS.ENABLE_POLICY_DISTANCE_RATES]: Parameters.EnablePolicyDistanceRatesParams;
@@ -365,9 +374,16 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.SET_POLICY_CUSTOM_TAX_NAME]: Parameters.SetPolicyCustomTaxNameParams;
[WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT]: Parameters.SetPolicyForeignCurrencyDefaultParams;
[WRITE_COMMANDS.CREATE_POLICY_TAX]: Parameters.CreatePolicyTaxParams;
+ [WRITE_COMMANDS.SET_POLICY_TAXES_ENABLED]: Parameters.SetPolicyTaxesEnabledParams;
+ [WRITE_COMMANDS.DELETE_POLICY_TAXES]: Parameters.DeletePolicyTaxesParams;
+ [WRITE_COMMANDS.UPDATE_POLICY_TAX_VALUE]: Parameters.UpdatePolicyTaxValueParams;
[WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE]: Parameters.CreatePolicyDistanceRateParams;
+ [WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams;
[WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams;
[WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams;
+ [WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_RATE_VALUE]: Parameters.UpdatePolicyDistanceRateValueParams;
+ [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_ENABLED]: Parameters.SetPolicyDistanceRatesEnabledParams;
+ [WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES]: Parameters.DeletePolicyDistanceRatesParams;
};
const READ_COMMANDS = {
@@ -427,7 +443,7 @@ type ReadCommandParameters = {
[READ_COMMANDS.SEARCH_FOR_REPORTS]: Parameters.SearchForReportsParams;
[READ_COMMANDS.SEND_PERFORMANCE_TIMING]: Parameters.SendPerformanceTimingParams;
[READ_COMMANDS.GET_ROUTE]: Parameters.GetRouteParams;
- [READ_COMMANDS.GET_ROUTE_FOR_DRAFT]: Parameters.GetRouteForDraftParams;
+ [READ_COMMANDS.GET_ROUTE_FOR_DRAFT]: Parameters.GetRouteParams;
[READ_COMMANDS.GET_STATEMENT_PDF]: Parameters.GetStatementPDFParams;
[READ_COMMANDS.OPEN_ONFIDO_FLOW]: EmptyObject;
[READ_COMMANDS.OPEN_INITIAL_SETTINGS_PAGE]: EmptyObject;
diff --git a/src/libs/E2E/client.ts b/src/libs/E2E/client.ts
index 4c0e572cc9b2..5aa999267ead 100644
--- a/src/libs/E2E/client.ts
+++ b/src/libs/E2E/client.ts
@@ -105,4 +105,4 @@ export default {
updateNetworkCache,
getNetworkCache,
};
-export type {TestResult, NativeCommand};
+export type {TestResult, NativeCommand, NativeCommandPayload};
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts
index 29781e718c6f..05f6fbd17503 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.ts
@@ -37,7 +37,10 @@ const findEmojiByName = (name: string): Emoji => Emojis.emojiNameTable[name];
const findEmojiByCode = (code: string): Emoji => Emojis.emojiCodeTableWithSkinTones[code];
-const getEmojiName = (emoji: Emoji, lang: 'en' | 'es' = CONST.LOCALES.DEFAULT): string => {
+const getEmojiName = (emoji: Emoji, lang: Locale = CONST.LOCALES.DEFAULT): string => {
+ if (!emoji) {
+ return '';
+ }
if (lang === CONST.LOCALES.DEFAULT) {
return emoji.name;
}
diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts
index 784d339a4a0d..d38700efd53d 100644
--- a/src/libs/ErrorUtils.ts
+++ b/src/libs/ErrorUtils.ts
@@ -110,6 +110,21 @@ function getEarliestErrorField(onyxDa
return {[key]: getErrorMessageWithTranslationData(errorsForField[key])};
}
+/**
+ * Method used to get the latest error field for any field
+ */
+function getLatestErrorFieldForAnyField(onyxData: TOnyxData): Errors {
+ const errorFields = onyxData.errorFields ?? {};
+
+ if (Object.keys(errorFields).length === 0) {
+ return {};
+ }
+
+ const fieldNames = Object.keys(errorFields);
+ const latestErrorFields = fieldNames.map((fieldName) => getLatestErrorField(onyxData, fieldName));
+ return latestErrorFields.reduce((acc, error) => ({...acc, ...error}), {});
+}
+
/**
* Method used to attach already translated message with isTranslated property
* @param errors - An object containing current errors in the form
@@ -176,6 +191,7 @@ export {
getLatestErrorField,
getLatestErrorMessage,
getLatestErrorMessageField,
+ getLatestErrorFieldForAnyField,
getMicroSecondOnyxError,
getMicroSecondOnyxErrorObject,
isReceiptError,
diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts
index 58a82de3df53..a18de0fdcbbf 100644
--- a/src/libs/GroupChatUtils.ts
+++ b/src/libs/GroupChatUtils.ts
@@ -6,8 +6,11 @@ import * as ReportUtils from './ReportUtils';
/**
* Returns the report name if the report is a group chat
*/
-function getGroupChatName(report: OnyxEntry): string | undefined {
- const participants = report?.participantAccountIDs ?? [];
+function getGroupChatName(report: OnyxEntry, shouldApplyLimit = false): string | undefined {
+ let participants = report?.participantAccountIDs ?? [];
+ if (shouldApplyLimit) {
+ participants = participants.slice(0, 5);
+ }
const isMultipleParticipantReport = participants.length > 1;
return participants
diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts
index 56ac47676a37..65390982f18c 100644
--- a/src/libs/IOUUtils.ts
+++ b/src/libs/IOUUtils.ts
@@ -11,13 +11,13 @@ function navigateToStartMoneyRequestStep(requestType: ValueOf(desiredLanguage: 'en' | 'es' |
const languageAbbreviation = desiredLanguage.substring(0, 2) as 'en' | 'es';
const translatedPhrase = getTranslatedPhrase(language, phraseKey, languageAbbreviation, ...phraseParameters);
- if (translatedPhrase) {
+ if (translatedPhrase !== null && translatedPhrase !== undefined) {
return translatedPhrase;
}
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 5835558e5de4..463dcfcd9e99 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -146,6 +146,7 @@ const modalScreenListeners = {
// Clear search input (WorkspaceInvitePage) when modal is closed
SearchInputManager.searchInput = '';
Modal.setModalVisibility(false);
+ Modal.willAlertModalBecomeVisible(false);
},
};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
index b576f0e7601a..bc14f346c3f9 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
@@ -103,7 +103,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Wallet/AddDebitCardPage').default as React.ComponentType,
[SCREENS.IOU_SEND.ENABLE_PAYMENTS]: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.WAYPOINT]: () => require('../../../pages/iou/MoneyRequestWaypointPage').default as React.ComponentType,
- [SCREENS.MONEY_REQUEST.DISTANCE]: () => require('../../../pages/iou/NewDistanceRequestPage').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.RECEIPT]: () => require('../../../pages/EditRequestReceiptPage').default as React.ComponentType,
});
@@ -265,6 +264,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/categories/EditCategoryPage').default as React.ComponentType,
[SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: () => require('../../../pages/workspace/distanceRates/CreateDistanceRatePage').default as React.ComponentType,
[SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: () => require('../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: () => require('../../../pages/workspace/distanceRates/PolicyDistanceRateDetailsPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: () => require('../../../pages/workspace/distanceRates/PolicyDistanceRateEditPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAGS_SETTINGS]: () => require('../../../pages/workspace/tags/WorkspaceTagsSettingsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAG_SETTINGS]: () => require('../../../pages/workspace/tags/TagSettingsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAGS_EDIT]: () => require('../../../pages/workspace/tags/WorkspaceEditTagsPage').default as React.ComponentType,
@@ -284,6 +285,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/ExitSurvey/ExitSurveyConfirmPage').default as React.ComponentType,
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default as React.ComponentType,
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.TAX_EDIT]: () => require('../../../pages/workspace/taxes/WorkspaceEditTaxPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.TAX_NAME]: () => require('../../../pages/workspace/taxes/NamePage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.TAX_VALUE]: () => require('../../../pages/workspace/taxes/ValuePage').default as React.ComponentType,
[SCREENS.WORKSPACE.TAX_CREATE]: () => require('../../../pages/workspace/taxes/WorkspaceCreateTaxPage').default as React.ComponentType,
});
diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts
index c55145a5d580..d57d0272738b 100644
--- a/src/libs/Navigation/Navigation.ts
+++ b/src/libs/Navigation/Navigation.ts
@@ -94,7 +94,7 @@ function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number
function parseHybridAppUrl(url: HybridAppRoute | Route): Route {
switch (url) {
case HYBRID_APP_ROUTES.MONEY_REQUEST_CREATE:
- return ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.REQUEST, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID());
+ return ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.REQUEST, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID());
default:
return url;
}
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 7388d6447ffa..35b129e8b0c0 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -17,10 +17,19 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME,
SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT,
SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT,
+ SCREENS.WORKSPACE.TAX_CREATE,
+ SCREENS.WORKSPACE.TAX_EDIT,
+ SCREENS.WORKSPACE.TAX_NAME,
+ SCREENS.WORKSPACE.TAX_VALUE,
],
[SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE, SCREENS.WORKSPACE.TAG_SETTINGS, SCREENS.WORKSPACE.TAG_EDIT],
[SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS, SCREENS.WORKSPACE.CATEGORY_EDIT],
- [SCREENS.WORKSPACE.DISTANCE_RATES]: [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE, SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS],
+ [SCREENS.WORKSPACE.DISTANCE_RATES]: [
+ SCREENS.WORKSPACE.CREATE_DISTANCE_RATE,
+ SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS,
+ SCREENS.WORKSPACE.DISTANCE_RATE_EDIT,
+ SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS,
+ ],
};
export default FULL_SCREEN_TO_RHP_MAPPING;
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 391d584d5a78..b8b9280bc576 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -295,6 +295,12 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: {
path: ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.route,
},
+ [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: {
+ path: ROUTES.WORKSPACE_DISTANCE_RATE_DETAILS.route,
+ },
+ [SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: {
+ path: ROUTES.WORKSPACE_DISTANCE_RATE_EDIT.route,
+ },
[SCREENS.WORKSPACE.TAGS_SETTINGS]: {
path: ROUTES.WORKSPACE_TAGS_SETTINGS.route,
},
@@ -354,6 +360,15 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.TAX_CREATE]: {
path: ROUTES.WORKSPACE_TAX_CREATE.route,
},
+ [SCREENS.WORKSPACE.TAX_EDIT]: {
+ path: ROUTES.WORKSPACE_TAX_EDIT.route,
+ },
+ [SCREENS.WORKSPACE.TAX_NAME]: {
+ path: ROUTES.WORKSPACE_TAX_NAME.route,
+ },
+ [SCREENS.WORKSPACE.TAX_VALUE]: {
+ path: ROUTES.WORKSPACE_TAX_VALUE.route,
+ },
},
},
[SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: {
@@ -501,7 +516,6 @@ const config: LinkingOptions['config'] = {
[SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route,
[SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route,
[SCREENS.MONEY_REQUEST.RECEIPT]: ROUTES.MONEY_REQUEST_RECEIPT.route,
- [SCREENS.MONEY_REQUEST.DISTANCE]: ROUTES.MONEY_REQUEST_DISTANCE.route,
[SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS,
[SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: ROUTES.IOU_SEND_ADD_BANK_ACCOUNT,
[SCREENS.IOU_SEND.ADD_DEBIT_CARD]: ROUTES.IOU_SEND_ADD_DEBIT_CARD,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 90fad4f29f22..3f85aec3a560 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -182,6 +182,14 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.TAG_CREATE]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: {
+ policyID: string;
+ rateID: string;
+ };
+ [SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: {
+ policyID: string;
+ rateID: string;
+ };
[SCREENS.WORKSPACE.TAGS_SETTINGS]: {
policyID: string;
};
@@ -242,6 +250,18 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.TAX_CREATE]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.TAX_EDIT]: {
+ policyID: string;
+ taxID: string;
+ };
+ [SCREENS.WORKSPACE.TAX_NAME]: {
+ policyID: string;
+ taxID: string;
+ };
+ [SCREENS.WORKSPACE.TAX_VALUE]: {
+ policyID: string;
+ taxID: string;
+ };
} & ReimbursementAccountNavigatorParamList;
type NewChatNavigatorParamList = {
@@ -364,6 +384,7 @@ type MoneyRequestNavigatorParamList = {
backTo: Routes | undefined;
action: ValueOf;
pageIndex: string;
+ transactionID: string;
};
[SCREENS.MONEY_REQUEST.STEP_MERCHANT]: {
action: ValueOf;
@@ -381,9 +402,12 @@ type MoneyRequestNavigatorParamList = {
waypointIndex: string;
threadReportID: number;
};
- [SCREENS.MONEY_REQUEST.DISTANCE]: {
+ [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: {
+ action: string;
iouType: ValueOf;
+ transactionID: string;
reportID: string;
+ backTo: string;
};
[SCREENS.MONEY_REQUEST.RECEIPT]: {
iouType: string;
diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts
index 0a5cfad2d146..5a19e68afe72 100644
--- a/src/libs/NextStepUtils.ts
+++ b/src/libs/NextStepUtils.ts
@@ -258,7 +258,7 @@ function buildNextStep(
text: 'Waiting for ',
},
{
- text: managerDisplayName,
+ text: 'you',
type: 'strong',
},
{
@@ -281,7 +281,7 @@ function buildNextStep(
text: 'Waiting for ',
},
{
- text: 'you',
+ text: managerDisplayName,
type: 'strong',
},
{
diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts
index 62d6fa00906a..2dfc1e722c58 100644
--- a/src/libs/NumberUtils.ts
+++ b/src/libs/NumberUtils.ts
@@ -92,4 +92,12 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
-export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple, roundToTwoDecimalPlaces, clamp};
+function generateNewRandomInt(old: number, min: number, max: number): number {
+ let newNum = old;
+ while (newNum === old) {
+ newNum = generateRandomInt(min, max);
+ }
+ return newNum;
+}
+
+export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple, roundToTwoDecimalPlaces, clamp, generateNewRandomInt};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index bacd019904a3..0f83b260c8f2 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -249,9 +249,11 @@ Onyx.connect({
});
const policyExpenseReports: OnyxCollection = {};
+const allReports: OnyxCollection = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
callback: (report, key) => {
+ allReports[key] = report;
if (!ReportUtils.isPolicyExpenseChat(report)) {
return;
}
@@ -521,7 +523,8 @@ function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry<
*/
function getLastActorDisplayName(lastActorDetails: Partial | null, hasMultipleParticipants: boolean) {
return hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID !== currentUserAccountID
- ? lastActorDetails.firstName ?? PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails)
+ ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ lastActorDetails.firstName || PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails)
: '';
}
@@ -734,6 +737,35 @@ function createOption(
return result;
}
+/**
+ * Get the option for a given report.
+ */
+function getReportOption(participant: Participant): ReportUtils.OptionData {
+ const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.reportID}`];
+
+ const option = createOption(
+ report?.visibleChatMemberAccountIDs ?? [],
+ allPersonalDetails ?? {},
+ report ?? null,
+ {},
+ {
+ showChatPreviewLine: false,
+ forcePolicyNamePreview: false,
+ },
+ );
+
+ // Update text & alternateText because createOption returns workspace name only if report is owned by the user
+ if (option.isSelfDM) {
+ option.alternateText = Localize.translateLocal('reportActionsView.yourSpace');
+ } else {
+ option.text = ReportUtils.getPolicyName(report);
+ option.alternateText = Localize.translateLocal('workspace.common.workspace');
+ }
+ option.selected = participant.selected;
+ option.isSelected = participant.selected;
+ return option;
+}
+
/**
* Get the option for a policy expense report.
*/
@@ -2073,6 +2105,7 @@ export {
formatSectionsFromSearchTerm,
transformedTaxRates,
getShareLogOptions,
+ getReportOption,
getTaxRatesSection,
};
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index 26df03134fd5..071113a70fae 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -26,6 +26,10 @@ function canUseViolations(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.VIOLATIONS) || canUseAllBetas(betas);
}
+function canUseTrackExpense(betas: OnyxEntry): boolean {
+ return !!betas?.includes(CONST.BETAS.TRACK_EXPENSE) || canUseAllBetas(betas);
+}
+
function canUseP2PDistanceRequests(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.P2P_DISTANCE_REQUESTS) || canUseAllBetas(betas);
}
@@ -47,6 +51,7 @@ export default {
canUseCommentLinking,
canUseLinkPreviews,
canUseViolations,
+ canUseTrackExpense,
canUseReportFields,
canUseP2PDistanceRequests,
canUseWorkflowsDelayedSubmission,
diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts
index 8f1438b1d092..cdcfb13eeb72 100644
--- a/src/libs/PolicyDistanceRatesUtils.ts
+++ b/src/libs/PolicyDistanceRatesUtils.ts
@@ -5,7 +5,7 @@ import getPermittedDecimalSeparator from './getPermittedDecimalSeparator';
import * as MoneyRequestUtils from './MoneyRequestUtils';
import * as NumberUtils from './NumberUtils';
-type RateValueForm = typeof ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM | typeof ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM;
+type RateValueForm = typeof ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM | typeof ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM | typeof ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM;
function validateRateValue(values: FormOnyxValues, currency: string, toLocaleDigit: (arg: string) => string): FormInputErrors {
const errors: FormInputErrors = {};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index 675e268045c1..39e6c8932aad 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -4,7 +4,8 @@ import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {PersonalDetailsList, Policy, PolicyCategories, PolicyMembers, PolicyTagList, PolicyTags} from '@src/types/onyx';
+import type {PersonalDetailsList, Policy, PolicyCategories, PolicyMembers, PolicyTagList, PolicyTags, TaxRate} from '@src/types/onyx';
+import type {PolicyFeatureName} from '@src/types/onyx/Policy';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import Navigation from './Navigation/Navigation';
@@ -34,7 +35,7 @@ function hasPolicyMemberError(policyMembers: OnyxEntry): boolean
* Check if the policy has any tax rate errors.
*/
function hasTaxRateError(policy: OnyxEntry): boolean {
- return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0);
+ return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean));
}
/**
@@ -276,6 +277,26 @@ function goBackFromInvalidPolicy() {
Navigation.navigate(ROUTES.SETTINGS_WORKSPACES);
}
+/** Get a tax with given ID from policy */
+function getTaxByID(policy: OnyxEntry, taxID: string): TaxRate | undefined {
+ return policy?.taxRates?.taxes?.[taxID];
+}
+
+/**
+ * Whether the tax rate can be deleted and disabled
+ */
+function canEditTaxRate(policy: Policy, taxID: string): boolean {
+ return policy.taxRates?.defaultExternalID !== taxID;
+}
+
+function isPolicyFeatureEnabled(policy: OnyxEntry | EmptyObject, featureName: PolicyFeatureName): boolean {
+ if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) {
+ return Boolean(policy?.tax?.trackingEnabled);
+ }
+
+ return Boolean(policy?.[featureName]);
+}
+
export {
getActivePolicies,
hasAccountingConnections,
@@ -296,6 +317,7 @@ export {
getIneligibleInvitees,
getTagLists,
getTagListName,
+ canEditTaxRate,
getTagList,
getCleanedTagName,
getCountOfEnabledTagsOfList,
@@ -306,7 +328,9 @@ export {
getPathWithoutPolicyID,
getPolicyMembersByIdWithoutCurrentUser,
goBackFromInvalidPolicy,
+ isPolicyFeatureEnabled,
hasTaxRateError,
+ getTaxByID,
hasPolicyCategoriesError,
};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index fbee7ffd7b11..05701c3e321f 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -51,6 +51,7 @@ type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMe
const policyChangeActionsSet = new Set(Object.values(CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG));
const allReports: OnyxCollection = {};
+
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
callback: (report, key) => {
@@ -103,9 +104,12 @@ function isCreatedAction(reportAction: OnyxEntry): boolean {
}
function isDeletedAction(reportAction: OnyxEntry): boolean {
- // A deleted comment has either an empty array or an object with html field with empty string as value
const message = reportAction?.message ?? [];
- return message.length === 0 || message[0]?.html === '';
+
+ // A legacy deleted comment has either an empty array or an object with html field with empty string as value
+ const isLegacyDeletedComment = message.length === 0 || message[0]?.html === '';
+
+ return isLegacyDeletedComment || !!message[0]?.deleted;
}
function isDeletedParentAction(reportAction: OnyxEntry): boolean {
@@ -209,6 +213,7 @@ function isTransactionThread(parentReportAction: OnyxEntry | Empty
return (
parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
(parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE ||
+ parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ||
(parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!parentReportAction.originalMessage.IOUDetails))
);
}
@@ -218,7 +223,7 @@ function isTransactionThread(parentReportAction: OnyxEntry | Empty
* This gives us a stable order even in the case of multiple reportActions created on the same millisecond
*
*/
-function getSortedReportActions(reportActions: ReportAction[] | null, shouldSortInDescendingOrder = false, shouldMarkTheFirstItemAsNewest = false): ReportAction[] {
+function getSortedReportActions(reportActions: ReportAction[] | null, shouldSortInDescendingOrder = false): ReportAction[] {
if (!Array.isArray(reportActions)) {
throw new Error(`ReportActionsUtils.getSortedReportActions requires an array, received ${typeof reportActions}`);
}
@@ -246,15 +251,58 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort
return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier;
});
- // If shouldMarkTheFirstItemAsNewest is true, label the first reportAction as isNewestReportAction
- if (shouldMarkTheFirstItemAsNewest && sortedActions?.length > 0) {
- sortedActions[0] = {
- ...sortedActions[0],
- isNewestReportAction: true,
- };
+ return sortedActions;
+}
+
+/**
+ * Returns the largest gapless range of reportActions including a the provided reportActionID, where a "gap" is defined as a reportAction's `previousReportActionID` not matching the previous reportAction in the sortedReportActions array.
+ * See unit tests for example of inputs and expected outputs.
+ * Note: sortedReportActions sorted in descending order
+ */
+function getContinuousReportActionChain(sortedReportActions: ReportAction[], id?: string): ReportAction[] {
+ let index;
+
+ if (id) {
+ index = sortedReportActions.findIndex((obj) => obj.reportActionID === id);
+ } else {
+ index = sortedReportActions.findIndex((obj) => obj.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
}
- return sortedActions;
+ if (index === -1) {
+ return [];
+ }
+
+ let startIndex = index;
+ let endIndex = index;
+
+ // Iterate forwards through the array, starting from endIndex. This loop checks the continuity of actions by:
+ // 1. Comparing the current item's previousReportActionID with the next item's reportActionID.
+ // This ensures that we are moving in a sequence of related actions from newer to older.
+ while (
+ (endIndex < sortedReportActions.length - 1 && sortedReportActions[endIndex].previousReportActionID === sortedReportActions[endIndex + 1].reportActionID) ||
+ !!sortedReportActions[endIndex + 1]?.whisperedToAccountIDs?.length ||
+ !!sortedReportActions[endIndex]?.whisperedToAccountIDs?.length ||
+ sortedReportActions[endIndex]?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM ||
+ sortedReportActions[endIndex + 1]?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED ||
+ sortedReportActions[endIndex + 1]?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED
+ ) {
+ endIndex++;
+ }
+
+ // Iterate backwards through the sortedReportActions, starting from startIndex. This loop has two main checks:
+ // 1. It compares the current item's reportActionID with the previous item's previousReportActionID.
+ // This is to ensure continuity in a sequence of actions.
+ // 2. If the first condition fails, it then checks if the previous item has a pendingAction of 'add'.
+ // This additional check is to include recently sent messages that might not yet be part of the established sequence.
+ while (
+ (startIndex > 0 && sortedReportActions[startIndex].reportActionID === sortedReportActions[startIndex - 1].previousReportActionID) ||
+ sortedReportActions[startIndex - 1]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ||
+ sortedReportActions[startIndex - 1]?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM
+ ) {
+ startIndex--;
+ }
+
+ return sortedReportActions.slice(startIndex, endIndex + 1);
}
/**
@@ -524,12 +572,22 @@ function filterOutDeprecatedReportActions(reportActions: ReportActions | null):
* to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp).
* This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY.
*/
-function getSortedReportActionsForDisplay(reportActions: ReportActions | ReportAction[] | null, shouldMarkTheFirstItemAsNewest = false): ReportAction[] {
- const filteredReportActions = Object.entries(reportActions ?? {})
- .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key))
- .map((entry) => entry[1]);
+function getSortedReportActionsForDisplay(reportActions: ReportActions | null | ReportAction[], shouldIncludeInvisibleActions = false): ReportAction[] {
+ let filteredReportActions: ReportAction[] = [];
+ if (!reportActions) {
+ return [];
+ }
+
+ if (shouldIncludeInvisibleActions) {
+ filteredReportActions = Object.values(reportActions);
+ } else {
+ filteredReportActions = Object.entries(reportActions)
+ .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key))
+ .map(([, reportAction]) => reportAction);
+ }
+
const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURLInPolicyChangeLogAction(reportAction));
- return getSortedReportActions(baseURLAdjustedReportActions, true, shouldMarkTheFirstItemAsNewest);
+ return getSortedReportActions(baseURLAdjustedReportActions, true);
}
/**
@@ -649,7 +707,7 @@ function getReportPreviewAction(chatReportID: string, iouReportID: string): Onyx
* Get the iouReportID for a given report action.
*/
function getIOUReportIDFromReportActionPreview(reportAction: OnyxEntry): string {
- return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? reportAction.originalMessage.linkedReportID : '';
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? reportAction.originalMessage.linkedReportID : '0';
}
function isCreatedTaskReportAction(reportAction: OnyxEntry): boolean {
@@ -674,6 +732,10 @@ function isSplitBillAction(reportAction: OnyxEntry): boolean {
return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
}
+function isTrackExpenseAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && (reportAction.originalMessage as IOUMessage).type === CONST.IOU.REPORT_ACTION_TYPE.TRACK;
+}
+
function isTaskAction(reportAction: OnyxEntry): boolean {
const reportActionName = reportAction?.actionName;
return (
@@ -996,6 +1058,7 @@ export {
isReportPreviewAction,
isSentMoneyReportAction,
isSplitBillAction,
+ isTrackExpenseAction,
isTaskAction,
doesReportHaveVisibleActions,
isThreadParentMessage,
@@ -1006,6 +1069,7 @@ export {
shouldReportActionBeVisible,
shouldHideNewMarker,
shouldReportActionBeVisibleAsLastAction,
+ getContinuousReportActionChain,
hasRequestFromCurrentAccount,
getFirstVisibleReportActionID,
isMemberChangeAction,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 826d58ee6ecc..d0ce53a0e10b 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -23,7 +23,6 @@ import type {
PersonalDetailsList,
Policy,
PolicyReportField,
- PolicyReportFields,
Report,
ReportAction,
ReportMetadata,
@@ -482,14 +481,6 @@ Onyx.connect({
callback: (value) => (allPolicies = value),
});
-let allPolicyReportFields: OnyxCollection = {};
-
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS,
- waitForCollectionCallback: true,
- callback: (value) => (allPolicyReportFields = value),
-});
-
let allBetas: OnyxEntry;
Onyx.connect({
key: ONYXKEYS.BETAS,
@@ -938,6 +929,15 @@ function isConciergeChatReport(report: OnyxEntry): boolean {
return report?.participantAccountIDs?.length === 1 && Number(report.participantAccountIDs?.[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isChatThread(report);
}
+function findSelfDMReportID(): string | undefined {
+ if (!allReports) {
+ return;
+ }
+
+ const selfDMReport = Object.values(allReports).find((report) => isSelfDM(report) && !isThread(report));
+ return selfDMReport?.reportID;
+}
+
/**
* Checks if the supplied report belongs to workspace based on the provided params. If the report's policyID is _FAKE_ or has no value, it means this report is a DM.
* In this case report and workspace members must be compared to determine whether the report belongs to the workspace.
@@ -1227,6 +1227,18 @@ function isIOURequest(report: OnyxEntry): boolean {
return false;
}
+/**
+ * A Track Expense Report is a thread where the parent the parentReportAction is a transaction, and
+ * parentReportAction has type of track.
+ */
+function isTrackExpenseReport(report: OnyxEntry): boolean {
+ if (isThread(report)) {
+ const parentReportAction = ReportActionsUtils.getParentReportAction(report);
+ return !isEmptyObject(parentReportAction) && ReportActionsUtils.isTrackExpenseAction(parentReportAction);
+ }
+ return false;
+}
+
/**
* Checks if a report is an IOU or expense request.
*/
@@ -2078,22 +2090,36 @@ function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry
/**
* Given a set of report fields, return the field of type formula
*/
-function getFormulaTypeReportField(reportFields: PolicyReportFields) {
- return Object.values(reportFields).find((field) => field.type === 'formula');
+function getFormulaTypeReportField(reportFields: Record) {
+ return Object.values(reportFields).find((field) => field?.type === 'formula');
}
/**
* Given a set of report fields, return the field that refers to title
*/
-function getTitleReportField(reportFields: PolicyReportFields) {
+function getTitleReportField(reportFields: Record) {
return Object.values(reportFields).find((field) => isReportFieldOfTypeTitle(field));
}
+/**
+ * Get the key for a report field
+ */
+function getReportFieldKey(reportFieldId: string) {
+ return `expensify_${reportFieldId}`;
+}
+
/**
* Get the report fields attached to the policy given policyID
*/
-function getReportFieldsByPolicyID(policyID: string) {
- return Object.entries(allPolicyReportFields ?? {}).find(([key]) => key.replace(ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS, '') === policyID)?.[1];
+function getReportFieldsByPolicyID(policyID: string): Record {
+ const policyReportFields = Object.entries(allPolicies ?? {}).find(([key]) => key.replace(ONYXKEYS.COLLECTION.POLICY, '') === policyID);
+ const fieldList = policyReportFields?.[1]?.fieldList;
+
+ if (!policyReportFields || !fieldList) {
+ return {};
+ }
+
+ return fieldList;
}
/**
@@ -2102,7 +2128,7 @@ function getReportFieldsByPolicyID(policyID: string) {
function getAvailableReportFields(report: Report, policyReportFields: PolicyReportField[]): PolicyReportField[] {
// Get the report fields that are attached to a report. These will persist even if a field is deleted from the policy.
- const reportFields = Object.values(report.reportFields ?? {});
+ const reportFields = Object.values(report.fieldList ?? {});
const reportIsSettled = isSettled(report.reportID);
// If the report is settled, we don't want to show any new field that gets added to the policy.
@@ -2113,7 +2139,24 @@ function getAvailableReportFields(report: Report, policyReportFields: PolicyRepo
// If the report is unsettled, we want to merge the new fields that get added to the policy with the fields that
// are attached to the report.
const mergedFieldIds = Array.from(new Set([...policyReportFields.map(({fieldID}) => fieldID), ...reportFields.map(({fieldID}) => fieldID)]));
- return mergedFieldIds.map((id) => report?.reportFields?.[id] ?? policyReportFields.find(({fieldID}) => fieldID === id)) as PolicyReportField[];
+
+ const fields = mergedFieldIds.map((id) => {
+ const field = report?.fieldList?.[getReportFieldKey(id)];
+
+ if (field) {
+ return field;
+ }
+
+ const policyReportField = policyReportFields.find(({fieldID}) => fieldID === id);
+
+ if (policyReportField) {
+ return policyReportField;
+ }
+
+ return null;
+ });
+
+ return fields.filter(Boolean) as PolicyReportField[];
}
/**
@@ -2121,7 +2164,7 @@ function getAvailableReportFields(report: Report, policyReportFields: PolicyRepo
*/
function getMoneyRequestReportName(report: OnyxEntry, policy: OnyxEntry | undefined = undefined): string {
const isReportSettled = isSettled(report?.reportID ?? '');
- const reportFields = isReportSettled ? report?.reportFields : getReportFieldsByPolicyID(report?.policyID ?? '');
+ const reportFields = isReportSettled ? report?.fieldList : getReportFieldsByPolicyID(report?.policyID ?? '');
const titleReportField = getFormulaTypeReportField(reportFields ?? {});
if (titleReportField && report?.reportName && reportFieldsEnabled(report)) {
@@ -2211,6 +2254,11 @@ function canEditMoneyRequest(reportAction: OnyxEntry): boolean {
return true;
}
+ // TODO: Uncomment this line when BE starts working properly (Editing Track Expense)
+ // if (reportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) {
+ // return true;
+ // }
+
if (reportAction.originalMessage.type !== CONST.IOU.REPORT_ACTION_TYPE.CREATE) {
return false;
}
@@ -2382,10 +2430,25 @@ function getTransactionReportName(reportAction: OnyxEntry, policies: OnyxCollection, transact
return TransactionUtils.hasViolation(IOUTransactionID, transactionViolations);
}
+/**
+ * Checks if we should display violation - we display violations when the money request has violation and it is not settled
+ */
+function shouldDisplayTransactionThreadViolations(
+ report: OnyxEntry,
+ transactionViolations: OnyxCollection,
+ parentReportAction: OnyxEntry,
+): boolean {
+ const {IOUReportID} = (parentReportAction?.originalMessage as IOUMessage) ?? {};
+ if (isSettled(IOUReportID)) {
+ return false;
+ }
+ return doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction);
+}
+
/**
* Checks to see if a report contains a violation
*/
@@ -4549,7 +4683,7 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o
/**
* Helper method to define what money request options we want to show for particular method.
- * There are 3 money request options: Request, Split and Send:
+ * There are 4 money request options: Request, Split, Send and Track expense:
* - Request option should show for:
* - DMs
* - own policy expense chats
@@ -4561,13 +4695,16 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o
* - chat/ policy rooms with more than 1 participants
* - groups chats with 3 and more participants
* - corporate workspace chats
+ * - Track expense option should show for:
+ * - Self DMs
+ * - admin rooms
*
* None of the options should show in chat threads or if there is some special Expensify account
* as a participant of the report.
*/
-function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, reportParticipants: number[]): Array> {
+function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, reportParticipants: number[], canUseTrackExpense = true): Array> {
// In any thread or task report, we do not allow any new money requests yet
- if (isChatThread(report) || isTaskReport(report) || isSelfDM(report)) {
+ if (isChatThread(report) || isTaskReport(report) || (!canUseTrackExpense && isSelfDM(report))) {
return [];
}
@@ -4583,6 +4720,10 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry 1;
let options: Array> = [];
+ if (isSelfDM(report)) {
+ options = [CONST.IOU.TYPE.TRACK_EXPENSE];
+ }
+
// User created policy rooms and default rooms like #admins or #announce will always have the Split Bill option
// unless there are no other participants at all (e.g. #admins room for a policy with only 1 admin)
// DM chats will have the Split Bill option only when there are at least 2 other people in the chat.
@@ -4591,6 +4732,11 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry)
amount: formattedAmount,
});
}
- translationKey = ReportActionsUtils.isSplitBillAction(reportAction) ? 'iou.didSplitAmount' : 'iou.requestedAmount';
+ if (ReportActionsUtils.isSplitBillAction(reportAction)) {
+ translationKey = 'iou.didSplitAmount';
+ } else if (ReportActionsUtils.isTrackExpenseAction(reportAction)) {
+ translationKey = 'iou.trackedAmount';
+ } else {
+ translationKey = 'iou.requestedAmount';
+ }
return Localize.translateLocal(translationKey, {
formattedAmount,
comment: transactionDetails?.comment ?? '',
@@ -5296,6 +5448,22 @@ function canBeAutoReimbursed(report: OnyxEntry, policy: OnyxEntry, approverAccountID?: number): boolean {
+ const policy = getPolicy(report?.policyID);
+ const {preventSelfApproval} = policy;
+
+ const isOwner = (approverAccountID ?? currentUserAccountID) === report?.ownerAccountID;
+
+ return !(preventSelfApproval && isOwner);
+}
+
+function isAllowedToSubmitDraftExpenseReport(report: OnyxEntry): boolean {
+ const policy = getPolicy(report?.policyID);
+ const {submitsTo} = policy;
+
+ return isAllowedToApproveExpenseReport(report, submitsTo);
+}
+
/**
* What missing payment method does this report action indicate, if any?
*/
@@ -5331,6 +5499,14 @@ function shouldCreateNewMoneyRequestReport(existingIOUReport: OnyxEntry
return !existingIOUReport || hasIOUWaitingOnCurrentUserBankAccount(chatReport) || !canAddOrDeleteTransactions(existingIOUReport);
}
+/**
+ * Checks if report contains actions with errors
+ */
+function hasActionsWithErrors(reportID: string): boolean {
+ const reportActions = ReportActionsUtils.getAllReportActions(reportID ?? '');
+ return Object.values(reportActions ?? {}).some((action) => !isEmptyObject(action.errors));
+}
+
export {
getReportParticipantsTitle,
isReportMessageAttachment,
@@ -5508,6 +5684,7 @@ export {
getRoom,
canEditReportDescription,
doesTransactionThreadHaveViolations,
+ shouldDisplayTransactionThreadViolations,
hasViolations,
navigateToPrivateNotes,
canEditWriteCapability,
@@ -5515,6 +5692,7 @@ export {
hasSmartscanError,
shouldAutoFocusOnKeyPress,
buildOptimisticHoldReportAction,
+ buildOptimisticHoldReportActionComment,
buildOptimisticUnHoldReportAction,
shouldDisplayThreadReplies,
shouldDisableThread,
@@ -5531,16 +5709,22 @@ export {
hasUpdatedTotal,
isReportFieldDisabled,
getAvailableReportFields,
+ getReportFieldKey,
reportFieldsEnabled,
getAllAncestorReportActionIDs,
getPendingChatMembers,
canEditRoomVisibility,
canEditPolicyDescription,
getPolicyDescriptionText,
+ isAllowedToSubmitDraftExpenseReport,
+ isAllowedToApproveExpenseReport,
+ findSelfDMReportID,
getIndicatedMissingPaymentMethod,
isJoinRequestInAdminRoom,
canAddOrDeleteTransactions,
shouldCreateNewMoneyRequestReport,
+ isTrackExpenseReport,
+ hasActionsWithErrors,
};
export type {
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 4b4d71cd5cdc..63b907a42e25 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -77,11 +77,25 @@ function getOrderedReportIDs(
// Filter out all the reports that shouldn't be displayed
let reportsToDisplay = allReportsDictValues.filter((report) => {
- const parentReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`;
+ if (!report) {
+ return false;
+ }
+
+ const parentReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`;
const parentReportActions = allReportActions?.[parentReportActionsKey];
- const parentReportAction = parentReportActions?.find((action) => action && report && action?.reportActionID === report?.parentReportActionID);
+ const reportActions = ReportActionsUtils.getAllReportActions(report.reportID);
+ const parentReportAction = parentReportActions?.find((action) => action && action?.reportActionID === report.parentReportActionID);
const doesReportHaveViolations =
betas.includes(CONST.BETAS.VIOLATIONS) && !!parentReportAction && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction);
+ const isHidden = report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
+ const isFocused = report.reportID === currentReportId;
+ const hasErrors = Object.keys(OptionsListUtils.getAllReportErrors(report, reportActions) ?? {}).length !== 0;
+ const hasBrickError = hasErrors || doesReportHaveViolations ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
+ const shouldOverrideHidden = hasBrickError || isFocused || report.isPinned;
+ if (isHidden && !shouldOverrideHidden) {
+ return false;
+ }
+
return ReportUtils.shouldReportBeInOptionList({
report,
currentReportId: currentReportId ?? '',
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 5876ccf5d7d7..cacab8333868 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -471,8 +471,9 @@ function isValidPercentage(value: string): boolean {
/**
* Validates the given value if it is correct tax name.
*/
-function isExistingTaxName(value: string, taxRates: TaxRates): boolean {
- return !!Object.values(taxRates).find((taxRate) => taxRate.name === value);
+function isExistingTaxName(taxName: string, taxRates: TaxRates): boolean {
+ const trimmedTaxName = taxName.trim();
+ return !!Object.values(taxRates).find((taxRate) => taxRate.name === trimmedTaxName);
}
export {
diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts
index 23cb53a317b0..f808f602a1c6 100644
--- a/src/libs/WorkspacesSettingsUtils.ts
+++ b/src/libs/WorkspacesSettingsUtils.ts
@@ -92,8 +92,9 @@ function hasGlobalWorkspaceSettingsRBR(policies: OnyxCollection, policyM
function hasWorkspaceSettingsRBR(policy: Policy) {
const policyMemberError = allPolicyMembers ? hasPolicyMemberError(allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policy.id}`]) : false;
+ const taxRateError = hasTaxRateError(policy);
- return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError;
+ return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError || taxRateError;
}
function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined {
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 91a74c593926..857f6f39173a 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -22,6 +22,7 @@ import type {
SplitBillParams,
StartSplitBillParams,
SubmitReportParams,
+ TrackExpenseParams,
UpdateMoneyRequestParams,
} from '@libs/API/parameters';
import {WRITE_COMMANDS} from '@libs/API/types';
@@ -35,7 +36,6 @@ import * as LocalePhoneNumber from '@libs/LocalePhoneNumber';
import * as Localize from '@libs/Localize';
import Navigation from '@libs/Navigation/Navigation';
import * as NextStepUtils from '@libs/NextStepUtils';
-import * as NumberUtils from '@libs/NumberUtils';
import Permissions from '@libs/Permissions';
import * as PhoneNumber from '@libs/PhoneNumber';
import * as PolicyUtils from '@libs/PolicyUtils';
@@ -84,6 +84,19 @@ type MoneyRequestInformation = {
onyxData: OnyxData;
};
+type TrackExpenseInformation = {
+ iouReport?: OnyxTypes.Report;
+ chatReport: OnyxTypes.Report;
+ transaction: OnyxTypes.Transaction;
+ iouAction: OptimisticIOUReportAction;
+ createdChatReportActionID: string;
+ createdIOUReportActionID?: string;
+ reportPreviewAction?: OnyxTypes.ReportAction;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
+ onyxData: OnyxData;
+};
+
type SplitData = {
chatReportID: string;
transactionID: string;
@@ -312,7 +325,7 @@ function updateMoneyRequestTypeParams(routes: StackNavigationState, reportID: string) {
clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
- Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID));
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID));
}
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -617,6 +630,7 @@ function buildOnyxDataForMoneyRequest(
value: {
pendingFields: null,
errorFields: null,
+ isOptimisticReport: false,
},
});
}
@@ -740,31 +754,6 @@ function buildOnyxDataForMoneyRequest(
pendingFields: clearedPendingFields,
},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
- value: {
- ...(isNewChatReport
- ? {
- [chatCreatedAction.reportActionID]: {
- // Disabling this line since transaction.filename can be an empty string
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- errors: getReceiptError(transaction?.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest),
- },
- [reportPreviewAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError(null),
- },
- }
- : {
- [reportPreviewAction.reportActionID]: {
- created: reportPreviewAction.created,
- // Disabling this line since transaction.filename can be an empty string
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- errors: getReceiptError(transaction?.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest),
- },
- }),
- },
- },
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
@@ -819,6 +808,178 @@ function buildOnyxDataForMoneyRequest(
return [optimisticData, successData, failureData];
}
+/** Builds the Onyx data for track expense */
+function buildOnyxDataForTrackExpense(
+ chatReport: OnyxEntry,
+ transaction: OnyxTypes.Transaction,
+ iouAction: OptimisticIOUReportAction,
+ transactionThreadReport: OptimisticChatReport,
+ transactionThreadCreatedReportAction: OptimisticCreatedReportAction,
+ policy?: OnyxEntry,
+ policyTagList?: OnyxEntry,
+ policyCategories?: OnyxEntry,
+): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] {
+ const isScanRequest = TransactionUtils.isScanRequest(transaction);
+ const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null]));
+ const optimisticData: OnyxUpdate[] = [];
+
+ if (chatReport) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`,
+ value: {
+ ...chatReport,
+ lastMessageText: iouAction.message?.[0].text,
+ lastMessageHtml: iouAction.message?.[0].html,
+ lastReadTime: DateUtils.getDBTime(),
+ },
+ });
+ }
+
+ optimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
+ value: transaction,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
+ value: {
+ [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`,
+ value: transactionThreadReport,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`,
+ value: {
+ [transactionThreadCreatedReportAction.reportActionID]: transactionThreadCreatedReportAction,
+ },
+ },
+
+ // Remove the temporary transaction used during the creation flow
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`,
+ value: null,
+ },
+ );
+
+ const successData: OnyxUpdate[] = [];
+
+ successData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`,
+ value: {
+ pendingFields: null,
+ errorFields: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
+ value: {
+ pendingAction: null,
+ pendingFields: clearedPendingFields,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
+ value: {
+ [iouAction.reportActionID]: {
+ pendingAction: null,
+ errors: null,
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`,
+ value: {
+ [transactionThreadCreatedReportAction.reportActionID]: {
+ pendingAction: null,
+ errors: null,
+ },
+ },
+ },
+ );
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`,
+ value: {
+ lastReadTime: chatReport?.lastReadTime,
+ lastMessageText: chatReport?.lastMessageText,
+ lastMessageHtml: chatReport?.lastMessageHtml,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`,
+ value: {
+ errorFields: {
+ createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'),
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
+ value: {
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ pendingAction: null,
+ pendingFields: clearedPendingFields,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
+ value: {
+ [iouAction.reportActionID]: {
+ // Disabling this line since transaction.filename can be an empty string
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, isScanRequest),
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`,
+ value: {
+ [transactionThreadCreatedReportAction.reportActionID]: {
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ },
+ },
+ },
+ ];
+
+ // We don't need to compute violations unless we're on a paid policy
+ if (!policy || !PolicyUtils.isPaidGroupPolicy(policy)) {
+ return [optimisticData, successData, failureData];
+ }
+
+ const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {});
+
+ if (violationsOnyxData) {
+ optimisticData.push(violationsOnyxData);
+ failureData.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`,
+ value: [],
+ });
+ }
+
+ return [optimisticData, successData, failureData];
+}
+
/**
* Gathers all the data needed to make a money request. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then
* it creates optimistic versions of them and uses those instead
@@ -1027,6 +1188,137 @@ function getMoneyRequestInformation(
};
}
+/**
+ * Gathers all the data needed to make an expense. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then
+ * it creates optimistic versions of them and uses those instead
+ */
+function getTrackExpenseInformation(
+ parentChatReport: OnyxEntry | EmptyObject,
+ participant: Participant,
+ comment: string,
+ amount: number,
+ currency: string,
+ created: string,
+ merchant: string,
+ receipt: Receipt | undefined,
+ category: string | undefined,
+ tag: string | undefined,
+ billable: boolean | undefined,
+ policy: OnyxEntry | undefined,
+ policyTagList: OnyxEntry | undefined,
+ policyCategories: OnyxEntry | undefined,
+ payeeEmail = currentUserEmail,
+): TrackExpenseInformation | EmptyObject {
+ // STEP 1: Get existing chat report
+ let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null;
+
+ // The chatReport always exist and we can get it from Onyx if chatReport is null.
+ if (!chatReport) {
+ chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.reportID}`] ?? null;
+ }
+
+ // If we still don't have a report, it likely doens't exist and we will early return here as it should not happen
+ // Maybe later, we can build an optimistic selfDM chat.
+ if (!chatReport) {
+ return {};
+ }
+
+ // STEP 2: Get the money request report.
+ // TODO: This is deferred to later as we are not sure if we create iouReport at all in future.
+ // We can build an optimistic iouReport here if needed.
+
+ // STEP 3: Build optimistic receipt and transaction
+ const receiptObject: Receipt = {};
+ let filename;
+ if (receipt?.source) {
+ receiptObject.source = receipt.source;
+ receiptObject.state = receipt.state ?? CONST.IOU.RECEIPT_STATE.SCANREADY;
+ filename = receipt.name;
+ }
+ const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`];
+ const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE;
+ let optimisticTransaction = TransactionUtils.buildOptimisticTransaction(
+ amount,
+ currency,
+ chatReport.reportID,
+ comment,
+ created,
+ '',
+ '',
+ merchant,
+ receiptObject,
+ filename,
+ null,
+ category,
+ tag,
+ billable,
+ isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined,
+ );
+
+ // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction
+ // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction
+ // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109.
+ // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417
+ // to remind me to do this.
+ if (isDistanceRequest) {
+ optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false);
+ }
+
+ // STEP 4: Build optimistic reportActions. We need:
+ // 1. IOU action for the chatReport
+ // 2. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread
+ const currentTime = DateUtils.getDBTime();
+ const iouAction = ReportUtils.buildOptimisticIOUReportAction(
+ CONST.IOU.REPORT_ACTION_TYPE.TRACK,
+ amount,
+ currency,
+ comment,
+ [participant],
+ optimisticTransaction.transactionID,
+ undefined,
+ '0',
+ false,
+ false,
+ receiptObject,
+ false,
+ currentTime,
+ );
+ const optimisticTransactionThread = ReportUtils.buildTransactionThread(iouAction, chatReport);
+ const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail);
+
+ // The IOU action and the transactionThread are co-dependent as parent-child, so we need to link them together
+ iouAction.childReportID = optimisticTransactionThread.reportID;
+
+ // STEP 5: Build Onyx Data
+ const [optimisticData, successData, failureData] = buildOnyxDataForTrackExpense(
+ chatReport,
+ optimisticTransaction,
+ iouAction,
+ optimisticTransactionThread,
+ optimisticCreatedActionForTransactionThread,
+ policy,
+ policyTagList,
+ policyCategories,
+ );
+
+ return {
+ chatReport,
+ iouReport: undefined,
+ transaction: optimisticTransaction,
+ iouAction,
+ createdChatReportActionID: '0',
+ createdIOUReportActionID: undefined,
+ reportPreviewAction: undefined,
+ transactionThreadReportID: optimisticTransactionThread.reportID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
+ onyxData: {
+ optimisticData,
+ successData,
+ failureData,
+ },
+ };
+}
+
/** Requests money based on a distance (eg. mileage from a map) */
function createDistanceRequest(
report: OnyxTypes.Report,
@@ -1424,6 +1716,185 @@ function getUpdateMoneyRequestParams(
};
}
+/**
+ * @param transactionID
+ * @param transactionThreadReportID
+ * @param transactionChanges
+ * @param [transactionChanges.created] Present when updated the date field
+ * @param onlyIncludeChangedFields
+ * When 'true', then the returned params will only include the transaction details for the fields that were changed.
+ * When `false`, then the returned params will include all the transaction details, regardless of which fields were changed.
+ * This setting is necessary while the UpdateDistanceRequest API is refactored to be fully 1:1:1 in https://github.com/Expensify/App/issues/28358
+ */
+function getUpdateTrackExpenseParams(
+ transactionID: string,
+ transactionThreadReportID: string,
+ transactionChanges: TransactionChanges,
+ onlyIncludeChangedFields: boolean,
+): UpdateMoneyRequestData {
+ const optimisticData: OnyxUpdate[] = [];
+ const successData: OnyxUpdate[] = [];
+ const failureData: OnyxUpdate[] = [];
+
+ // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData
+ const pendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE]));
+ const clearedPendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, null]));
+ const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}]));
+
+ // Step 2: Get all the collections being updated
+ const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null;
+ const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
+ const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null;
+ const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction);
+ let updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, false) : null;
+ const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction);
+
+ if (transactionDetails?.waypoints) {
+ // This needs to be a JSON string since we're sending this to the MapBox API
+ transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints);
+ }
+
+ const dataToIncludeInParams: Partial | undefined = onlyIncludeChangedFields
+ ? Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => Object.keys(transactionChanges).includes(key)))
+ : transactionDetails;
+
+ const params: UpdateMoneyRequestParams = {
+ ...dataToIncludeInParams,
+ reportID: chatReport?.reportID,
+ transactionID,
+ };
+
+ const hasPendingWaypoints = 'waypoints' in transactionChanges;
+ if (transaction && updatedTransaction && hasPendingWaypoints) {
+ updatedTransaction = {
+ ...updatedTransaction,
+ amount: CONST.IOU.DEFAULT_AMOUNT,
+ modifiedAmount: CONST.IOU.DEFAULT_AMOUNT,
+ modifiedMerchant: Localize.translateLocal('iou.routePending'),
+ };
+
+ // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors
+ successData.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`,
+ value: null,
+ });
+
+ // Revert the transaction's amount to the original value on failure.
+ // The IOU Report will be fully reverted in the failureData further below.
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ amount: transaction.amount,
+ modifiedAmount: transaction.modifiedAmount,
+ modifiedMerchant: transaction.modifiedMerchant,
+ },
+ });
+ }
+
+ // Step 3: Build the modified expense report actions
+ // We don't create a modified report action if we're updating the waypoints,
+ // since there isn't actually any optimistic data we can create for them and the report action is created on the server
+ // with the response from the MapBox API
+ const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, false);
+ if (!hasPendingWaypoints) {
+ params.reportActionID = updatedReportAction.reportActionID;
+
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`,
+ value: {
+ [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction,
+ },
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`,
+ value: {
+ [updatedReportAction.reportActionID]: {pendingAction: null},
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`,
+ value: {
+ [updatedReportAction.reportActionID]: {
+ ...(updatedReportAction as OnyxTypes.ReportAction),
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericEditFailureMessage'),
+ },
+ },
+ });
+ }
+
+ // Step 4: Update the report preview message (and report header) so LHN amount tracked is correct.
+ // Optimistically modify the transaction and the transaction thread
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ ...updatedTransaction,
+ pendingFields,
+ isLoading: hasPendingWaypoints,
+ errorFields: null,
+ },
+ });
+
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`,
+ value: {
+ lastActorAccountID: updatedReportAction.actorAccountID,
+ },
+ });
+
+ if (isScanning && ('amount' in transactionChanges || 'currency' in transactionChanges)) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
+ value: {
+ [transactionThread?.parentReportActionID ?? '']: {
+ whisperedToAccountIDs: [],
+ },
+ },
+ });
+ }
+
+ // Clear out the error fields and loading states on success
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ pendingFields: clearedPendingFields,
+ isLoading: false,
+ errorFields: null,
+ },
+ });
+
+ // Clear out loading states, pending fields, and add the error fields
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ pendingFields: clearedPendingFields,
+ isLoading: false,
+ errorFields,
+ },
+ });
+
+ // Reset the transaction thread to its original state
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`,
+ value: transactionThread,
+ });
+
+ return {
+ params,
+ onyxData: {optimisticData, successData, failureData},
+ };
+}
+
/** Updates the created date of a money request */
function updateMoneyRequestDate(
transactionID: string,
@@ -1436,7 +1907,14 @@ function updateMoneyRequestDate(
const transactionChanges: TransactionChanges = {
created: value,
};
- const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true);
+ const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null;
+ let data: UpdateMoneyRequestData;
+ if (ReportUtils.isTrackExpenseReport(transactionThreadReport)) {
+ data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true);
+ } else {
+ data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true);
+ }
+ const {params, onyxData} = data;
API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData);
}
@@ -1468,7 +1946,14 @@ function updateMoneyRequestMerchant(
const transactionChanges: TransactionChanges = {
merchant: value,
};
- const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true);
+ const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null;
+ let data: UpdateMoneyRequestData;
+ if (ReportUtils.isTrackExpenseReport(transactionThreadReport)) {
+ data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true);
+ } else {
+ data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true);
+ }
+ const {params, onyxData} = data;
API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, params, onyxData);
}
@@ -1500,7 +1985,14 @@ function updateMoneyRequestDistance(
const transactionChanges: TransactionChanges = {
waypoints,
};
- const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true);
+ const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null;
+ let data: UpdateMoneyRequestData;
+ if (ReportUtils.isTrackExpenseReport(transactionThreadReport)) {
+ data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true);
+ } else {
+ data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true);
+ }
+ const {params, onyxData} = data;
API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE, params, onyxData);
}
@@ -1532,7 +2024,14 @@ function updateMoneyRequestDescription(
const transactionChanges: TransactionChanges = {
comment,
};
- const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true);
+ const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null;
+ let data: UpdateMoneyRequestData;
+ if (ReportUtils.isTrackExpenseReport(transactionThreadReport)) {
+ data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true);
+ } else {
+ data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true);
+ }
+ const {params, onyxData} = data;
API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData);
}
@@ -1647,6 +2146,93 @@ function requestMoney(
Report.notifyNewAction(activeReportID, payeeAccountID);
}
+/**
+ * Track an expense
+ */
+function trackExpense(
+ report: OnyxTypes.Report,
+ amount: number,
+ currency: string,
+ created: string,
+ merchant: string,
+ payeeEmail: string,
+ payeeAccountID: number,
+ participant: Participant,
+ comment: string,
+ receipt: Receipt,
+ category?: string,
+ tag?: string,
+ taxCode = '',
+ taxAmount = 0,
+ billable?: boolean,
+ policy?: OnyxEntry,
+ policyTagList?: OnyxEntry,
+ policyCategories?: OnyxEntry,
+ gpsPoints = undefined,
+) {
+ const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created);
+ const {
+ iouReport,
+ chatReport,
+ transaction,
+ iouAction,
+ createdChatReportActionID,
+ createdIOUReportActionID,
+ reportPreviewAction,
+ transactionThreadReportID,
+ createdReportActionIDForThread,
+ onyxData,
+ } = getTrackExpenseInformation(
+ report,
+ participant,
+ comment,
+ amount,
+ currency,
+ currentCreated,
+ merchant,
+ receipt,
+ category,
+ tag,
+ billable,
+ policy,
+ policyTagList,
+ policyCategories,
+ payeeEmail,
+ );
+ const activeReportID = report.reportID;
+
+ const parameters: TrackExpenseParams = {
+ amount,
+ currency,
+ comment,
+ created: currentCreated,
+ merchant,
+ iouReportID: iouReport?.reportID,
+ chatReportID: chatReport.reportID,
+ transactionID: transaction.transactionID,
+ reportActionID: iouAction.reportActionID,
+ createdChatReportActionID,
+ createdIOUReportActionID,
+ reportPreviewReportActionID: reportPreviewAction?.reportActionID,
+ receipt,
+ receiptState: receipt?.state,
+ category,
+ tag,
+ taxCode,
+ taxAmount,
+ billable,
+ // This needs to be a string of JSON because of limitations with the fetch() API and nested objects
+ gpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined,
+ transactionThreadReportID,
+ createdReportActionIDForThread,
+ };
+
+ API.write(WRITE_COMMANDS.TRACK_EXPENSE, parameters, onyxData);
+ resetMoneyRequestInfo();
+ Navigation.dismissModal(activeReportID);
+ Report.notifyNewAction(activeReportID, payeeAccountID);
+}
+
/**
* Build the Onyx data and IOU split necessary for splitting a bill with 3+ users.
* 1. Build the optimistic Onyx data for the group chat, i.e. chatReport and iouReportAction creating the former if it doesn't yet exist.
@@ -3040,7 +3626,7 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor
if (updatedReportPreviewAction?.message?.[0]) {
updatedReportPreviewAction.message[0].text = messageText;
- updatedReportPreviewAction.message[0].html = shouldDeleteIOUReport ? '' : messageText;
+ updatedReportPreviewAction.message[0].deleted = shouldDeleteIOUReport ? DateUtils.getDBTime() : '';
}
if (updatedReportPreviewAction && reportPreviewAction?.childMoneyRequestCount && reportPreviewAction?.childMoneyRequestCount > 0) {
@@ -3145,12 +3731,10 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
value: {
- [reportPreviewAction?.reportActionID ?? '']: shouldDeleteIOUReport
- ? null
- : {
- pendingAction: null,
- errors: null,
- },
+ [reportPreviewAction?.reportActionID ?? '']: {
+ pendingAction: null,
+ errors: null,
+ },
},
},
];
@@ -3263,6 +3847,167 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor
}
}
+function deleteTrackExpense(chatReportID: string, transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) {
+ // STEP 1: Get all collections we're updating
+ const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null;
+ const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
+ const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`];
+ const transactionThreadID = reportAction.childReportID;
+ let transactionThread = null;
+ if (transactionThreadID) {
+ transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`] ?? null;
+ }
+
+ // STEP 2: Decide if we need to:
+ // 1. Delete the transactionThread - delete if there are no visible comments in the thread
+ // 2. Update the moneyRequestPreview to show [Deleted request] - update if the transactionThread exists AND it isn't being deleted
+ const shouldDeleteTransactionThread = transactionThreadID ? (reportAction?.childVisibleActionCount ?? 0) === 0 : false;
+ const shouldShowDeletedRequestMessage = !!transactionThreadID && !shouldDeleteTransactionThread;
+
+ // STEP 3: Update the IOU reportAction.
+ const updatedReportAction = {
+ [reportAction.reportActionID]: {
+ pendingAction: shouldShowDeletedRequestMessage ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ previousMessage: reportAction.message,
+ message: [
+ {
+ type: 'COMMENT',
+ html: '',
+ text: '',
+ isEdited: true,
+ isDeletedParentAction: shouldShowDeletedRequestMessage,
+ },
+ ],
+ originalMessage: {
+ IOUTransactionID: null,
+ },
+ errors: undefined,
+ },
+ } as OnyxTypes.ReportActions;
+
+ const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(chatReport?.reportID ?? '', updatedReportAction);
+ const reportLastMessageText = ReportActionsUtils.getLastVisibleMessage(chatReport?.reportID ?? '', updatedReportAction).lastMessageText;
+
+ // STEP 4: Build Onyx data
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: null,
+ },
+ ];
+
+ if (Permissions.canUseViolations(betas)) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`,
+ value: null,
+ });
+ }
+
+ if (shouldDeleteTransactionThread) {
+ optimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadID}`,
+ value: null,
+ },
+ );
+ }
+
+ optimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
+ value: updatedReportAction,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`,
+ value: {
+ lastMessageText: reportLastMessageText,
+ lastVisibleActionCreated: lastVisibleAction?.created,
+ },
+ },
+ );
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ pendingAction: null,
+ errors: null,
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: transaction,
+ },
+ ];
+
+ if (Permissions.canUseViolations(betas)) {
+ failureData.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`,
+ value: transactionViolations,
+ });
+ }
+
+ if (shouldDeleteTransactionThread) {
+ failureData.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`,
+ value: transactionThread,
+ });
+ }
+
+ failureData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ ...reportAction,
+ pendingAction: null,
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericDeleteFailureMessage'),
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`,
+ value: chatReport,
+ },
+ );
+
+ const parameters: DeleteMoneyRequestParams = {
+ transactionID,
+ reportActionID: reportAction.reportActionID,
+ };
+
+ // STEP 6: Make the API request
+ API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData});
+ CachedPDFPaths.clearByKey(transactionID);
+
+ // STEP 7: Navigate the user depending on which page they are on and which resources were deleted
+ if (isSingleTransactionView && shouldDeleteTransactionThread) {
+ // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report.
+ Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID ?? ''));
+ }
+}
+
/**
* @param managerID - Account ID of the person sending the money
* @param recipient - The user receiving the money
@@ -4185,9 +4930,11 @@ function setMoneyRequestParticipantsFromReport(transactionID: string, report: On
// If the report is iou or expense report, we should get the chat report to set participant for request money
const chatReport = ReportUtils.isMoneyRequestReport(report) ? ReportUtils.getReport(report.chatReportID) : report;
const currentUserAccountID = currentUserPersonalDetails.accountID;
- const participants: Participant[] = ReportUtils.isPolicyExpenseChat(chatReport)
- ? [{reportID: chatReport?.reportID, isPolicyExpenseChat: true, selected: true}]
- : (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true}));
+ const shouldAddAsReport = !isEmptyObject(chatReport) && ReportUtils.isSelfDM(chatReport);
+ const participants: Participant[] =
+ ReportUtils.isPolicyExpenseChat(chatReport) || shouldAddAsReport
+ ? [{accountID: 0, reportID: chatReport?.reportID, isPolicyExpenseChat: ReportUtils.isPolicyExpenseChat(chatReport), selected: true}]
+ : (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true}));
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {participants, participantsAutoAssigned: true});
}
@@ -4224,15 +4971,6 @@ function setShownHoldUseExplanation() {
Onyx.set(ONYXKEYS.NVP_HOLD_USE_EXPLAINED, true);
}
-function setUpDistanceTransaction() {
- const transactionID = NumberUtils.rand64();
- Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
- transactionID,
- comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE}},
- });
- Onyx.merge(ONYXKEYS.IOU, {transactionID});
-}
-
/** Navigates to the next IOU page based on where the IOU request was started */
function navigateToNextPage(iou: OnyxEntry, iouType: string, report?: OnyxTypes.Report, path = '') {
const moneyRequestID = `${iouType}${report?.reportID ?? ''}`;
@@ -4283,7 +5021,8 @@ function getIOUReportID(iou?: OnyxTypes.IOU, route?: MoneyRequestRoute): string
* Put money request on HOLD
*/
function putOnHold(transactionID: string, comment: string, reportID: string) {
- const createdReportAction = ReportUtils.buildOptimisticHoldReportAction(comment);
+ const createdReportAction = ReportUtils.buildOptimisticHoldReportAction();
+ const createdReportActionComment = ReportUtils.buildOptimisticHoldReportActionComment(comment);
const optimisticData: OnyxUpdate[] = [
{
@@ -4291,6 +5030,7 @@ function putOnHold(transactionID: string, comment: string, reportID: string) {
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
value: {
[createdReportAction.reportActionID]: createdReportAction as ReportAction,
+ [createdReportActionComment.reportActionID]: createdReportActionComment as ReportAction,
},
},
{
@@ -4324,6 +5064,7 @@ function putOnHold(transactionID: string, comment: string, reportID: string) {
transactionID,
comment,
reportActionID: createdReportAction.reportActionID,
+ commentReportActionID: createdReportActionComment.reportActionID,
},
{optimisticData, successData, failureData},
);
@@ -4410,6 +5151,7 @@ export {
setMoneyRequestParticipants,
createDistanceRequest,
deleteMoneyRequest,
+ deleteTrackExpense,
splitBill,
splitBillAndOpenReport,
setDraftSplitTransaction,
@@ -4445,7 +5187,6 @@ export {
setMoneyRequestTag,
setMoneyRequestTaxAmount,
setMoneyRequestTaxRate,
- setUpDistanceTransaction,
setShownHoldUseExplanation,
navigateToNextPage,
updateMoneyRequestDate,
@@ -4465,6 +5206,7 @@ export {
cancelPayment,
navigateToStartStepIfScanFileCannotBeRead,
savePreferredPaymentMethod,
+ trackExpense,
canIOUBePaid,
canApproveIOU,
};
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index 1385a99fac0f..580898a2f869 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -14,6 +14,7 @@ import type {
CreateWorkspaceFromIOUPaymentParams,
CreateWorkspaceParams,
DeleteMembersFromWorkspaceParams,
+ DeletePolicyDistanceRatesParams,
DeleteWorkspaceAvatarParams,
DeleteWorkspaceParams,
EnablePolicyCategoriesParams,
@@ -35,6 +36,7 @@ import type {
OpenWorkspaceParams,
OpenWorkspaceReimburseViewParams,
SetPolicyDistanceRatesDefaultCategoryParams,
+ SetPolicyDistanceRatesEnabledParams,
SetPolicyDistanceRatesUnitParams,
SetWorkspaceApprovalModeParams,
SetWorkspaceAutoReportingFrequencyParams,
@@ -42,6 +44,7 @@ import type {
SetWorkspaceAutoReportingParams,
SetWorkspacePayerParams,
SetWorkspaceReimbursementParams,
+ UpdatePolicyDistanceRateValueParams,
UpdateWorkspaceAvatarParams,
UpdateWorkspaceCustomUnitAndRateParams,
UpdateWorkspaceDescriptionParams,
@@ -252,25 +255,38 @@ function updateLastAccessedWorkspace(policyID: OnyxEntry) {
Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID);
}
+/**
+ * Checks if the currency is supported for direct reimbursement
+ * USD currency is the only one supported in NewDot for now
+ */
+function isCurrencySupportedForDirectReimbursement(currency: string) {
+ return currency === CONST.CURRENCY.USD;
+}
+
/**
* Check if the user has any active free policies (aka workspaces)
*/
-function hasActiveFreePolicy(policies: Array> | PoliciesRecord): boolean {
- const adminFreePolicies = Object.values(policies).filter((policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN);
+function hasActiveChatEnabledPolicies(policies: Array> | PoliciesRecord, includeOnlyFreePolicies = false): boolean {
+ const adminChatEnabledPolicies = Object.values(policies).filter(
+ (policy) =>
+ policy &&
+ ((policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN) ||
+ (!includeOnlyFreePolicies && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.role === CONST.POLICY.ROLE.ADMIN && policy.isPolicyExpenseChatEnabled)),
+ );
- if (adminFreePolicies.length === 0) {
+ if (adminChatEnabledPolicies.length === 0) {
return false;
}
- if (adminFreePolicies.some((policy) => !policy?.pendingAction)) {
+ if (adminChatEnabledPolicies.some((policy) => !policy?.pendingAction)) {
return true;
}
- if (adminFreePolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) {
+ if (adminChatEnabledPolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) {
return true;
}
- if (adminFreePolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) {
+ if (adminChatEnabledPolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) {
return false;
}
@@ -298,7 +314,7 @@ function deleteWorkspace(policyID: string, policyName: string) {
errors: null,
},
},
- ...(!hasActiveFreePolicy(filteredPolicies)
+ ...(!hasActiveChatEnabledPolicies(filteredPolicies, true)
? [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -471,6 +487,7 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean, frequency
},
autoReportingFrequency: policy.autoReportingFrequency ?? null,
pendingFields: {autoReporting: null},
+ errorFields: {autoReporting: ErrorUtils.getMicroSecondOnyxError('workflowsDelayedSubmissionPage.autoReportingErrorMessage')},
},
},
];
@@ -511,6 +528,7 @@ function setWorkspaceAutoReportingFrequency(policyID: string, frequency: ValueOf
value: {
autoReportingFrequency: policy.autoReportingFrequency ?? null,
pendingFields: {autoReportingFrequency: null},
+ errorFields: {autoReportingFrequency: ErrorUtils.getMicroSecondOnyxError('workflowsDelayedSubmissionPage.autoReportingFrequencyErrorMessage')},
},
},
];
@@ -551,6 +569,7 @@ function setWorkspaceAutoReportingMonthlyOffset(policyID: string, autoReportingO
value: {
autoReportingOffset: policy.autoReportingOffset ?? null,
pendingFields: {autoReportingOffset: null},
+ errorFields: {autoReportingOffset: ErrorUtils.getMicroSecondOnyxError('workflowsDelayedSubmissionPage.monthlyOffsetErrorMessage')},
},
},
];
@@ -596,6 +615,7 @@ function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMo
approver: policy.approver ?? null,
approvalMode: policy.approvalMode ?? null,
pendingFields: {approvalMode: null},
+ errorFields: {approvalMode: ErrorUtils.getMicroSecondOnyxError('workflowsApprovalPage.genericErrorMessage')},
},
},
];
@@ -666,8 +686,8 @@ function setWorkspacePayer(policyID: string, reimburserEmail: string, reimburser
API.write(WRITE_COMMANDS.SET_WORKSPACE_PAYER, params, {optimisticData, failureData, successData});
}
-function clearWorkspacePayerError(policyID: string) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {reimburserEmail: null}});
+function clearPolicyErrorField(policyID: string, fieldName: string) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {[fieldName]: null}});
}
function setWorkspaceReimbursement(policyID: string, reimbursementChoice: ValueOf, reimburserAccountID: number, reimburserEmail: string) {
@@ -854,13 +874,14 @@ function removeMembers(accountIDs: number[], policyID: string) {
},
});
});
- optimisticClosedReportActions.forEach((reportAction, index) => {
- optimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`,
- value: {[reportAction.reportActionID]: reportAction as ReportAction},
- });
- });
+ // comment out for time this issue would be resolved https://github.com/Expensify/App/issues/35952
+ // optimisticClosedReportActions.forEach((reportAction, index) => {
+ // optimisticData.push({
+ // onyxMethod: Onyx.METHOD.MERGE,
+ // key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`,
+ // value: {[reportAction.reportActionID]: reportAction as ReportAction},
+ // });
+ // });
// If the policy has primaryLoginsInvited, then it displays informative messages on the members page about which primary logins were added by secondary logins.
// If we delete all these logins then we should clear the informative messages since they are no longer relevant.
@@ -1367,6 +1388,7 @@ function updateGeneralSettings(policyID: string, name: string, currency: string)
},
},
];
+
const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -2861,7 +2883,7 @@ function createPolicyTag(policyID: string, tagName: string) {
tags: {
[tagName]: {
name: tagName,
- enabled: false,
+ enabled: true,
errors: null,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
},
@@ -3403,7 +3425,15 @@ function navigateWhenEnableFeature(policyID: string, featureRoute: Route) {
return;
}
- Navigation.navigate(featureRoute);
+ /**
+ * The app needs to set a navigation action to the microtask queue, it guarantees to execute Onyx.update first, then the navigation action.
+ * More details - https://github.com/Expensify/App/issues/37785#issuecomment-1989056726.
+ */
+ new Promise((resolve) => {
+ resolve();
+ }).then(() => {
+ Navigation.navigate(featureRoute);
+ });
}
function enablePolicyCategories(policyID: string, enabled: boolean) {
@@ -3975,6 +4005,34 @@ function clearPolicyDistanceRatesErrorFields(policyID: string, customUnitID: str
});
}
+function clearDeleteDistanceRateError(policyID: string, customUnitID: string, rateID: string) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ customUnits: {
+ [customUnitID]: {
+ rates: {
+ [rateID]: {
+ errors: null,
+ },
+ },
+ },
+ },
+ });
+}
+
+function clearPolicyDistanceRateErrorFields(policyID: string, customUnitID: string, rateID: string, updatedErrorFields: ErrorFields) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ customUnits: {
+ [customUnitID]: {
+ rates: {
+ [rateID]: {
+ errorFields: updatedErrorFields,
+ },
+ },
+ },
+ },
+ });
+}
+
function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) {
const optimisticData: OnyxUpdate[] = [
{
@@ -3998,7 +4056,7 @@ function setPolicyDistanceRatesUnit(policyID: string, currentCustomUnit: CustomU
value: {
customUnits: {
[newCustomUnit.customUnitID]: {
- pendingFields: null,
+ pendingFields: {attributes: null},
},
},
},
@@ -4083,6 +4141,237 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn
API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData});
}
+/**
+ * Takes array of customUnitRates and removes pendingFields and errorFields from each rate - we don't want to send those via API
+ */
+function prepareCustomUnitRatesArray(customUnitRates: Rate[]): Rate[] {
+ const customUnitRateArray: Rate[] = [];
+ customUnitRates.forEach((rate) => {
+ const cleanedRate = {...rate};
+ delete cleanedRate.pendingFields;
+ delete cleanedRate.errorFields;
+ customUnitRateArray.push(cleanedRate);
+ });
+
+ return customUnitRateArray;
+}
+
+function updatePolicyDistanceRateValue(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) {
+ const currentRates = customUnit.rates;
+ const optimisticRates: Record = {};
+ const successRates: Record = {};
+ const failureRates: Record = {};
+ const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID);
+
+ for (const rateID of Object.keys(customUnit.rates)) {
+ if (rateIDs.includes(rateID)) {
+ const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID);
+ optimisticRates[rateID] = {...foundRate, pendingFields: {rate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}};
+ successRates[rateID] = {...foundRate, pendingFields: {rate: null}};
+ failureRates[rateID] = {
+ ...currentRates[rateID],
+ pendingFields: {rate: null},
+ errorFields: {rate: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ };
+ }
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: optimisticRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: successRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: failureRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const params: UpdatePolicyDistanceRateValueParams = {
+ policyID,
+ customUnitID: customUnit.customUnitID,
+ customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)),
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_RATE_VALUE, params, {optimisticData, successData, failureData});
+}
+
+function setPolicyDistanceRatesEnabled(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) {
+ const currentRates = customUnit.rates;
+ const optimisticRates: Record = {};
+ const successRates: Record = {};
+ const failureRates: Record = {};
+ const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID);
+
+ for (const rateID of Object.keys(currentRates)) {
+ if (rateIDs.includes(rateID)) {
+ const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID);
+ optimisticRates[rateID] = {...foundRate, pendingFields: {enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}};
+ successRates[rateID] = {...foundRate, pendingFields: {enabled: null}};
+ failureRates[rateID] = {
+ ...currentRates[rateID],
+ pendingFields: {enabled: null},
+ errorFields: {enabled: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')},
+ };
+ }
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: optimisticRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: successRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: failureRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const params: SetPolicyDistanceRatesEnabledParams = {
+ policyID,
+ customUnitID: customUnit.customUnitID,
+ customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)),
+ };
+
+ API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_ENABLED, params, {optimisticData, successData, failureData});
+}
+
+function deletePolicyDistanceRates(policyID: string, customUnit: CustomUnit, rateIDsToDelete: string[]) {
+ const currentRates = customUnit.rates;
+ const optimisticRates: Record = {};
+ const successRates: Record = {};
+ const failureRates: Record = {};
+
+ for (const rateID of Object.keys(currentRates)) {
+ if (rateIDsToDelete.includes(rateID)) {
+ optimisticRates[rateID] = {
+ ...currentRates[rateID],
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ };
+ failureRates[rateID] = {
+ ...currentRates[rateID],
+ pendingAction: null,
+ errors: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage'),
+ };
+ } else {
+ optimisticRates[rateID] = currentRates[rateID];
+ successRates[rateID] = currentRates[rateID];
+ }
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: optimisticRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: successRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ customUnits: {
+ [customUnit.customUnitID]: {
+ rates: failureRates,
+ },
+ },
+ },
+ },
+ ];
+
+ const params: DeletePolicyDistanceRatesParams = {
+ policyID,
+ customUnitID: customUnit.customUnitID,
+ customUnitRateID: rateIDsToDelete,
+ };
+
+ API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, successData, failureData});
+}
+
function setPolicyCustomTaxName(policyID: string, customTaxName: string) {
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
const originalCustomTaxName = policy?.taxRates?.name;
@@ -4244,7 +4533,7 @@ export {
updateWorkspaceMembersRole,
addMembersToWorkspace,
isAdminOfFreePolicy,
- hasActiveFreePolicy,
+ hasActiveChatEnabledPolicies,
setWorkspaceErrors,
clearCustomUnitErrors,
hideWorkspaceAlertMessage,
@@ -4297,7 +4586,6 @@ export {
renamePolicyCategory,
clearCategoryErrors,
setWorkspacePayer,
- clearWorkspacePayerError,
setWorkspaceReimbursement,
openPolicyWorkflowsPage,
setPolicyRequiresTag,
@@ -4314,6 +4602,7 @@ export {
generateCustomUnitID,
createPolicyDistanceRate,
clearCreateDistanceRateItemAndError,
+ clearDeleteDistanceRateError,
setPolicyDistanceRatesUnit,
setPolicyDistanceRatesDefaultCategory,
createPolicyTag,
@@ -4326,5 +4615,11 @@ export {
setWorkspaceCurrencyDefault,
setForeignCurrencyDefault,
setPolicyCustomTaxName,
+ clearPolicyErrorField,
+ isCurrencySupportedForDirectReimbursement,
clearPolicyDistanceRatesErrorFields,
+ clearPolicyDistanceRateErrorFields,
+ updatePolicyDistanceRateValue,
+ setPolicyDistanceRatesEnabled,
+ deletePolicyDistanceRates,
};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 49ecfce36cf0..93154bfff16b 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -116,28 +116,14 @@ Onyx.connect({
// map of reportID to all reportActions for that report
const allReportActions: OnyxCollection = {};
-// map of reportID to the ID of the oldest reportAction for that report
-const oldestReportActions: Record = {};
-
-// map of report to the ID of the newest action for that report
-const newestReportActions: Record = {};
-
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
- callback: (actions, key) => {
- if (!key || !actions) {
+ callback: (action, key) => {
+ if (!key || !action) {
return;
}
const reportID = CollectionUtils.extractCollectionItemID(key);
- allReportActions[reportID] = actions;
- const sortedActions = ReportActionsUtils.getSortedReportActions(Object.values(actions));
-
- if (sortedActions.length === 0) {
- return;
- }
-
- oldestReportActions[reportID] = sortedActions[0].reportActionID;
- newestReportActions[reportID] = sortedActions[sortedActions.length - 1].reportActionID;
+ allReportActions[reportID] = action;
},
});
@@ -546,6 +532,8 @@ function reportActionsExist(reportID: string): boolean {
* Gets the latest page of report actions and updates the last read message
* If a chat with the passed reportID is not found, we will create a chat based on the passed participantList
*
+ * @param reportID The ID of the report to open
+ * @param reportActionID The ID used to fetch a specific range of report actions related to the current reportActionID when opening a chat.
* @param participantLoginList The list of users that are included in a new chat, not including the user creating it
* @param newReportObject The optimistic report object created when making a new chat, saved as optimistic data
* @param parentReportActionID The parent report action that a thread was created from (only passed for new threads)
@@ -554,6 +542,7 @@ function reportActionsExist(reportID: string): boolean {
*/
function openReport(
reportID: string,
+ reportActionID?: string,
participantLoginList: string[] = [],
newReportObject: Partial = {},
parentReportActionID = '0',
@@ -589,6 +578,15 @@ function openReport(
];
const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ errorFields: {
+ notFound: null,
+ },
+ },
+ },
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
@@ -610,6 +608,7 @@ function openReport(
const parameters: OpenReportParams = {
reportID,
+ reportActionID,
emailList: participantLoginList ? participantLoginList.join(',') : '',
accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '',
parentReportActionID,
@@ -760,7 +759,7 @@ function navigateToAndOpenReport(userLogins: string[], shouldDismissModal = true
const report = chat ?? newChat;
// We want to pass newChat here because if anything is passed in that param (even an existing chat), we will try to create a chat on the server
- openReport(report.reportID, userLogins, newChat);
+ openReport(report.reportID, '', userLogins, newChat);
if (shouldDismissModal) {
Navigation.dismissModalWithReport(report);
} else {
@@ -783,7 +782,7 @@ function navigateToAndOpenReportWithAccountIDs(participantAccountIDs: number[])
const report = chat ?? newChat;
// We want to pass newChat here because if anything is passed in that param (even an existing chat), we will try to create a chat on the server
- openReport(report.reportID, [], newChat, '0', false, participantAccountIDs);
+ openReport(report.reportID, '', [], newChat, '0', false, participantAccountIDs);
Navigation.dismissModalWithReport(report);
}
@@ -817,7 +816,7 @@ function navigateToAndOpenChildReport(childReportID = '0', parentReportAction: P
);
const participantLogins = PersonalDetailsUtils.getLoginsByAccountIDs(newChat?.participantAccountIDs ?? []);
- openReport(newChat.reportID, participantLogins, newChat, parentReportAction.reportActionID);
+ openReport(newChat.reportID, '', participantLogins, newChat, parentReportAction.reportActionID);
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(newChat.reportID));
}
}
@@ -874,7 +873,7 @@ function reconnect(reportID: string) {
* Gets the older actions that have not been read yet.
* Normally happens when you scroll up on a chat, and the actions have not been read yet.
*/
-function getOlderActions(reportID: string) {
+function getOlderActions(reportID: string, reportActionID: string) {
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -907,7 +906,7 @@ function getOlderActions(reportID: string) {
const parameters: GetOlderActionsParams = {
reportID,
- reportActionID: oldestReportActions[reportID],
+ reportActionID,
};
API.read(READ_COMMANDS.GET_OLDER_ACTIONS, parameters, {optimisticData, successData, failureData});
@@ -917,7 +916,7 @@ function getOlderActions(reportID: string) {
* Gets the newer actions that have not been read yet.
* Normally happens when you are not located at the bottom of the list and scroll down on a chat.
*/
-function getNewerActions(reportID: string) {
+function getNewerActions(reportID: string, reportActionID: string) {
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -950,7 +949,7 @@ function getNewerActions(reportID: string) {
const parameters: GetNewerActionsParams = {
reportID,
- reportActionID: newestReportActions[reportID],
+ reportActionID,
};
API.read(READ_COMMANDS.GET_NEWER_ACTIONS, parameters, {optimisticData, successData, failureData});
@@ -1529,7 +1528,7 @@ function toggleSubscribeToChildReport(childReportID = '0', parentReportAction: P
);
const participantLogins = PersonalDetailsUtils.getLoginsByAccountIDs(participantAccountIDs);
- openReport(newChat.reportID, participantLogins, newChat, parentReportAction.reportActionID);
+ openReport(newChat.reportID, '', participantLogins, newChat, parentReportAction.reportActionID);
const notificationPreference =
prevNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
updateNotificationPreference(newChat.reportID, prevNotificationPreference, notificationPreference, false, parentReportID, parentReportAction?.reportActionID);
@@ -1589,29 +1588,30 @@ function updateReportName(reportID: string, value: string, previousValue: string
}
function updateReportField(reportID: string, reportField: PolicyReportField, previousReportField: PolicyReportField) {
- const recentlyUsedValues = allRecentlyUsedReportFields?.[reportField.fieldID] ?? [];
+ const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID);
+ const recentlyUsedValues = allRecentlyUsedReportFields?.[fieldKey] ?? [];
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
- reportFields: {
- [reportField.fieldID]: reportField,
+ fieldList: {
+ [fieldKey]: reportField,
},
pendingFields: {
- [reportField.fieldID]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ [fieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
},
},
},
];
- if (reportField.type === 'dropdown') {
+ if (reportField.type === 'dropdown' && reportField.value) {
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS,
value: {
- [reportField.fieldID]: [...new Set([...recentlyUsedValues, reportField.value])],
+ [fieldKey]: [...new Set([...recentlyUsedValues, reportField.value])],
},
});
}
@@ -1621,14 +1621,14 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
- reportFields: {
- [reportField.fieldID]: previousReportField,
+ fieldList: {
+ [fieldKey]: previousReportField,
},
pendingFields: {
- [reportField.fieldID]: null,
+ [fieldKey]: null,
},
errorFields: {
- [reportField.fieldID]: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReportFieldFailureMessage'),
+ [fieldKey]: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReportFieldFailureMessage'),
},
},
},
@@ -1639,7 +1639,7 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS,
value: {
- [reportField.fieldID]: recentlyUsedValues,
+ [fieldKey]: recentlyUsedValues,
},
});
}
@@ -1650,10 +1650,10 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
pendingFields: {
- [reportField.fieldID]: null,
+ [fieldKey]: null,
},
errorFields: {
- [reportField.fieldID]: null,
+ [fieldKey]: null,
},
},
},
@@ -1661,7 +1661,7 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
const parameters = {
reportID,
- reportFields: JSON.stringify({[`expensify_${reportField.fieldID}`]: reportField}),
+ reportFields: JSON.stringify({[fieldKey]: reportField}),
};
API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData});
@@ -2214,7 +2214,7 @@ function openReportFromDeepLink(url: string) {
if (reportID && !Session.hasAuthToken()) {
// Call the OpenReport command to check in the server if it's a public room. If so, we'll open it as an anonymous user
- openReport(reportID, [], {}, '0', true);
+ openReport(reportID, '', [], {}, '0', true);
// Show the sign-in page if the app is offline
if (isNetworkOffline) {
@@ -2456,8 +2456,6 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
- participantAccountIDs: participantAccountIDsAfterRemoval,
- visibleChatMemberAccountIDs: visibleChatMemberAccountIDsAfterRemoval,
pendingChatMembers,
},
},
@@ -2468,8 +2466,6 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
- participantAccountIDs: report?.participantAccountIDs,
- visibleChatMemberAccountIDs: report?.visibleChatMemberAccountIDs,
pendingChatMembers: report?.pendingChatMembers ?? null,
},
},
diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts
index 1bad1de0a9f5..3f2420c76f87 100644
--- a/src/libs/actions/TaxRate.ts
+++ b/src/libs/actions/TaxRate.ts
@@ -1,14 +1,25 @@
+import type {OnyxCollection} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
+import type {FormOnyxValues} from '@components/Form/types';
import * as API from '@libs/API';
-import type {CreatePolicyTaxParams} from '@libs/API/parameters';
+import type {CreatePolicyTaxParams, DeletePolicyTaxesParams, RenamePolicyTaxParams, SetPolicyTaxesEnabledParams, UpdatePolicyTaxValueParams} from '@libs/API/parameters';
import {WRITE_COMMANDS} from '@libs/API/types';
+import * as ValidationUtils from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import * as ErrorUtils from '@src/libs/ErrorUtils';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {TaxRate, TaxRates} from '@src/types/onyx';
-import type {PendingAction} from '@src/types/onyx/OnyxCommon';
+import INPUT_IDS from '@src/types/form/WorkspaceNewTaxForm';
+import type {Policy, TaxRate, TaxRates} from '@src/types/onyx';
+import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import type {OnyxData} from '@src/types/onyx/Request';
+let allPolicies: OnyxCollection;
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.POLICY,
+ waitForCollectionCallback: true,
+ callback: (value) => (allPolicies = value),
+});
+
/**
* Get tax value with percentage
*/
@@ -20,6 +31,34 @@ function covertTaxNameToID(name: string) {
return `id_${name.toUpperCase().replaceAll(' ', '_')}`;
}
+/**
+ * Function to validate tax name
+ */
+const validateTaxName = (policy: Policy, values: FormOnyxValues) => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]);
+
+ 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;
+};
+
+/**
+ * Function to validate tax value
+ */
+const validateTaxValue = (values: FormOnyxValues) => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.VALUE]);
+
+ const value = values[INPUT_IDS.VALUE];
+ if (!ValidationUtils.isValidPercentage(value)) {
+ errors[INPUT_IDS.VALUE] = 'workspace.taxes.errors.valuePercentageRange';
+ }
+
+ return errors;
+};
+
/**
* Get new tax ID
*/
@@ -39,7 +78,8 @@ function getNextTaxCode(name: string, taxRates?: TaxRates): string {
function createPolicyTax(policyID: string, taxRate: TaxRate) {
if (!taxRate.code) {
- throw new Error('Tax code is required when creating a new tax rate.');
+ console.debug('Policy or tax rates not found');
+ return;
}
const onyxData: OnyxData = {
@@ -83,7 +123,7 @@ function createPolicyTax(policyID: string, taxRate: TaxRate) {
taxRates: {
taxes: {
[taxRate.code]: {
- errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.genericFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.createFailureMessage'),
},
},
},
@@ -105,7 +145,24 @@ function createPolicyTax(policyID: string, taxRate: TaxRate) {
API.write(WRITE_COMMANDS.CREATE_POLICY_TAX, parameters, onyxData);
}
-function clearTaxRateError(policyID: string, taxID: string, pendingAction?: PendingAction) {
+function clearTaxRateFieldError(policyID: string, taxID: string, field: keyof TaxRate) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ taxRates: {
+ taxes: {
+ [taxID]: {
+ pendingFields: {
+ [field]: null,
+ },
+ errorFields: {
+ [field]: null,
+ },
+ },
+ },
+ },
+ });
+}
+
+function clearTaxRateError(policyID: string, taxID: string, pendingAction?: OnyxCommon.PendingAction) {
if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
taxRates: {
@@ -119,10 +176,288 @@ function clearTaxRateError(policyID: string, taxID: string, pendingAction?: Pend
Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
taxRates: {
taxes: {
- [taxID]: {pendingAction: null, errors: null},
+ [taxID]: {pendingAction: null, errors: null, errorFields: null},
},
},
});
}
-export {createPolicyTax, clearTaxRateError, getNextTaxCode, getTaxValueWithPercentage};
+type TaxRateEnabledMap = Record>;
+
+function setPolicyTaxesEnabled(policyID: string, taxesIDsToUpdate: string[], isEnabled: boolean) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const originalTaxes = {...policy?.taxRates?.taxes};
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: taxesIDsToUpdate.reduce((acc, taxID) => {
+ acc[taxID] = {
+ isDisabled: !isEnabled,
+ pendingFields: {isDisabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ errorFields: {isDisabled: null},
+ };
+ return acc;
+ }, {}),
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: taxesIDsToUpdate.reduce((acc, taxID) => {
+ acc[taxID] = {pendingFields: {isDisabled: null}, errorFields: {isDisabled: null}};
+ return acc;
+ }, {}),
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: taxesIDsToUpdate.reduce((acc, taxID) => {
+ acc[taxID] = {
+ isDisabled: !!originalTaxes[taxID].isDisabled,
+ pendingFields: {isDisabled: null},
+ errorFields: {isDisabled: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')},
+ };
+ return acc;
+ }, {}),
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxFieldsArray: JSON.stringify(taxesIDsToUpdate.map((taxID) => ({taxCode: taxID, enabled: isEnabled}))),
+ } satisfies SetPolicyTaxesEnabledParams;
+
+ API.write(WRITE_COMMANDS.SET_POLICY_TAXES_ENABLED, parameters, onyxData);
+}
+
+type TaxRateDeleteMap = Record<
+ string,
+ | (Pick & {
+ errors: OnyxCommon.Errors | null;
+ })
+ | null
+>;
+
+function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const policyTaxRates = policy?.taxRates?.taxes;
+
+ if (!policyTaxRates) {
+ console.debug('Policy or tax rates not found');
+ return;
+ }
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: taxesToDelete.reduce((acc, taxID) => {
+ acc[taxID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null};
+ return acc;
+ }, {}),
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: taxesToDelete.reduce((acc, taxID) => {
+ acc[taxID] = null;
+ return acc;
+ }, {}),
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: taxesToDelete.reduce((acc, taxID) => {
+ acc[taxID] = {
+ pendingAction: null,
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.deleteFailureMessage'),
+ };
+ return acc;
+ }, {}),
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)),
+ } satisfies DeletePolicyTaxesParams;
+
+ API.write(WRITE_COMMANDS.DELETE_POLICY_TAXES, parameters, onyxData);
+}
+
+function updatePolicyTaxValue(policyID: string, taxID: string, taxValue: number) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const originalTaxRate = {...policy?.taxRates?.taxes[taxID]};
+ const stringTaxValue = `${taxValue}%`;
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxID]: {
+ value: stringTaxValue,
+ pendingFields: {value: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ errorFields: {value: null},
+ },
+ },
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxID]: {pendingFields: {value: null}, errorFields: {value: null}},
+ },
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxID]: {
+ value: originalTaxRate.value,
+ pendingFields: {value: null},
+ errorFields: {value: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')},
+ },
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxCode: taxID,
+ taxAmount: Number(taxValue),
+ } satisfies UpdatePolicyTaxValueParams;
+
+ API.write(WRITE_COMMANDS.UPDATE_POLICY_TAX_VALUE, parameters, onyxData);
+}
+
+function renamePolicyTax(policyID: string, taxID: string, newName: string) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
+ const originalTaxRate = {...policy?.taxRates?.taxes[taxID]};
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxID]: {
+ name: newName,
+ pendingFields: {name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ errorFields: {name: null},
+ },
+ },
+ },
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxID]: {pendingFields: {name: null}, errorFields: {name: null}},
+ },
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ taxRates: {
+ taxes: {
+ [taxID]: {
+ name: originalTaxRate.name,
+ pendingFields: {name: null},
+ errorFields: {name: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')},
+ },
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const parameters = {
+ policyID,
+ taxCode: taxID,
+ newName,
+ } satisfies RenamePolicyTaxParams;
+
+ API.write(WRITE_COMMANDS.RENAME_POLICY_TAX, parameters, onyxData);
+}
+
+export {
+ createPolicyTax,
+ getNextTaxCode,
+ clearTaxRateError,
+ clearTaxRateFieldError,
+ getTaxValueWithPercentage,
+ setPolicyTaxesEnabled,
+ validateTaxName,
+ validateTaxValue,
+ deletePolicyTaxes,
+ updatePolicyTaxValue,
+ renamePolicyTax,
+};
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index 3d9aa5646098..123614f2e0bb 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -4,7 +4,7 @@ import lodashHas from 'lodash/has';
import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
-import type {GetRouteForDraftParams, GetRouteParams} from '@libs/API/parameters';
+import type {GetRouteParams} from '@libs/API/parameters';
import {READ_COMMANDS} from '@libs/API/types';
import * as CollectionUtils from '@libs/CollectionUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
@@ -218,26 +218,13 @@ function getOnyxDataForRouteRequest(transactionID: string, isDraft = false): Ony
* Gets the route for a set of waypoints
* Used so we can generate a map view of the provided waypoints
*/
-function getRoute(transactionID: string, waypoints: WaypointCollection) {
+function getRoute(transactionID: string, waypoints: WaypointCollection, isDraft: boolean) {
const parameters: GetRouteParams = {
transactionID,
waypoints: JSON.stringify(waypoints),
};
- API.read(READ_COMMANDS.GET_ROUTE, parameters, getOnyxDataForRouteRequest(transactionID));
-}
-
-/**
- * Gets the route for a set of waypoints
- * Used so we can generate a map view of the provided waypoints
- */
-function getRouteForDraft(transactionID: string, waypoints: WaypointCollection) {
- const parameters: GetRouteForDraftParams = {
- transactionID,
- waypoints: JSON.stringify(waypoints),
- };
-
- API.read(READ_COMMANDS.GET_ROUTE_FOR_DRAFT, parameters, getOnyxDataForRouteRequest(transactionID, true));
+ API.read(isDraft ? READ_COMMANDS.GET_ROUTE_FOR_DRAFT : READ_COMMANDS.GET_ROUTE, parameters, getOnyxDataForRouteRequest(transactionID, isDraft));
}
/**
@@ -277,4 +264,4 @@ function clearError(transactionID: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {errors: null});
}
-export {addStop, createInitialWaypoints, saveWaypoint, removeWaypoint, getRoute, getRouteForDraft, updateWaypoints, clearError};
+export {addStop, createInitialWaypoints, saveWaypoint, removeWaypoint, getRoute, updateWaypoints, clearError};
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index 6655d78cb0a8..2d23edfba93f 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -30,6 +30,7 @@ import PusherUtils from '@libs/PusherUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import playSound, {SOUNDS} from '@libs/Sound';
import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile';
+import Visibility from '@libs/Visibility';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -489,80 +490,84 @@ const isChannelMuted = (reportId: string) =>
function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) {
const reportActionsOnly = pushJSON.filter((update) => update.key?.includes('reportActions_'));
// "reportActions_5134363522480668" -> "5134363522480668"
- const reportIDs = reportActionsOnly.map((value) => value.key.split('_')[1]);
+ const reportID = reportActionsOnly
+ .map((value) => value.key.split('_')[1])
+ .find((reportKey) => reportKey === Navigation.getTopmostReportId() && Visibility.isVisible() && Visibility.hasFocus());
- Promise.all(reportIDs.map((reportID) => isChannelMuted(reportID)))
- .then((muted) => muted.every((isMuted) => isMuted))
- .then((isSoundMuted) => {
- if (isSoundMuted) {
- return;
+ if (!reportID) {
+ return;
+ }
+
+ isChannelMuted(reportID).then((isSoundMuted) => {
+ if (isSoundMuted) {
+ return;
+ }
+
+ try {
+ const flatten = reportActionsOnly.flatMap((update) => {
+ const value = update.value as OnyxCollection;
+
+ if (!value) {
+ return [];
+ }
+
+ return Object.values(value);
+ }) as ReportAction[];
+
+ for (const data of flatten) {
+ // Someone completes a task
+ if (data.actionName === 'TASKCOMPLETED') {
+ return playSound(SOUNDS.SUCCESS);
+ }
}
- try {
- const flatten = reportActionsOnly.flatMap((update) => {
- const value = update.value as OnyxCollection;
+ const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[];
- if (!value) {
- return [];
- }
+ for (const message of types) {
+ // someone sent money
+ if ('IOUDetails' in message) {
+ return playSound(SOUNDS.SUCCESS);
+ }
- return Object.values(value);
- }) as ReportAction[];
+ // mention user
+ if ('html' in message && typeof message.html === 'string' && message.html.includes(`@${currentEmail} `)) {
+ return playSoundExcludingMobile(SOUNDS.ATTENTION);
+ }
+
+ // mention @here
+ if ('html' in message && typeof message.html === 'string' && message.html.includes('')) {
+ return playSoundExcludingMobile(SOUNDS.ATTENTION);
+ }
- for (const data of flatten) {
- // Someone completes a task
- if (data.actionName === 'TASKCOMPLETED') {
- return playSound(SOUNDS.SUCCESS);
- }
+ // assign a task
+ if ('taskReportID' in message) {
+ return playSound(SOUNDS.ATTENTION);
}
- const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[];
-
- for (const message of types) {
- // someone sent money
- if ('IOUDetails' in message) {
- return playSound(SOUNDS.SUCCESS);
- }
-
- // mention user
- if ('html' in message && typeof message.html === 'string' && message.html.includes(`@${currentEmail} `)) {
- return playSoundExcludingMobile(SOUNDS.ATTENTION);
- }
-
- // mention @here
- if ('html' in message && typeof message.html === 'string' && message.html.includes('')) {
- return playSoundExcludingMobile(SOUNDS.ATTENTION);
- }
-
- // assign a task
- if ('taskReportID' in message) {
- return playSound(SOUNDS.ATTENTION);
- }
-
- // request money
- if ('IOUTransactionID' in message) {
- return playSound(SOUNDS.ATTENTION);
- }
-
- // Someone completes a money request
- if ('IOUReportID' in message) {
- return playSound(SOUNDS.SUCCESS);
- }
-
- // plain message
- if ('html' in message) {
- return playSoundExcludingMobile(SOUNDS.RECEIVE);
- }
+ // request money
+ if ('IOUTransactionID' in message) {
+ return playSound(SOUNDS.ATTENTION);
}
- } catch (e) {
- let errorMessage = String(e);
- if (e instanceof Error) {
- errorMessage = e.message;
+
+ // Someone completes a money request
+ if ('IOUReportID' in message) {
+ return playSound(SOUNDS.SUCCESS);
}
- Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`);
+ // plain message
+ if ('html' in message) {
+ return playSoundExcludingMobile(SOUNDS.RECEIVE);
+ }
}
- });
+ } catch (e) {
+ let errorMessage = String(e);
+ if (e instanceof Error) {
+ errorMessage = e.message;
+ }
+
+ Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`);
+ }
+ });
}
/**
@@ -960,11 +965,9 @@ function dismissReferralBanner(type: ValueOf we're either logged-in or shown 2FA screen
+ // !isSignedIn - confirms we're not signed-in yet as there's possible one last step (2FA validation)
+ const shouldPopToTop = (autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED || autoAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN) && !isSignedIn;
+
+ if (shouldPopToTop) {
+ Navigation.isNavigationReady().then(() => Navigation.resetToHome());
+ }
+}
+
+export default desktopLoginRedirect;
diff --git a/src/libs/desktopLoginRedirect/index.ts b/src/libs/desktopLoginRedirect/index.ts
new file mode 100644
index 000000000000..14f5750c3de9
--- /dev/null
+++ b/src/libs/desktopLoginRedirect/index.ts
@@ -0,0 +1,5 @@
+import type {AutoAuthState} from '@src/types/onyx/Session';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function desktopLoginRedirect(autoAuthState: AutoAuthState, isSignedIn: boolean) {}
+export default desktopLoginRedirect;
diff --git a/src/libs/mapChildrenFlat.ts b/src/libs/mapChildrenFlat.ts
index 238e57d47a83..73009a3340d4 100644
--- a/src/libs/mapChildrenFlat.ts
+++ b/src/libs/mapChildrenFlat.ts
@@ -14,8 +14,12 @@ import React from 'react';
* return modifiedChild;
* });
*/
-const mapChildrenFlat = (...args: Parameters>) => {
- const mappedChildren = React.Children.map(...args);
+const mapChildrenFlat = (element: C, fn: (child: C, index: number) => T) => {
+ if (typeof element === 'function') {
+ return element(false) as C;
+ }
+
+ const mappedChildren = React.Children.map(element, fn);
if (Array.isArray(mappedChildren) && mappedChildren.length === 1) {
return mappedChildren[0];
diff --git a/src/libs/markAllPolicyReportsAsRead.ts b/src/libs/markAllPolicyReportsAsRead.ts
new file mode 100644
index 000000000000..c3c719b1132e
--- /dev/null
+++ b/src/libs/markAllPolicyReportsAsRead.ts
@@ -0,0 +1,33 @@
+// eslint-disable-next-line you-dont-need-lodash-underscore/each
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Report} from '@src/types/onyx';
+import * as ReportActionFile from './actions/Report';
+import * as ReportUtils from './ReportUtils';
+
+export default function markAllPolicyReportsAsRead(policyID: string) {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ if (!allReports) {
+ return;
+ }
+
+ let delay = 0;
+ Object.keys(allReports).forEach((key: string) => {
+ const report: Report | null | undefined = allReports[key];
+ if (report?.policyID !== policyID || !ReportUtils.isUnread(report)) {
+ return;
+ }
+
+ setTimeout(() => {
+ ReportActionFile.readNewestAction(report?.reportID);
+ }, delay);
+
+ delay += 1000;
+ });
+ Onyx.disconnect(connectionID);
+ },
+ });
+}
diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts
index 1202275067a5..e8c0b2bf3e10 100644
--- a/src/libs/migrateOnyx.ts
+++ b/src/libs/migrateOnyx.ts
@@ -1,5 +1,7 @@
import Log from './Log';
+import CheckForPreviousReportActionID from './migrations/CheckForPreviousReportActionID';
import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID';
+import NVPMigration from './migrations/NVPMigration';
import RemoveEmptyReportActionsDrafts from './migrations/RemoveEmptyReportActionsDrafts';
import RenameReceiptFilename from './migrations/RenameReceiptFilename';
import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection';
@@ -10,7 +12,14 @@ export default function (): Promise {
return new Promise((resolve) => {
// Add all migrations to an array so they are executed in order
- const migrationPromises = [RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts];
+ const migrationPromises = [
+ CheckForPreviousReportActionID,
+ RenameReceiptFilename,
+ KeyReportActionsDraftByReportActionID,
+ TransactionBackupsToCollection,
+ RemoveEmptyReportActionsDrafts,
+ NVPMigration,
+ ];
// Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the
// previous promise to finish before moving onto the next one.
diff --git a/src/libs/migrations/NVPMigration.ts b/src/libs/migrations/NVPMigration.ts
new file mode 100644
index 000000000000..9ab774328f78
--- /dev/null
+++ b/src/libs/migrations/NVPMigration.ts
@@ -0,0 +1,86 @@
+import after from 'lodash/after';
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+// These are the oldKeyName: newKeyName of the NVPs we can migrate without any processing
+const migrations = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ nvp_lastPaymentMethod: ONYXKEYS.NVP_LAST_PAYMENT_METHOD,
+ isFirstTimeNewExpensifyUser: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER,
+ preferredLocale: ONYXKEYS.NVP_PREFERRED_LOCALE,
+ preferredEmojiSkinTone: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
+ frequentlyUsedEmojis: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ private_blockedFromConcierge: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ private_pushNotificationID: ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID,
+ tryFocusMode: ONYXKEYS.NVP_TRY_FOCUS_MODE,
+ introSelected: ONYXKEYS.NVP_INTRO_SELECTED,
+ hasDismissedIdlePanel: ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL,
+};
+
+// This migration changes the keys of all the NVP related keys so that they are standardized
+export default function () {
+ return new Promise((resolve) => {
+ // Resolve the migration when all the keys have been migrated. The number of keys is the size of the `migrations` object in addition to the ACCOUNT and OLD_POLICY_RECENTLY_USED_TAGS keys (which is why there is a +2).
+ const resolveWhenDone = after(Object.entries(migrations).length + 2, () => resolve());
+
+ for (const [oldKey, newKey] of Object.entries(migrations)) {
+ const connectionID = Onyx.connect({
+ // @ts-expect-error oldKey is a variable
+ key: oldKey,
+ callback: (value) => {
+ Onyx.disconnect(connectionID);
+ if (value === null) {
+ resolveWhenDone();
+ return;
+ }
+ // @ts-expect-error These keys are variables, so we can't check the type
+ Onyx.multiSet({
+ [newKey]: value,
+ [oldKey]: null,
+ }).then(resolveWhenDone);
+ },
+ });
+ }
+ const connectionIDAccount = Onyx.connect({
+ key: ONYXKEYS.ACCOUNT,
+ callback: (value) => {
+ Onyx.disconnect(connectionIDAccount);
+ // @ts-expect-error we are removing this property, so it is not in the type anymore
+ if (!value?.activePolicyID) {
+ resolveWhenDone();
+ return;
+ }
+ // @ts-expect-error we are removing this property, so it is not in the type anymore
+ const activePolicyID = value.activePolicyID;
+ const newValue = {...value};
+ // @ts-expect-error we are removing this property, so it is not in the type anymore
+ delete newValue.activePolicyID;
+ Onyx.multiSet({
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: activePolicyID,
+ [ONYXKEYS.ACCOUNT]: newValue,
+ }).then(resolveWhenDone);
+ },
+ });
+ const connectionIDRecentlyUsedTags = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.OLD_POLICY_RECENTLY_USED_TAGS,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ Onyx.disconnect(connectionIDRecentlyUsedTags);
+ if (!value) {
+ resolveWhenDone();
+ return;
+ }
+ const newValue = {};
+ for (const key of Object.keys(value)) {
+ // @ts-expect-error We have no fixed types here
+ newValue[`nvp_${key}`] = value[key];
+ // @ts-expect-error We have no fixed types here
+ newValue[key] = null;
+ }
+ Onyx.multiSet(newValue).then(resolveWhenDone);
+ },
+ });
+ });
+}
diff --git a/src/libs/shouldFetchReport.ts b/src/libs/shouldFetchReport.ts
new file mode 100644
index 000000000000..5259e88ca6d8
--- /dev/null
+++ b/src/libs/shouldFetchReport.ts
@@ -0,0 +1,7 @@
+import type Report from '@src/types/onyx/Report';
+
+export default function shouldFetchReport(report: Report) {
+ // If the report is optimistic, there's no need to fetch it. The original action should create it.
+ // If there is an error for creating the chat, there's no need to fetch it since it doesn't exist
+ return !report?.isOptimisticReport && !report?.errorFields?.createChat;
+}
diff --git a/src/pages/EditReportFieldDatePage.tsx b/src/pages/EditReportFieldDatePage.tsx
index 45d2f31073ec..3d60884d3cfc 100644
--- a/src/pages/EditReportFieldDatePage.tsx
+++ b/src/pages/EditReportFieldDatePage.tsx
@@ -19,8 +19,8 @@ type EditReportFieldDatePageProps = {
/** Name of the policy report field */
fieldName: string;
- /** ID of the policy report field */
- fieldID: string;
+ /** Key of the policy report field */
+ fieldKey: string;
/** Flag to indicate if the field can be left blank */
isRequired: boolean;
@@ -29,7 +29,7 @@ type EditReportFieldDatePageProps = {
onSubmit: (form: FormOnyxValues) => void;
};
-function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldID}: EditReportFieldDatePageProps) {
+function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldKey}: EditReportFieldDatePageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const inputRef = useRef(null);
@@ -37,12 +37,12 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f
const validate = useCallback(
(value: FormOnyxValues) => {
const errors: FormInputErrors = {};
- if (isRequired && value[fieldID].trim() === '') {
- errors[fieldID] = 'common.error.fieldRequired';
+ if (isRequired && value[fieldKey].trim() === '') {
+ errors[fieldKey] = 'common.error.fieldRequired';
}
return errors;
},
- [fieldID, isRequired],
+ [fieldKey, isRequired],
);
return (
@@ -67,8 +67,8 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f
{/* @ts-expect-error TODO: Remove this once DatePicker (https://github.com/Expensify/App/issues/25148) is migrated to TypeScript. */}
InputComponent={DatePicker}
- inputID={fieldID}
- name={fieldID}
+ inputID={fieldKey}
+ name={fieldKey}
defaultValue={fieldValue}
label={fieldName}
accessibilityLabel={fieldName}
diff --git a/src/pages/EditReportFieldDropdownPage.tsx b/src/pages/EditReportFieldDropdownPage.tsx
index 1ad3c766221b..a3d9c2fd99ff 100644
--- a/src/pages/EditReportFieldDropdownPage.tsx
+++ b/src/pages/EditReportFieldDropdownPage.tsx
@@ -17,8 +17,8 @@ type EditReportFieldDropdownPageComponentProps = {
/** Name of the policy report field */
fieldName: string;
- /** ID of the policy report field */
- fieldID: string;
+ /** Key of the policy report field */
+ fieldKey: string;
/** ID of the policy this report field belongs to */
// eslint-disable-next-line react/no-unused-prop-types
@@ -37,42 +37,89 @@ type EditReportFieldDropdownPageOnyxProps = {
type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps & EditReportFieldDropdownPageOnyxProps;
-function EditReportFieldDropdownPage({fieldName, onSubmit, fieldID, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) {
+type ReportFieldDropdownData = {
+ text: string;
+ keyForList: string;
+ searchText: string;
+ tooltipText: string;
+};
+
+type ReportFieldDropdownSectionItem = {
+ data: ReportFieldDropdownData[];
+ shouldShow: boolean;
+ title?: string;
+};
+
+function EditReportFieldDropdownPage({fieldName, onSubmit, fieldKey, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) {
const [searchValue, setSearchValue] = useState('');
const styles = useThemeStyles();
const {getSafeAreaMargins} = useStyleUtils();
const {translate} = useLocalize();
- const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldID] ?? [], [recentlyUsedReportFields, fieldID]);
- const [headerMessage, setHeaderMessage] = useState('');
-
- const sections = useMemo(() => {
- const filteredRecentOptions = recentlyUsedOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase()));
- const filteredRestOfOptions = fieldOptions.filter((option) => !filteredRecentOptions.includes(option) && option.toLowerCase().includes(searchValue.toLowerCase()));
- setHeaderMessage(!filteredRecentOptions.length && !filteredRestOfOptions.length ? translate('common.noResultsFound') : '');
-
- return [
- {
- title: translate('common.recents'),
- shouldShow: filteredRecentOptions.length > 0,
- data: filteredRecentOptions.map((option) => ({
- text: option,
- keyForList: option,
- searchText: option,
- tooltipText: option,
- })),
- },
- {
- title: translate('common.all'),
- shouldShow: filteredRestOfOptions.length > 0,
- data: filteredRestOfOptions.map((option) => ({
+ const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldKey] ?? [], [recentlyUsedReportFields, fieldKey]);
+
+ const {sections, headerMessage} = useMemo(() => {
+ let newHeaderMessage = '';
+ const newSections: ReportFieldDropdownSectionItem[] = [];
+
+ if (searchValue) {
+ const filteredOptions = fieldOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase()));
+ newHeaderMessage = !filteredOptions.length ? translate('common.noResultsFound') : '';
+ newSections.push({
+ shouldShow: false,
+ data: filteredOptions.map((option) => ({
text: option,
keyForList: option,
searchText: option,
tooltipText: option,
})),
- },
- ];
- }, [fieldOptions, recentlyUsedOptions, searchValue, translate]);
+ });
+ } else {
+ const selectedValue = fieldValue;
+ if (selectedValue) {
+ newSections.push({
+ shouldShow: false,
+ data: [
+ {
+ text: selectedValue,
+ keyForList: selectedValue,
+ searchText: selectedValue,
+ tooltipText: selectedValue,
+ },
+ ],
+ });
+ }
+
+ const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((option) => option !== selectedValue);
+ if (filteredRecentlyUsedOptions.length > 0) {
+ newSections.push({
+ title: translate('common.recents'),
+ shouldShow: true,
+ data: filteredRecentlyUsedOptions.map((option) => ({
+ text: option,
+ keyForList: option,
+ searchText: option,
+ tooltipText: option,
+ })),
+ });
+ }
+
+ const filteredFieldOptions = fieldOptions.filter((option) => option !== selectedValue);
+ if (filteredFieldOptions.length > 0) {
+ newSections.push({
+ title: translate('common.all'),
+ shouldShow: true,
+ data: filteredFieldOptions.map((option) => ({
+ text: option,
+ keyForList: option,
+ searchText: option,
+ tooltipText: option,
+ })),
+ });
+ }
+ }
+
+ return {sections: newSections, headerMessage: newHeaderMessage};
+ }, [fieldValue, fieldOptions, recentlyUsedOptions, searchValue, translate]);
return (
) => onSubmit({[fieldID]: option.text})}
+ onSelectRow={(option: Record) =>
+ onSubmit({
+ [fieldKey]: fieldValue === option.text ? '' : option.text,
+ })
+ }
onChangeText={setSearchValue}
highlightSelectedOptions
isRowMultilineSupported
diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx
index 4124a9ebef98..8c8376468c0f 100644
--- a/src/pages/EditReportFieldPage.tsx
+++ b/src/pages/EditReportFieldPage.tsx
@@ -9,7 +9,7 @@ import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import * as ReportActions from '@src/libs/actions/Report';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Policy, PolicyReportFields, Report} from '@src/types/onyx';
+import type {Policy, Report} from '@src/types/onyx';
import EditReportFieldDatePage from './EditReportFieldDatePage';
import EditReportFieldDropdownPage from './EditReportFieldDropdownPage';
import EditReportFieldTextPage from './EditReportFieldTextPage';
@@ -18,9 +18,6 @@ type EditReportFieldPageOnyxProps = {
/** The report object for the expense report */
report: OnyxEntry;
- /** Policy report fields */
- policyReportFields: OnyxEntry;
-
/** Policy to which the report belongs to */
policy: OnyxEntry;
};
@@ -42,8 +39,9 @@ type EditReportFieldPageProps = EditReportFieldPageOnyxProps & {
};
};
-function EditReportFieldPage({route, policy, report, policyReportFields}: EditReportFieldPageProps) {
- const reportField = report?.reportFields?.[route.params.fieldID] ?? policyReportFields?.[route.params.fieldID];
+function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps) {
+ const fieldKey = ReportUtils.getReportFieldKey(route.params.fieldID);
+ const reportField = report?.fieldList?.[fieldKey] ?? policy?.fieldList?.[fieldKey];
const isDisabled = ReportUtils.isReportFieldDisabled(report, reportField ?? null, policy);
if (!reportField || !report || isDisabled) {
@@ -65,11 +63,11 @@ function EditReportFieldPage({route, policy, report, policyReportFields}: EditRe
const isReportFieldTitle = ReportUtils.isReportFieldOfTypeTitle(reportField);
const handleReportFieldChange = (form: FormOnyxValues) => {
- const value = form[reportField.fieldID] || '';
+ const value = form[fieldKey];
if (isReportFieldTitle) {
ReportActions.updateReportName(report.reportID, value, report.reportName ?? '');
} else {
- ReportActions.updateReportField(report.reportID, {...reportField, value}, reportField);
+ ReportActions.updateReportField(report.reportID, {...reportField, value: value === '' ? null : value}, reportField);
}
Navigation.dismissModal(report?.reportID);
@@ -81,7 +79,7 @@ function EditReportFieldPage({route, policy, report, policyReportFields}: EditRe
return (
!(value in reportField.disabledOptions))}
onSubmit={handleReportFieldChange}
/>
);
@@ -121,9 +119,6 @@ export default withOnyx(
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`,
},
- policyReportFields: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${route.params.policyID}`,
- },
policy: {
key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`,
},
diff --git a/src/pages/EditReportFieldTextPage.tsx b/src/pages/EditReportFieldTextPage.tsx
index 9cda559280a9..1a6cf96fb37a 100644
--- a/src/pages/EditReportFieldTextPage.tsx
+++ b/src/pages/EditReportFieldTextPage.tsx
@@ -19,8 +19,8 @@ type EditReportFieldTextPageProps = {
/** Name of the policy report field */
fieldName: string;
- /** ID of the policy report field */
- fieldID: string;
+ /** Key of the policy report field */
+ fieldKey: string;
/** Flag to indicate if the field can be left blank */
isRequired: boolean;
@@ -29,7 +29,7 @@ type EditReportFieldTextPageProps = {
onSubmit: (form: FormOnyxValues) => void;
};
-function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldID}: EditReportFieldTextPageProps) {
+function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldKey}: EditReportFieldTextPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const inputRef = useRef(null);
@@ -37,12 +37,12 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f
const validate = useCallback(
(values: FormOnyxValues) => {
const errors: FormInputErrors = {};
- if (isRequired && values[fieldID].trim() === '') {
- errors[fieldID] = 'common.error.fieldRequired';
+ if (isRequired && values[fieldKey].trim() === '') {
+ errors[fieldKey] = 'common.error.fieldRequired';
}
return errors;
},
- [fieldID, isRequired],
+ [fieldKey, isRequired],
);
return (
@@ -66,8 +66,8 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f
{
- hasWaypointError.current = Boolean(lodashGet(transaction, 'errorFields.route') || lodashGet(transaction, 'errorFields.waypoints'));
-
- // When the loading goes from true to false, then we know the transaction has just been
- // saved to the server. Check for errors. If there are no errors, then the modal can be closed.
- if (prevIsLoading && !transaction.isLoading && !hasWaypointError.current) {
- Navigation.dismissModal(report.reportID);
- }
- }, [transaction, prevIsLoading, report]);
-
- /**
- * Save the changes to the original transaction object
- * @param {Object} waypoints
- */
- const saveTransaction = (waypoints) => {
- // If nothing was changed, simply go to transaction thread
- // We compare only addresses because numbers are rounded while backup
- const oldWaypoints = lodashGet(transactionBackup, 'comment.waypoints', {});
- const oldAddresses = _.mapObject(oldWaypoints, (waypoint) => _.pick(waypoint, 'address'));
- const addresses = _.mapObject(waypoints, (waypoint) => _.pick(waypoint, 'address'));
- if (_.isEqual(oldAddresses, addresses)) {
- Navigation.dismissModal(report.reportID);
- return;
- }
-
- IOU.updateMoneyRequestDistance(transaction.transactionID, report.reportID, waypoints);
-
- // If the client is offline, then the modal can be closed as well (because there are no errors or other feedback to show them
- // until they come online again and sync with the server).
- if (isOffline) {
- Navigation.dismissModal(report.reportID);
- }
- };
-
- return (
-
- Navigation.goBack()}
- />
-
-
- );
-}
-
-EditRequestDistancePage.propTypes = propTypes;
-EditRequestDistancePage.defaultProps = defaultProps;
-EditRequestDistancePage.displayName = 'EditRequestDistancePage';
-export default withOnyx({
- transaction: {
- key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`,
- },
- transactionBackup: {
- key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${props.transactionID}`,
- },
-})(EditRequestDistancePage);
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index de17d16a7c38..7d10e0e55e79 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -20,7 +20,6 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import EditRequestAmountPage from './EditRequestAmountPage';
-import EditRequestDistancePage from './EditRequestDistancePage';
import EditRequestReceiptPage from './EditRequestReceiptPage';
import EditRequestTagPage from './EditRequestTagPage';
import reportActionPropTypes from './home/report/reportActionPropTypes';
@@ -176,16 +175,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p
);
}
- if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DISTANCE) {
- return (
-
- );
- }
-
return (
;
/** An object that holds data about which referral banners have been dismissed */
- dismissedReferralBanners: DismissedReferralBanners;
+ dismissedReferralBanners: OnyxEntry;
/** Whether we are searching for reports in the server */
isSearchingForReports: OnyxEntry;
@@ -265,7 +264,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF
shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
shouldShowOptions={isOptionsDataReady && didScreenTransitionEnd}
shouldShowConfirmButton
- shouldShowReferralCTA={!dismissedReferralBanners[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]}
+ shouldShowReferralCTA={!dismissedReferralBanners?.[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]}
referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT}
confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')}
textInputAlert={isOffline ? [`${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}`, {isTranslated: true}] : ''}
@@ -287,8 +286,7 @@ NewChatPage.displayName = 'NewChatPage';
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/ReimbursementAccount/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
index 82f2d4689044..027ab77d84da 100644
--- a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
+++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
@@ -124,6 +124,7 @@ function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkTok
[BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN]: '',
};
ReimbursementAccountUtils.updateReimbursementAccountDraft(bankAccountData);
+ ReimbursementAccountUtils.hideBankAccountErrors();
BankAccounts.setBankAccountSubStep(null);
}
} else {
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
index 473b36571390..0f5d04919e29 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
@@ -484,9 +484,7 @@ function ReimbursementAccountPage({reimbursementAccount, route, onfidoToken, pol
reimbursementAccount={reimbursementAccount}
continue={continueFunction}
policyName={policyName}
- onBackButtonPress={() => {
- Navigation.goBack();
- }}
+ onBackButtonPress={Navigation.goBack}
/>
);
}
diff --git a/src/pages/ValidateLoginPage/index.website.tsx b/src/pages/ValidateLoginPage/index.website.tsx
index 2acad7815754..b8e8709215e8 100644
--- a/src/pages/ValidateLoginPage/index.website.tsx
+++ b/src/pages/ValidateLoginPage/index.website.tsx
@@ -4,6 +4,7 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import ExpiredValidateCodeModal from '@components/ValidateCode/ExpiredValidateCodeModal';
import JustSignedInModal from '@components/ValidateCode/JustSignedInModal';
import ValidateCodeModal from '@components/ValidateCode/ValidateCodeModal';
+import desktopLoginRedirect from '@libs/desktopLoginRedirect';
import Navigation from '@libs/Navigation/Navigation';
import * as Session from '@userActions/Session';
import CONST from '@src/CONST';
@@ -43,6 +44,11 @@ function ValidateLoginPage({
// The user has initiated the sign in process on the same browser, in another tab.
Session.signInWithValidateCode(Number(accountID), validateCode);
+
+ // Since on Desktop we don't have multi-tab functionality to handle the login flow,
+ // we need to `popToTop` the stack after `signInWithValidateCode` in order to
+ // perform login for both 2FA and non-2FA accounts.
+ desktopLoginRedirect(autoAuthState, isSignedIn);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx
index 8e0aefc39257..784924dfd074 100644
--- a/src/pages/home/HeaderView.tsx
+++ b/src/pages/home/HeaderView.tsx
@@ -78,7 +78,7 @@ function HeaderView({report, personalDetails, parentReport, parentReportAction,
const styles = useThemeStyles();
const isSelfDM = ReportUtils.isSelfDM(report);
// Currently, currentUser is not included in participantAccountIDs, so for selfDM, we need to add the currentUser as participants.
- const participants = isSelfDM ? [session?.accountID ?? -1] : report?.participantAccountIDs ?? [];
+ const participants = isSelfDM ? [session?.accountID ?? -1] : (report?.participantAccountIDs ?? []).slice(0, 5);
const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, personalDetails);
const isMultipleParticipant = participants.length > 1;
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant, undefined, isSelfDM);
@@ -89,7 +89,7 @@ function HeaderView({report, personalDetails, parentReport, parentReportAction,
const isTaskReport = ReportUtils.isTaskReport(report);
const reportHeaderData = !isTaskReport && !isChatThread && report.parentReportID ? parentReport : report;
// Use sorted display names for the title for group chats on native small screen widths
- const title = ReportUtils.isGroupChat(report) ? getGroupChatName(report) : ReportUtils.getReportName(reportHeaderData);
+ const title = ReportUtils.isGroupChat(report) ? getGroupChatName(report, true) : ReportUtils.getReportName(reportHeaderData);
const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData);
const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData);
const isConcierge = ReportUtils.hasSingleParticipant(report) && participants.includes(CONST.ACCOUNT_ID.CONCIERGE);
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index 009d55e6d139..242602b0654c 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -1,7 +1,7 @@
import {useIsFocused} from '@react-navigation/native';
import type {StackScreenProps} from '@react-navigation/stack';
import lodashIsEqual from 'lodash/isEqual';
-import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import type {FlatList, ViewStyle} from 'react-native';
import {withOnyx} from 'react-native-onyx';
@@ -9,8 +9,10 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {WithOnyxInstanceState} from 'react-native-onyx/dist/types';
import type {LayoutChangeEvent} from 'react-native/Libraries/Types/CoreEventTypes';
import Banner from '@components/Banner';
+import BlockingView from '@components/BlockingViews/BlockingView';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import DragAndDropProvider from '@components/DragAndDrop/Provider';
+import * as Illustrations from '@components/Icon/Illustrations';
import MoneyReportHeader from '@components/MoneyReportHeader';
import MoneyRequestHeader from '@components/MoneyRequestHeader';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -35,7 +37,9 @@ import Performance from '@libs/Performance';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import shouldFetchReport from '@libs/shouldFetchReport';
import type {CentralPaneNavigatorParamList} from '@navigation/types';
+import variables from '@styles/variables';
import * as ComposerActions from '@userActions/Composer';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
@@ -69,8 +73,8 @@ type ReportScreenOnyxProps = {
/** Whether the composer is full size */
isComposerFullSize: OnyxEntry;
- /** All the report actions for this report */
- reportActions: OnyxTypes.ReportAction[];
+ /** An array containing all report actions related to this report, sorted based on a date criterion */
+ sortedAllReportActions: OnyxTypes.ReportAction[];
/** The report currently being looked at */
report: OnyxEntry;
@@ -116,12 +120,12 @@ function ReportScreen({
betas = [],
route,
report: reportProp,
+ sortedAllReportActions,
reportMetadata = {
isLoadingInitialReportActions: true,
isLoadingOlderReportActions: false,
isLoadingNewerReportActions: false,
},
- reportActions = [],
parentReportAction,
accountManagerReportID,
markReadyForHydration,
@@ -136,6 +140,8 @@ function ReportScreen({
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
+ const reportIDFromRoute = getReportID(route);
+ const reportActionIDFromRoute = route?.params?.reportActionID ?? '';
const isFocused = useIsFocused();
const prevIsFocused = usePrevious(isFocused);
const firstRenderRef = useRef(true);
@@ -171,7 +177,7 @@ function ReportScreen({
managerID: reportProp?.managerID,
total: reportProp?.total,
nonReimbursableTotal: reportProp?.nonReimbursableTotal,
- reportFields: reportProp?.reportFields,
+ fieldList: reportProp?.fieldList,
ownerAccountID: reportProp?.ownerAccountID,
currency: reportProp?.currency,
participantAccountIDs: reportProp?.participantAccountIDs,
@@ -208,7 +214,7 @@ function ReportScreen({
reportProp?.managerID,
reportProp?.total,
reportProp?.nonReimbursableTotal,
- reportProp?.reportFields,
+ reportProp?.fieldList,
reportProp?.ownerAccountID,
reportProp?.currency,
reportProp?.participantAccountIDs,
@@ -228,6 +234,20 @@ function ReportScreen({
const prevReport = usePrevious(report);
const prevUserLeavingStatus = usePrevious(userLeavingStatus);
+ const [isLinkingToMessage, setLinkingToMessage] = useState(!!reportActionIDFromRoute);
+ const reportActions = useMemo(() => {
+ if (!sortedAllReportActions.length) {
+ return [];
+ }
+ const currentRangeOfReportActions = ReportActionsUtils.getContinuousReportActionChain(sortedAllReportActions, reportActionIDFromRoute);
+ return currentRangeOfReportActions;
+ }, [reportActionIDFromRoute, sortedAllReportActions]);
+
+ // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. If we have cached reportActions, they will be shown immediately. We aim to display a loader first, then fetch relevant reportActions, and finally show them.
+ useLayoutEffect(() => {
+ setLinkingToMessage(!!reportActionIDFromRoute);
+ }, [route, reportActionIDFromRoute]);
+
const [isBannerVisible, setIsBannerVisible] = useState(true);
const [listHeight, setListHeight] = useState(0);
const [scrollPosition, setScrollPosition] = useState({});
@@ -238,16 +258,18 @@ function ReportScreen({
Performance.markStart(CONST.TIMING.CHAT_RENDER);
}
- const reportID = getReportID(route);
const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report);
const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}];
const isEmptyChat = useMemo((): boolean => reportActions.length === 0, [reportActions]);
// There are no reportActions at all to display and we are still in the process of loading the next set of actions.
const isLoadingInitialReportActions = reportActions.length === 0 && !!reportMetadata?.isLoadingInitialReportActions;
const isOptimisticDelete = report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED;
- const shouldHideReport = !ReportUtils.canAccessReport(report, policies, betas);
- const isLoading = !reportID || !isSidebarLoaded || PersonalDetailsUtils.isPersonalDetailsEmpty();
+ // If there's a non-404 error for the report we should show it instead of blocking the screen
+ const hasHelpfulErrors = Object.keys(report?.errorFields ?? {}).some((key) => key !== 'notFound');
+ const shouldHideReport = !hasHelpfulErrors && !ReportUtils.canAccessReport(report, policies, betas);
+
+ const isLoading = !reportIDFromRoute || !isSidebarLoaded || PersonalDetailsUtils.isPersonalDetailsEmpty();
const lastReportAction: OnyxEntry = useMemo(
() =>
reportActions.length
@@ -255,9 +277,9 @@ function ReportScreen({
: null,
[reportActions, parentReportAction],
);
- const isSingleTransactionView = ReportUtils.isMoneyRequest(report);
+ const isSingleTransactionView = ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report);
const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] ?? null;
- const isTopMostReportId = currentReportID === getReportID(route);
+ const isTopMostReportId = currentReportID === reportIDFromRoute;
const didSubscribeToReportLeavingEvents = useRef(false);
useEffect(() => {
@@ -274,7 +296,7 @@ function ReportScreen({
let headerView = (
{
- const reportIDFromPath = getReportID(route);
-
// This is necessary so that when we are retrieving the next report data from Onyx the ReportActionsView will remount completely
- const isTransitioning = report && report.reportID !== reportIDFromPath;
- return reportIDFromPath !== '' && !!report.reportID && !isTransitioning;
- }, [route, report]);
+ const isTransitioning = report && report.reportID !== reportIDFromRoute;
+ return reportIDFromRoute !== '' && !!report.reportID && !isTransitioning;
+ }, [report, reportIDFromRoute]);
+
+ const shouldShowSkeleton =
+ isLinkingToMessage || !isReportReadyForDisplay || isLoadingInitialReportActions || isLoading || (!!reportActionIDFromRoute && reportMetadata?.isLoadingInitialReportActions);
+
+ const shouldShowReportActionList = isReportReadyForDisplay && !isLoading;
+
+ const fetchReport = useCallback(() => {
+ Report.openReport(reportIDFromRoute, reportActionIDFromRoute);
+ }, [reportIDFromRoute, reportActionIDFromRoute]);
useEffect(() => {
if (!report.reportID || !isFocused) {
@@ -319,23 +348,25 @@ function ReportScreen({
}, [report.reportID, isFocused]);
const fetchReportIfNeeded = useCallback(() => {
- const reportIDFromPath = getReportID(route);
-
// Report ID will be empty when the reports collection is empty.
// This could happen when we are loading the collection for the first time after logging in.
- if (!ReportUtils.isValidReportIDFromPath(reportIDFromPath)) {
+ if (!ReportUtils.isValidReportIDFromPath(reportIDFromRoute)) {
return;
}
- // It possible that we may not have the report object yet in Onyx yet e.g. we navigated to a URL for an accessible report that
+ // It is possible that we may not have the report object yet in Onyx yet e.g. we navigated to a URL for an accessible report that
// is not stored locally yet. If report.reportID exists, then the report has been stored locally and nothing more needs to be done.
// If it doesn't exist, then we fetch the report from the API.
- if (report.reportID === getReportID(route) && !isLoadingInitialReportActions) {
+ if (report.reportID && report.reportID === reportIDFromRoute && !reportMetadata?.isLoadingInitialReportActions) {
return;
}
- Report.openReport(reportIDFromPath);
- }, [report.reportID, route, isLoadingInitialReportActions]);
+ if (!shouldFetchReport(report)) {
+ return;
+ }
+
+ fetchReport();
+ }, [report, reportMetadata?.isLoadingInitialReportActions, fetchReport, reportIDFromRoute]);
const dismissBanner = useCallback(() => {
setIsBannerVisible(false);
@@ -399,7 +430,6 @@ function ReportScreen({
const onyxReportID = report.reportID;
const prevOnyxReportID = prevReport.reportID;
- const routeReportID = getReportID(route);
// Navigate to the Concierge chat if the room was removed from another device (e.g. user leaving a room or removed from a room)
if (
@@ -407,7 +437,7 @@ function ReportScreen({
(!prevUserLeavingStatus && !!userLeavingStatus) ||
// optimistic case
(!!prevOnyxReportID &&
- prevOnyxReportID === routeReportID &&
+ prevOnyxReportID === reportIDFromRoute &&
!onyxReportID &&
prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN &&
(report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED || (!report.statusNum && !prevReport.parentReportID && prevReport.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ROOM))) ||
@@ -435,16 +465,29 @@ function ReportScreen({
// the ReportScreen never actually unmounts and the reportID in the route also doesn't change.
// Therefore, we need to compare if the existing reportID is the same as the one in the route
// before deciding that we shouldn't call OpenReport.
- if (onyxReportID === prevReport.reportID && (!onyxReportID || onyxReportID === routeReportID)) {
+ if (onyxReportID === prevReport.reportID && (!onyxReportID || onyxReportID === reportIDFromRoute)) {
return;
}
fetchReportIfNeeded();
ComposerActions.setShouldShowComposeInput(true);
- }, [route, report, fetchReportIfNeeded, prevReport.reportID, prevUserLeavingStatus, userLeavingStatus, prevReport.statusNum, prevReport.parentReportID, prevReport.chatType, prevReport]);
+ }, [
+ route,
+ report,
+ // errors,
+ fetchReportIfNeeded,
+ prevReport.reportID,
+ prevUserLeavingStatus,
+ userLeavingStatus,
+ prevReport.statusNum,
+ prevReport.parentReportID,
+ prevReport.chatType,
+ prevReport,
+ reportIDFromRoute,
+ ]);
useEffect(() => {
- if (!ReportUtils.isValidReportIDFromPath(reportID)) {
+ if (!ReportUtils.isValidReportIDFromPath(reportIDFromRoute)) {
return;
}
// Ensures subscription event succeeds when the report/workspace room is created optimistically.
@@ -455,7 +498,7 @@ function ReportScreen({
let interactionTask: ReturnType | null = null;
if (!didSubscribeToReportLeavingEvents.current && didCreateReportSuccessfully) {
interactionTask = InteractionManager.runAfterInteractions(() => {
- Report.subscribeToReportLeavingEvents(reportID);
+ Report.subscribeToReportLeavingEvents(reportIDFromRoute);
didSubscribeToReportLeavingEvents.current = true;
});
}
@@ -465,7 +508,7 @@ function ReportScreen({
}
interactionTask.cancel();
};
- }, [report, didSubscribeToReportLeavingEvents, reportID]);
+ }, [report, didSubscribeToReportLeavingEvents, reportIDFromRoute]);
const onListLayout = useCallback((event: LayoutChangeEvent) => {
setListHeight((prev) => event.nativeEvent?.layout?.height ?? prev);
@@ -477,7 +520,6 @@ function ReportScreen({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const reportIDFromParams = route.params.reportID;
// eslint-disable-next-line rulesdir/no-negated-variables
const shouldShowNotFoundPage = useMemo(
(): boolean =>
@@ -489,12 +531,46 @@ function ReportScreen({
!isLoading &&
!userLeavingStatus) ||
shouldHideReport ||
- (!!reportIDFromParams && !ReportUtils.isValidReportIDFromPath(reportIDFromParams)),
- [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus, reportIDFromParams],
+ (!!reportIDFromRoute && !ReportUtils.isValidReportIDFromPath(reportIDFromRoute)),
+ [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus, reportIDFromRoute],
);
const actionListValue = useMemo((): ActionListContextType => ({flatListRef, scrollPosition, setScrollPosition}), [flatListRef, scrollPosition, setScrollPosition]);
+ // This helps in tracking from the moment 'route' triggers useMemo until isLoadingInitialReportActions becomes true. It prevents blinking when loading reportActions from cache.
+ useEffect(() => {
+ InteractionManager.runAfterInteractions(() => {
+ setLinkingToMessage(false);
+ });
+ }, [reportMetadata?.isLoadingInitialReportActions]);
+
+ const onLinkPress = () => {
+ Navigation.setParams({reportActionID: ''});
+ fetchReport();
+ };
+
+ const isLinkedReportActionDeleted = useMemo(() => {
+ if (!reportActionIDFromRoute || !sortedAllReportActions) {
+ return false;
+ }
+ const action = sortedAllReportActions.find((item) => item.reportActionID === reportActionIDFromRoute);
+ return action && ReportActionsUtils.isDeletedAction(action);
+ }, [reportActionIDFromRoute, sortedAllReportActions]);
+
+ if (isLinkedReportActionDeleted ?? (!shouldShowSkeleton && reportActionIDFromRoute && reportActions?.length === 0 && !isLinkingToMessage)) {
+ return (
+
+ );
+ }
+
return (
@@ -543,7 +619,7 @@ function ReportScreen({
style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]}
onLayout={onListLayout}
>
- {isReportReadyForDisplay && !isLoadingInitialReportActions && !isLoading && (
+ {shouldShowReportActionList && (
)}
{/* Note: The ReportActionsSkeletonView should be allowed to mount even if the initial report actions are not loaded.
- If we prevent rendering the report while they are loading then
- we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */}
- {(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && }
+ If we prevent rendering the report while they are loading then
+ we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */}
+ {shouldShowSkeleton && }
{isReportReadyForDisplay ? (
`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`,
canEvict: false,
- selector: (reportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, true),
+ selector: (allReportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true),
},
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`,
@@ -642,7 +719,7 @@ export default withViewportOffsetTop(
ReportScreen,
(prevProps, nextProps) =>
prevProps.isSidebarLoaded === nextProps.isSidebarLoaded &&
- lodashIsEqual(prevProps.reportActions, nextProps.reportActions) &&
+ lodashIsEqual(prevProps.sortedAllReportActions, nextProps.sortedAllReportActions) &&
lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) &&
prevProps.isComposerFullSize === nextProps.isComposerFullSize &&
lodashIsEqual(prevProps.betas, nextProps.betas) &&
@@ -652,6 +729,7 @@ export default withViewportOffsetTop(
prevProps.currentReportID === nextProps.currentReportID &&
prevProps.viewportOffsetTop === nextProps.viewportOffsetTop &&
lodashIsEqual(prevProps.parentReportAction, nextProps.parentReportAction) &&
+ lodashIsEqual(prevProps.route, nextProps.route) &&
lodashIsEqual(prevProps.report, nextProps.report),
),
),
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index c5ab9bbff1f5..0ac639d5367c 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -377,7 +377,7 @@ const ContextMenuActions: ContextMenuAction[] = [
const mentionWhisperMessage = ReportActionsUtils.getActionableMentionWhisperMessage(reportAction);
setClipboardMessage(mentionWhisperMessage);
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) {
- Clipboard.setString(Localize.translateLocal('iou.heldRequest', {comment: reportAction.message?.[1]?.text ?? ''}));
+ Clipboard.setString(Localize.translateLocal('iou.heldRequest'));
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) {
Clipboard.setString(Localize.translateLocal('iou.unheldRequest'));
} else if (content) {
diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
index 793fbf9b1e7e..9bf32bb92b35 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
@@ -12,7 +12,6 @@ import calculateAnchorPosition from '@libs/calculateAnchorPosition';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as IOU from '@userActions/IOU';
import * as Report from '@userActions/Report';
-import CONST from '@src/CONST';
import type {ReportAction} from '@src/types/onyx';
import BaseReportActionContextMenu from './BaseReportActionContextMenu';
import type {ContextMenuAction} from './ContextMenuActions';
@@ -256,8 +255,12 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef {
callbackWhenDeleteModalHide.current = () => (onComfirmDeleteModal.current = runAndResetCallback(onComfirmDeleteModal.current));
const reportAction = reportActionRef.current;
- if (ReportActionsUtils.isMoneyRequestAction(reportAction) && reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) {
- IOU.deleteMoneyRequest(reportAction?.originalMessage?.IOUTransactionID ?? '', reportAction);
+ if (ReportActionsUtils.isMoneyRequestAction(reportAction)) {
+ if (ReportActionsUtils.isTrackExpenseAction(reportAction)) {
+ IOU.deleteTrackExpense(reportIDRef.current, reportAction?.originalMessage?.IOUTransactionID ?? '', reportAction);
+ } else {
+ IOU.deleteMoneyRequest(reportAction?.originalMessage?.IOUTransactionID ?? '', reportAction);
+ }
} else if (reportAction) {
Report.deleteReportComment(reportIDRef.current, reportAction);
}
diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
index 6a21845f47ad..95533db02f06 100644
--- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
+++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
@@ -13,6 +13,7 @@ import PopoverMenu from '@components/PopoverMenu';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Tooltip from '@components/Tooltip/PopoverAnchorTooltip';
import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -115,6 +116,7 @@ function AttachmentPickerWithMenuItems({
const styles = useThemeStyles();
const {translate} = useLocalize();
const {windowHeight} = useWindowDimensions();
+ const {canUseTrackExpense} = usePermissions();
/**
* Returns the list of IOU Options
@@ -136,12 +138,17 @@ function AttachmentPickerWithMenuItems({
text: translate('iou.sendMoney'),
onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report?.reportID ?? ''),
},
+ [CONST.IOU.TYPE.TRACK_EXPENSE]: {
+ icon: Expensicons.DocumentPlus,
+ text: translate('iou.trackExpense'),
+ onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK_EXPENSE, report?.reportID ?? ''),
+ },
};
- return ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs ?? []).map((option) => ({
+ return ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs ?? [], canUseTrackExpense).map((option) => ({
...options[option],
}));
- }, [report, policy, reportParticipantIDs, translate]);
+ }, [translate, report, policy, reportParticipantIDs, canUseTrackExpense]);
/**
* Determines if we can show the task option
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index f0280b27efa0..0e8b0cf97d1e 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -58,6 +58,7 @@ import * as ReportActions from '@userActions/ReportActions';
import * as Session from '@userActions/Session';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
@@ -97,9 +98,6 @@ type ReportActionItemOnyxProps = {
/** The user's wallet account */
userWallet: OnyxEntry;
- /** All policy report fields */
- policyReportFields: OnyxEntry;
-
/** The policy which the user has access to and which the report is tied to */
policy: OnyxEntry;
};
@@ -156,7 +154,6 @@ function ReportActionItem({
userWallet,
shouldHideThreadDividerLine = false,
shouldShowSubscriptAvatar = false,
- policyReportFields,
policy,
onPress = undefined,
}: ReportActionItemProps) {
@@ -414,7 +411,10 @@ function ReportActionItem({
isIOUReport(action) &&
action.originalMessage &&
// For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message
- (action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || isSendingMoney)
+ (action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE ||
+ action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT ||
+ action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ||
+ isSendingMoney)
) {
// There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID
const iouReportID = action.originalMessage.IOUReportID ? action.originalMessage.IOUReportID.toString() : '0';
@@ -517,7 +517,9 @@ function ReportActionItem({
// This handles all historical actions from OldDot that we just want to display the message text
children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) {
- children = ;
+ children = ;
+ } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLDCOMMENT) {
+ children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) {
children = ;
} else {
@@ -667,6 +669,14 @@ function ReportActionItem({
if (ReportActionsUtils.isTransactionThread(parentReportAction)) {
const isReversedTransaction = ReportActionsUtils.isReversedTransaction(parentReportAction);
if (ReportActionsUtils.isDeletedParentAction(parentReportAction) || isReversedTransaction) {
+ let message: TranslationPaths;
+ if (isReversedTransaction) {
+ message = 'parentReportAction.reversedTransaction';
+ } else if (ReportActionsUtils.isTrackExpenseAction(parentReportAction)) {
+ message = 'parentReportAction.deletedExpense';
+ } else {
+ message = 'parentReportAction.deletedRequest';
+ }
return (
@@ -677,9 +687,7 @@ function ReportActionItem({
showHeader
report={report}
>
- ${translate(isReversedTransaction ? 'parentReportAction.reversedTransaction' : 'parentReportAction.deletedRequest')}`}
- />
+ ${translate(message)}`} />
@@ -734,7 +742,6 @@ function ReportActionItem({
@@ -881,10 +888,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 +928,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/ReportAttachments.tsx b/src/pages/home/report/ReportAttachments.tsx
index 2c6ce7a2dabd..de145d5ef7e6 100644
--- a/src/pages/home/report/ReportAttachments.tsx
+++ b/src/pages/home/report/ReportAttachments.tsx
@@ -16,8 +16,7 @@ function ReportAttachments({route}: ReportAttachmentsProps) {
const report = ReportUtils.getReport(reportID);
// In native the imported images sources are of type number. Ref: https://reactnative.dev/docs/image#imagesource
- const decodedSource = decodeURI(route.params.source);
- const source = Number(decodedSource) || decodedSource;
+ const source = Number(route.params.source) || route.params.source;
const onCarouselAttachmentChange = useCallback(
(attachment: Attachment) => {
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..f661cee00b56 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,19 +189,34 @@ 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'),
onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.TEACHERS_UNITE)),
},
- ...(!props.isLoading && !Policy.hasActiveFreePolicy(props.allPolicies)
+ ...(!props.isLoading && !Policy.hasActiveChatEnabledPolicies(props.allPolicies)
? [
{
displayInDefaultIconColor: true,
diff --git a/src/pages/iou/HoldReasonPage.tsx b/src/pages/iou/HoldReasonPage.tsx
index 0d5c5b8a327b..2a5cba810759 100644
--- a/src/pages/iou/HoldReasonPage.tsx
+++ b/src/pages/iou/HoldReasonPage.tsx
@@ -8,6 +8,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
@@ -40,6 +41,7 @@ type HoldReasonPageProps = {
function HoldReasonPage({route}: HoldReasonPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const {inputCallbackRef} = useAutoFocusInput();
const {transactionID, reportID, backTo} = route.params;
@@ -110,7 +112,7 @@ function HoldReasonPage({route}: HoldReasonPageProps) {
defaultValue={undefined}
label={translate('iou.reason')}
accessibilityLabel={translate('iou.reason')}
- autoFocus
+ ref={inputCallbackRef}
/>
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/NewDistanceRequestPage.js b/src/pages/iou/NewDistanceRequestPage.js
deleted file mode 100644
index 750ac5d0141e..000000000000
--- a/src/pages/iou/NewDistanceRequestPage.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect} from 'react';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import DistanceRequest from '@components/DistanceRequest';
-import Navigation from '@libs/Navigation/Navigation';
-import reportPropTypes from '@pages/reportPropTypes';
-import * as IOU from '@userActions/IOU';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import {iouPropTypes} from './propTypes';
-
-const propTypes = {
- /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
- iou: iouPropTypes,
-
- /** The report on which the request is initiated on */
- report: reportPropTypes,
-
- /** Passed from the navigator */
- route: PropTypes.shape({
- /** Parameters the route gets */
- params: PropTypes.shape({
- /** Type of IOU */
- iouType: PropTypes.oneOf(_.values(CONST.IOU.TYPE)),
- /** Id of the report on which the distance request is being created */
- reportID: PropTypes.string,
- }),
- }),
-};
-
-const defaultProps = {
- iou: {},
- report: {},
- route: {
- params: {
- iouType: '',
- reportID: '',
- },
- },
-};
-
-// This component is responsible for getting the transactionID from the IOU key, or creating the transaction if it doesn't exist yet, and then passing the transactionID.
-// You can't use Onyx props in the withOnyx mapping, so we need to set up and access the transactionID here, and then pass it down so that DistanceRequest can subscribe to the transaction.
-function NewDistanceRequestPage({iou, report, route}) {
- const iouType = lodashGet(route, 'params.iouType', 'request');
- const isEditingNewRequest = Navigation.getActiveRoute().includes('address');
-
- useEffect(() => {
- if (iou.transactionID) {
- return;
- }
- IOU.setUpDistanceTransaction();
- }, [iou.transactionID]);
-
- const onSubmit = useCallback(() => {
- if (isEditingNewRequest) {
- Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID));
- return;
- }
- IOU.navigateToNextPage(iou, iouType, report);
- }, [iou, iouType, isEditingNewRequest, report]);
-
- return (
-
- );
-}
-
-NewDistanceRequestPage.displayName = 'NewDistanceRequestPage';
-NewDistanceRequestPage.propTypes = propTypes;
-NewDistanceRequestPage.defaultProps = defaultProps;
-export default withOnyx({
- iou: {key: ONYXKEYS.IOU},
- report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID')}`,
- },
-})(NewDistanceRequestPage);
diff --git a/src/pages/iou/request/IOURequestRedirectToStartPage.js b/src/pages/iou/request/IOURequestRedirectToStartPage.js
index ee98c8006cdb..2da235743705 100644
--- a/src/pages/iou/request/IOURequestRedirectToStartPage.js
+++ b/src/pages/iou/request/IOURequestRedirectToStartPage.js
@@ -41,11 +41,11 @@ function IOURequestRedirectToStartPage({
// Redirect the person to the right start page using a rendom reportID
const optimisticReportID = ReportUtils.generateReportID();
if (iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) {
- Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID));
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID));
} else if (iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL) {
- Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID));
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID));
} else if (iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN) {
- Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID));
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID));
}
// This useEffect should only run on mount which is why there are no dependencies being passed in the second parameter
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..435121a76028 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 participantAccountID = lodashGet(participant, 'accountID', 0);
+ return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant);
}),
[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/IOURequestStepDistance.js b/src/pages/iou/request/step/IOURequestStepDistance.js
index 9e12381bc497..dad610cbc636 100644
--- a/src/pages/iou/request/step/IOURequestStepDistance.js
+++ b/src/pages/iou/request/step/IOURequestStepDistance.js
@@ -1,6 +1,7 @@
import lodashGet from 'lodash/get';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import Button from '@components/Button';
import DistanceRequestFooter from '@components/DistanceRequest/DistanceRequestFooter';
@@ -22,6 +23,7 @@ import * as IOU from '@userActions/IOU';
import * as MapboxToken from '@userActions/MapboxToken';
import * as Transaction from '@userActions/Transaction';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import IOURequestStepRoutePropTypes from './IOURequestStepRoutePropTypes';
import StepScreenWrapper from './StepScreenWrapper';
@@ -38,19 +40,24 @@ const propTypes = {
/** The transaction object being modified in Onyx */
transaction: transactionPropTypes,
+
+ /** backup version of the original transaction */
+ transactionBackup: transactionPropTypes,
};
const defaultProps = {
report: {},
transaction: {},
+ transactionBackup: {},
};
function IOURequestStepDistance({
report,
route: {
- params: {iouType, reportID, transactionID, backTo},
+ params: {action, iouType, reportID, transactionID, backTo},
},
transaction,
+ transactionBackup,
}) {
const styles = useThemeStyles();
const {isOffline} = useNetwork();
@@ -76,6 +83,8 @@ function IOURequestStepDistance({
const nonEmptyWaypointsCount = useMemo(() => _.filter(_.keys(waypoints), (key) => !_.isEmpty(waypoints[key])).length, [waypoints]);
const duplicateWaypointsError = useMemo(() => nonEmptyWaypointsCount >= 2 && _.size(validatedWaypoints) !== nonEmptyWaypointsCount, [nonEmptyWaypointsCount, validatedWaypoints]);
const atLeastTwoDifferentWaypointsError = useMemo(() => _.size(validatedWaypoints) < 2, [validatedWaypoints]);
+ const isEditing = action === CONST.IOU.ACTION.EDIT;
+ const isCreatingNewRequest = Navigation.getActiveRoute().includes('start');
useEffect(() => {
MapboxToken.init();
@@ -86,8 +95,8 @@ function IOURequestStepDistance({
if (isOffline || !shouldFetchRoute) {
return;
}
- Transaction.getRouteForDraft(transactionID, validatedWaypoints);
- }, [shouldFetchRoute, transactionID, validatedWaypoints, isOffline]);
+ Transaction.getRoute(transactionID, validatedWaypoints, action === CONST.IOU.ACTION.CREATE);
+ }, [shouldFetchRoute, transactionID, validatedWaypoints, isOffline, action]);
useEffect(() => {
if (numberOfWaypoints <= numberOfPreviousWaypoints) {
@@ -112,9 +121,7 @@ function IOURequestStepDistance({
* @param {Number} index of the waypoint to edit
*/
const navigateToWaypointEditPage = (index) => {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.REQUEST, transactionID, report.reportID, index, Navigation.getActiveRouteWithoutParams()),
- );
+ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(action, CONST.IOU.TYPE.REQUEST, transactionID, report.reportID, index, Navigation.getActiveRouteWithoutParams()));
};
const navigateToNextStep = useCallback(() => {
@@ -168,11 +175,14 @@ function IOURequestStepDistance({
setOptimisticWaypoints(newWaypoints);
// eslint-disable-next-line rulesdir/no-thenable-actions-in-views
- Promise.all([Transaction.removeWaypoint(transaction, emptyWaypointIndex.toString(), true), Transaction.updateWaypoints(transactionID, newWaypoints, true)]).then(() => {
- setOptimisticWaypoints(null);
+ Promise.all([
+ Transaction.removeWaypoint(transaction, emptyWaypointIndex.toString(), action === CONST.IOU.ACTION.CREATE),
+ Transaction.updateWaypoints(transactionID, newWaypoints, action === CONST.IOU.ACTION.CREATE),
+ ]).then(() => {
+ setOptimisticWaypoints(undefined);
});
},
- [transactionID, transaction, waypoints, waypointsList],
+ [transactionID, transaction, waypoints, waypointsList, action],
);
const submitWaypoints = useCallback(() => {
@@ -181,15 +191,42 @@ function IOURequestStepDistance({
setShouldShowAtLeastTwoDifferentWaypointsError(true);
return;
}
+ if (isEditing) {
+ // If nothing was changed, simply go to transaction thread
+ // We compare only addresses because numbers are rounded while backup
+ const oldWaypoints = lodashGet(transactionBackup, 'comment.waypoints', {});
+ const oldAddresses = _.mapObject(oldWaypoints, (waypoint) => _.pick(waypoint, 'address'));
+ const addresses = _.mapObject(waypoints, (waypoint) => _.pick(waypoint, 'address'));
+ if (_.isEqual(oldAddresses, addresses)) {
+ Navigation.dismissModal(report.reportID);
+ return;
+ }
+ IOU.updateMoneyRequestDistance(transaction.transactionID, report.reportID, waypoints);
+ Navigation.dismissModal(report.reportID);
+ return;
+ }
+
navigateToNextStep();
- }, [atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, isLoadingRoute, isLoading, navigateToNextStep]);
+ }, [
+ duplicateWaypointsError,
+ atLeastTwoDifferentWaypointsError,
+ hasRouteError,
+ isLoadingRoute,
+ isLoading,
+ isEditing,
+ navigateToNextStep,
+ transactionBackup,
+ waypoints,
+ transaction.transactionID,
+ report.reportID,
+ ]);
return (
<>
@@ -237,7 +274,7 @@ function IOURequestStepDistance({
large
style={[styles.w100, styles.mb4, styles.ph4, styles.flexShrink0]}
onPress={submitWaypoints}
- text={translate('common.next')}
+ text={translate(!isCreatingNewRequest ? 'common.save' : 'common.next')}
isLoading={!isOffline && (isLoadingRoute || shouldFetchRoute || isLoading)}
/>
@@ -250,4 +287,12 @@ IOURequestStepDistance.displayName = 'IOURequestStepDistance';
IOURequestStepDistance.propTypes = propTypes;
IOURequestStepDistance.defaultProps = defaultProps;
-export default compose(withWritableReportOrNotFound, withFullTransactionOrNotFound)(IOURequestStepDistance);
+export default compose(
+ withWritableReportOrNotFound,
+ withFullTransactionOrNotFound,
+ withOnyx({
+ transactionBackup: {
+ key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${props.transactionID}`,
+ },
+ }),
+)(IOURequestStepDistance);
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..4e61ac944aac 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: {
@@ -117,7 +107,7 @@ function IOURequestStepWaypoint({
const waypointValue = values[`waypoint${pageIndex}`] ?? '';
// Allows letting you set a waypoint to an empty value
if (waypointValue === '') {
- Transaction.removeWaypoint(transaction, pageIndex, true);
+ Transaction.removeWaypoint(transaction, pageIndex, action === CONST.IOU.ACTION.CREATE);
}
// While the user is offline, the auto-complete address search will not work
@@ -133,13 +123,13 @@ function IOURequestStepWaypoint({
}
// Other flows will be handled by selecting a waypoint with selectWaypoint as this is mainly for the offline flow
- Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(iouType, transactionID, reportID));
+ Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID));
};
const deleteStopAndHideModal = () => {
- Transaction.removeWaypoint(transaction, pageIndex, true);
+ Transaction.removeWaypoint(transaction, pageIndex, action === CONST.IOU.ACTION.CREATE);
setIsDeleteStopModalOpen(false);
- Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(iouType, transactionID, reportID));
+ Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID));
};
const selectWaypoint = (values: Waypoint) => {
@@ -155,7 +145,7 @@ function IOURequestStepWaypoint({
Navigation.goBack(backTo);
return;
}
- Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(iouType, transactionID, reportID));
+ Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID));
};
return (
@@ -170,7 +160,7 @@ function IOURequestStepWaypoint({
title={translate(waypointDescriptionKey)}
shouldShowBackButton
onBackButtonPress={() => {
- Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(iouType, transactionID, reportID));
+ Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID));
}}
shouldShowThreeDotsButton={shouldShowThreeDotsButton}
shouldSetModalVisibility={false}
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/settings/ExitSurvey/ExitSurveyResponsePage.tsx b/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx
index 724c665b131a..badbdeb86d14 100644
--- a/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx
+++ b/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx
@@ -8,6 +8,7 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useKeyboardState from '@hooks/useKeyboardState';
import useLocalize from '@hooks/useLocalize';
@@ -43,6 +44,7 @@ function ExitSurveyResponsePage({draftResponse, route, navigation}: ExitSurveyRe
const {keyboardHeight} = useKeyboardState();
const {windowHeight} = useWindowDimensions();
const {top: safeAreaInsetsTop} = useSafeAreaInsets();
+ const {inputCallbackRef} = useAutoFocusInput();
const {reason, backTo} = route.params;
const {isOffline} = useNetwork({
@@ -132,6 +134,7 @@ function ExitSurveyResponsePage({draftResponse, route, navigation}: ExitSurveyRe
return;
}
updateMultilineInputRange(el);
+ inputCallbackRef(el);
}}
containerStyles={[baseResponseInputContainerStyle, StyleUtils.getMaximumHeight(responseInputMaxHeight)]}
shouldSaveDraft
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 ? (
Navigation.navigate(ROUTES.WORKSPACE_TAX_CREATE.getRoute(route.params.policyID))}
+ onPress={() => Navigation.navigate(ROUTES.WORKSPACE_TAX_CREATE.getRoute(policyID))}
icon={Expensicons.Plus}
text={translate('workspace.taxes.addRate')}
style={[styles.mr3, isSmallScreenWidth && styles.w50]}
/>
Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(route.params.policyID))}
+ onPress={() => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID))}
icon={Expensicons.Gear}
text={translate('common.settings')}
style={[isSmallScreenWidth && styles.w50]}
/>
+ ) : (
+
+ 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..8fbfa7b79292 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';
@@ -37,13 +39,13 @@ function WorkspaceTaxesSettingsPage({
pendingAction: policy?.taxRates?.pendingFields?.name,
},
{
- title: policy?.taxRates?.taxes[policy?.taxRates?.defaultExternalID]?.name,
+ title: policy?.taxRates?.taxes?.[policy?.taxRates?.defaultExternalID]?.name,
description: translate('workspace.taxes.workspaceDefault'),
action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.getRoute(policyID)),
pendingAction: policy?.taxRates?.pendingFields?.defaultExternalID,
},
{
- title: policy?.taxRates?.taxes[policy?.taxRates?.foreignTaxDefault]?.name,
+ title: policy?.taxRates?.taxes?.[policy?.taxRates?.foreignTaxDefault]?.name,
description: translate('workspace.taxes.foreignDefault'),
action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT.getRoute(policyID)),
pendingAction: policy?.taxRates?.pendingFields?.foreignTaxDefault,
@@ -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]}
+ >
-
-
-
- item.text}
- />
-
-
+
+
+ Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING_FREQUENCY)}
+ >
+ item.text}
+ />
+
+
+
+
);
}
diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx
index 84d70e799c42..204857fccb18 100644
--- a/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx
+++ b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx
@@ -1,3 +1,4 @@
+import type {StackScreenProps} from '@react-navigation/stack';
import React, {useState} from 'react';
import type {ValueOf} from 'type-fest';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
@@ -7,16 +8,20 @@ import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
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 type SCREENS from '@src/SCREENS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
const DAYS_OF_MONTH = 28;
-type WorkspaceAutoReportingMonthlyOffsetProps = WithPolicyOnyxProps;
+type WorkspaceAutoReportingMonthlyOffsetProps = WithPolicyOnyxProps &
+ StackScreenProps;
type AutoReportingOffsetKeys = ValueOf;
@@ -27,7 +32,7 @@ type WorkspaceAutoReportingMonthlyOffsetPageItem = {
isNumber?: boolean;
};
-function WorkspaceAutoReportingMonthlyOffsetPage({policy}: WorkspaceAutoReportingMonthlyOffsetProps) {
+function WorkspaceAutoReportingMonthlyOffsetPage({policy, route}: WorkspaceAutoReportingMonthlyOffsetProps) {
const {translate, toLocaleOrdinal} = useLocalize();
const offset = policy?.autoReportingOffset ?? 0;
const [searchText, setSearchText] = useState('');
@@ -67,34 +72,39 @@ function WorkspaceAutoReportingMonthlyOffsetPage({policy}: WorkspaceAutoReportin
};
return (
-
-
-
+
+
-
-
-
+
+
+
+
);
}
diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx
index 52406a8033d2..e8913aafe972 100644
--- a/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx
+++ b/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx
@@ -1,3 +1,4 @@
+import type {StackScreenProps} from '@react-navigation/stack';
import React, {useCallback, useMemo, useState} from 'react';
import type {SectionListData} from 'react-native';
import {withOnyx} from 'react-native-onyx';
@@ -17,15 +18,18 @@ import compose from '@libs/compose';
import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
+import type {WorkspacesCentralPaneNavigatorParamList} from '@libs/Navigation/types';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as UserUtils from '@libs/UserUtils';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
import type {PersonalDetailsList, PolicyMember} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
@@ -34,11 +38,13 @@ type WorkspaceWorkflowsApproverPageOnyxProps = {
personalDetails: OnyxEntry;
};
-type WorkspaceWorkflowsApproverPageProps = WorkspaceWorkflowsApproverPageOnyxProps & WithPolicyAndFullscreenLoadingProps;
+type WorkspaceWorkflowsApproverPageProps = WorkspaceWorkflowsApproverPageOnyxProps &
+ WithPolicyAndFullscreenLoadingProps &
+ StackScreenProps;
type MemberOption = Omit & {accountID: number};
type MembersSection = SectionListData>;
-function WorkspaceWorkflowsApproverPage({policy, policyMembers, personalDetails, isLoadingReportData = true}: WorkspaceWorkflowsApproverPageProps) {
+function WorkspaceWorkflowsApproverPage({policy, policyMembers, personalDetails, isLoadingReportData = true, route}: WorkspaceWorkflowsApproverPageProps) {
const {translate} = useLocalize();
const policyName = policy?.name ?? '';
const [searchTerm, setSearchTerm] = useState('');
@@ -161,33 +167,38 @@ function WorkspaceWorkflowsApproverPage({policy, policyMembers, personalDetails,
};
return (
-
-
-
-
-
-
+
+
+
+
+
+
);
}
diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
index af91eeb3aef6..5bcb631c21b0 100644
--- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
+++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
@@ -1,8 +1,9 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useCallback, useEffect, useMemo} from 'react';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {FlatList, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
+import ConfirmModal from '@components/ConfirmModal';
import * as Illustrations from '@components/Icon/Illustrations';
import MenuItem from '@components/MenuItem';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -19,6 +20,7 @@ import Permissions from '@libs/Permissions';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types';
+import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper';
import type {WithPolicyProps} from '@pages/workspace/withPolicy';
import withPolicy from '@pages/workspace/withPolicy';
import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections';
@@ -53,6 +55,7 @@ function WorkspaceWorkflowsPage({policy, betas, route, reimbursementAccount, ses
const policyApproverName = useMemo(() => PersonalDetailsUtils.getPersonalDetailByEmail(policyApproverEmail ?? '')?.displayName ?? policyApproverEmail, [policyApproverEmail]);
const containerStyle = useMemo(() => [styles.ph8, styles.mhn8, styles.ml11, styles.pv3, styles.pr0, styles.pl4, styles.mr0, styles.widthAuto, styles.mt4], [styles]);
const canUseDelayedSubmission = Permissions.canUseWorkflowsDelayedSubmission(betas);
+ const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false);
const displayNameForAuthorizedPayer = useMemo(() => {
const personalDetails = PersonalDetailsUtils.getPersonalDetailsByIDs([policy?.reimburserAccountID ?? 0], session?.accountID ?? 0);
@@ -66,6 +69,15 @@ function WorkspaceWorkflowsPage({policy, betas, route, reimbursementAccount, ses
Policy.openPolicyWorkflowsPage(policy?.id ?? route.params.policyID);
};
+ const confirmCurrencyChangeAndHideModal = useCallback(() => {
+ if (!policy) {
+ return;
+ }
+ Policy.updateGeneralSettings(policy.id, policy.name, CONST.CURRENCY.USD);
+ setIsCurrencyModalOpen(false);
+ navigateToBankAccountRoute(route.params.policyID, ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID));
+ }, [policy, route.params.policyID]);
+
useNetwork({onReconnect: fetchData});
useEffect(() => {
@@ -78,6 +90,8 @@ function WorkspaceWorkflowsPage({policy, betas, route, reimbursementAccount, ses
const hasVBA = state === BankAccount.STATE.OPEN;
const bankDisplayName = bankName ? `${bankName} ${accountNumber ? `${accountNumber.slice(-5)}` : ''}` : '';
const hasReimburserEmailError = !!policy?.errorFields?.reimburserEmail;
+ const hasApprovalError = !!policy?.errorFields?.approvalMode;
+ const hasDelayedSubmissionError = !!policy?.errorFields?.autoReporting;
return [
...(canUseDelayedSubmission
@@ -108,10 +122,13 @@ function WorkspaceWorkflowsPage({policy, betas, route, reimbursementAccount, ses
shouldShowRightIcon
wrapperStyle={containerStyle}
hoverAndPressStyle={[styles.mr0, styles.br2]}
+ brickRoadIndicator={hasDelayedSubmissionError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
/>
),
- isActive: (policy?.harvesting?.enabled && policy.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT) ?? false,
+ isActive: (policy?.harvesting?.enabled && policy.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT && !hasDelayedSubmissionError) ?? false,
pendingAction: policy?.pendingFields?.autoReporting,
+ errors: ErrorUtils.getLatestErrorField(policy ?? {}, CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING),
+ onCloseError: () => Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING),
},
]
: []),
@@ -132,18 +149,28 @@ function WorkspaceWorkflowsPage({policy, betas, route, reimbursementAccount, ses
shouldShowRightIcon
wrapperStyle={containerStyle}
hoverAndPressStyle={[styles.mr0, styles.br2]}
+ brickRoadIndicator={hasApprovalError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
/>
),
- isActive: policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.BASIC,
+ isActive: (policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.BASIC && !hasApprovalError) ?? false,
pendingAction: policy?.pendingFields?.approvalMode,
+ errors: ErrorUtils.getLatestErrorField(policy ?? {}, CONST.POLICY.COLLECTION_KEYS.APPROVAL_MODE),
+ onCloseError: () => Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.APPROVAL_MODE),
},
{
icon: Illustrations.WalletAlt,
title: translate('workflowsPage.makeOrTrackPaymentsTitle'),
subtitle: translate('workflowsPage.makeOrTrackPaymentsDescription'),
- onToggle: () => {
- const isActive = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES;
- const newReimbursementChoice = isActive ? CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL : CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES;
+ onToggle: (isEnabled: boolean) => {
+ let newReimbursementChoice;
+ if (!isEnabled) {
+ newReimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO;
+ } else if (hasVBA && !Policy.isCurrencySupportedForDirectReimbursement(policy?.outputCurrency ?? '')) {
+ newReimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL;
+ } else {
+ newReimbursementChoice = hasVBA ? CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES : CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL;
+ }
+
const newReimburserAccountID =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
PersonalDetailsUtils.getPersonalDetailByEmail(policy?.reimburserEmail ?? '')?.accountID || policy?.reimburserAccountID || policy?.ownerAccountID;
@@ -153,20 +180,30 @@ function WorkspaceWorkflowsPage({policy, betas, route, reimbursementAccount, ses
subMenuItems: (
<>
navigateToBankAccountRoute(route.params.policyID, ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID))}
+ onPress={() => {
+ if (!Policy.isCurrencySupportedForDirectReimbursement(policy?.outputCurrency ?? '')) {
+ setIsCurrencyModalOpen(true);
+ return;
+ }
+ navigateToBankAccountRoute(route.params.policyID, ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID));
+ }}
shouldShowRightIcon
wrapperStyle={containerStyle}
hoverAndPressStyle={[styles.mr0, styles.br2]}
/>
- {hasVBA && (
+ {hasVBA && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && (
Policy.clearWorkspacePayerError(policy?.id ?? '')}
+ errors={ErrorUtils.getLatestErrorField(policy ?? {}, CONST.POLICY.COLLECTION_KEYS.REIMBURSER_EMAIL)}
+ onClose={() => Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.REIMBURSER_EMAIL)}
errorRowStyles={[styles.ml7]}
>
),
isEndOptionRow: true,
- isActive: policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES,
+ isActive: policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO,
pendingAction: policy?.pendingFields?.reimbursementChoice,
- errors: ErrorUtils.getLatestErrorField(policy ?? {}, 'reimbursementChoice'),
- onCloseError: () => Policy.clearWorkspaceReimbursementErrors(policy?.id ?? ''),
+ errors: ErrorUtils.getLatestErrorField(policy ?? {}, CONST.POLICY.COLLECTION_KEYS.REIMBURSEMENT_CHOICE),
+ onCloseError: () => Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.REIMBURSEMENT_CHOICE),
},
];
}, [
@@ -227,33 +264,48 @@ function WorkspaceWorkflowsPage({policy, betas, route, reimbursementAccount, ses
const isLoading = reimbursementAccount?.isLoading ?? true;
return (
-
-
-
-
- {translate('workflowsPage.workflowDescription')}
- item.title}
- />
-
-
-
-
+
+
+
+
+ {translate('workflowsPage.workflowDescription')}
+ item.title}
+ />
+ setIsCurrencyModalOpen(false)}
+ prompt={translate('workspace.bankAccount.updateCurrencyPrompt')}
+ confirmText={translate('workspace.bankAccount.updateToUSD')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
+
+
+
+
+
);
}
diff --git a/src/setup/addUtilsToWindow.ts b/src/setup/addUtilsToWindow.ts
index 9991a3dc07cd..d2d11e138431 100644
--- a/src/setup/addUtilsToWindow.ts
+++ b/src/setup/addUtilsToWindow.ts
@@ -1,5 +1,6 @@
import Onyx from 'react-native-onyx';
import * as Environment from '@libs/Environment/Environment';
+import markAllPolicyReportsAsRead from '@libs/markAllPolicyReportsAsRead';
import * as Session from '@userActions/Session';
/**
@@ -44,5 +45,9 @@ export default function addUtilsToWindow() {
};
window.setSupportToken = Session.setSupportAuthToken;
+
+ // Workaround to give employees the ability to mark reports as read via the JS console
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any).markAllPolicyReportsAsRead = markAllPolicyReportsAsRead;
});
}
diff --git a/src/stories/CheckboxWithLabel.stories.js b/src/stories/CheckboxWithLabel.stories.tsx
similarity index 71%
rename from src/stories/CheckboxWithLabel.stories.js
rename to src/stories/CheckboxWithLabel.stories.tsx
index f978856aaefb..8d3c1610e500 100644
--- a/src/stories/CheckboxWithLabel.stories.js
+++ b/src/stories/CheckboxWithLabel.stories.tsx
@@ -1,29 +1,33 @@
+import type {ComponentMeta, ComponentStory} from '@storybook/react';
import React from 'react';
import CheckboxWithLabel from '@components/CheckboxWithLabel';
+import type {CheckboxWithLabelProps} from '@components/CheckboxWithLabel';
import Text from '@components/Text';
// eslint-disable-next-line no-restricted-imports
import {defaultStyles} from '@styles/index';
+type CheckboxWithLabelStory = ComponentStory;
+
/**
* We use the Component Story Format for writing stories. Follow the docs here:
*
* https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
*/
-const story = {
+const story: ComponentMeta = {
title: 'Components/CheckboxWithLabel',
component: CheckboxWithLabel,
};
-function Template(args) {
+function Template(props: CheckboxWithLabelProps) {
// eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+ return ;
}
// Arguments can be passed to the component by binding
// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
-const Default = Template.bind({});
-const WithLabelComponent = Template.bind({});
-const WithErrors = Template.bind({});
+const Default: CheckboxWithLabelStory = Template.bind({});
+const WithLabelComponent: CheckboxWithLabelStory = Template.bind({});
+const WithErrors: CheckboxWithLabelStory = Template.bind({});
Default.args = {
isChecked: true,
label: 'Plain text label',
@@ -44,7 +48,6 @@ WithLabelComponent.args = {
WithErrors.args = {
isChecked: false,
- hasError: true,
errorText: 'Please accept Terms before continuing.',
onInputChange: () => {},
label: 'I accept the Terms & Conditions',
diff --git a/src/stories/MenuItem.stories.js b/src/stories/MenuItem.stories.tsx
similarity index 77%
rename from src/stories/MenuItem.stories.js
rename to src/stories/MenuItem.stories.tsx
index 0e7260fa4d1a..da486656cddf 100644
--- a/src/stories/MenuItem.stories.js
+++ b/src/stories/MenuItem.stories.tsx
@@ -1,26 +1,30 @@
+import type {ComponentMeta, ComponentStory} from '@storybook/react';
import React from 'react';
import Chase from '@assets/images/bankicons/chase.svg';
import MenuItem from '@components/MenuItem';
+import type {MenuItemProps} from '@components/MenuItem';
import variables from '@styles/variables';
+type MenuItemStory = ComponentStory;
+
/**
* We use the Component Story Format for writing stories. Follow the docs here:
*
* https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
*/
-const story = {
+const story: ComponentMeta = {
title: 'Components/MenuItem',
component: MenuItem,
};
-function Template(args) {
+function Template(props: MenuItemProps) {
// eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+ return ;
}
// Arguments can be passed to the component by binding
// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
-const Default = Template.bind({});
+const Default: MenuItemStory = Template.bind({});
Default.args = {
title: 'Alberta Bobbeth Charleson',
icon: Chase,
@@ -28,7 +32,7 @@ Default.args = {
iconWidth: variables.iconSizeExtraLarge,
};
-const Description = Template.bind({});
+const Description: MenuItemStory = Template.bind({});
Description.args = {
title: 'Alberta Bobbeth Charleson',
description: 'Account ending in 1111',
@@ -37,7 +41,7 @@ Description.args = {
iconWidth: variables.iconSizeExtraLarge,
};
-const RightIcon = Template.bind({});
+const RightIcon: MenuItemStory = Template.bind({});
RightIcon.args = {
title: 'Alberta Bobbeth Charleson',
icon: Chase,
@@ -46,7 +50,7 @@ RightIcon.args = {
shouldShowRightIcon: true,
};
-const RightIconAndDescription = Template.bind({});
+const RightIconAndDescription: MenuItemStory = Template.bind({});
RightIconAndDescription.args = {
title: 'Alberta Bobbeth Charleson',
description: 'Account ending in 1111',
@@ -56,7 +60,7 @@ RightIconAndDescription.args = {
shouldShowRightIcon: true,
};
-const RightIconAndDescriptionWithLabel = Template.bind({});
+const RightIconAndDescriptionWithLabel: MenuItemStory = Template.bind({});
RightIconAndDescriptionWithLabel.args = {
label: 'Account number',
title: 'Alberta Bobbeth Charleson',
@@ -67,7 +71,7 @@ RightIconAndDescriptionWithLabel.args = {
shouldShowRightIcon: true,
};
-const Selected = Template.bind({});
+const Selected: MenuItemStory = Template.bind({});
Selected.args = {
title: 'Alberta Bobbeth Charleson',
description: 'Account ending in 1111',
@@ -78,7 +82,7 @@ Selected.args = {
isSelected: true,
};
-const BadgeText = Template.bind({});
+const BadgeText: MenuItemStory = Template.bind({});
BadgeText.args = {
title: 'Alberta Bobbeth Charleson',
icon: Chase,
@@ -88,7 +92,7 @@ BadgeText.args = {
badgeText: '$0.00',
};
-const Focused = Template.bind({});
+const Focused: MenuItemStory = Template.bind({});
Focused.args = {
title: 'Alberta Bobbeth Charleson',
icon: Chase,
@@ -98,7 +102,7 @@ Focused.args = {
focused: true,
};
-const Disabled = Template.bind({});
+const Disabled: MenuItemStory = Template.bind({});
Disabled.args = {
title: 'Alberta Bobbeth Charleson',
icon: Chase,
@@ -108,17 +112,17 @@ Disabled.args = {
disabled: true,
};
-const BrickRoadIndicatorSuccess = Template.bind({});
-BrickRoadIndicatorSuccess.args = {
+const BrickRoadIndicatorInfo: MenuItemStory = Template.bind({});
+BrickRoadIndicatorInfo.args = {
title: 'Alberta Bobbeth Charleson',
icon: Chase,
iconHeight: variables.iconSizeExtraLarge,
iconWidth: variables.iconSizeExtraLarge,
shouldShowRightIcon: true,
- brickRoadIndicator: 'success',
+ brickRoadIndicator: 'info',
};
-const BrickRoadIndicatorFailure = Template.bind({});
+const BrickRoadIndicatorFailure: MenuItemStory = Template.bind({});
BrickRoadIndicatorFailure.args = {
title: 'Alberta Bobbeth Charleson',
icon: Chase,
@@ -128,7 +132,7 @@ BrickRoadIndicatorFailure.args = {
brickRoadIndicator: 'error',
};
-const ErrorMessage = Template.bind({});
+const ErrorMessage: MenuItemStory = Template.bind({});
ErrorMessage.args = {
title: 'Alberta Bobbeth Charleson',
icon: Chase,
@@ -149,7 +153,7 @@ export {
BadgeText,
Focused,
Disabled,
- BrickRoadIndicatorSuccess,
+ BrickRoadIndicatorInfo,
BrickRoadIndicatorFailure,
RightIconAndDescriptionWithLabel,
ErrorMessage,
diff --git a/src/stories/OptionRow.stories.js b/src/stories/OptionRow.stories.tsx
similarity index 91%
rename from src/stories/OptionRow.stories.js
rename to src/stories/OptionRow.stories.tsx
index 3096940dda5f..ea83816ab340 100644
--- a/src/stories/OptionRow.stories.js
+++ b/src/stories/OptionRow.stories.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import * as Expensicons from '@components/Icon/Expensicons';
import OnyxProvider from '@components/OnyxProvider';
import OptionRow from '@components/OptionRow';
+import type {OptionRowProps} from '@components/OptionRow';
/* eslint-disable react/jsx-props-no-spreading */
@@ -42,10 +43,10 @@ export default {
},
};
-function Template(args) {
+function Template(props: OptionRowProps) {
return (
-
+
);
}
diff --git a/src/stories/PopoverMenu.stories.js b/src/stories/PopoverMenu.stories.tsx
similarity index 78%
rename from src/stories/PopoverMenu.stories.js
rename to src/stories/PopoverMenu.stories.tsx
index c03a554741f1..8396a0ea15b5 100644
--- a/src/stories/PopoverMenu.stories.js
+++ b/src/stories/PopoverMenu.stories.tsx
@@ -1,36 +1,40 @@
+import type {ComponentMeta, ComponentStory} from '@storybook/react';
import React from 'react';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
import PopoverMenu from '@components/PopoverMenu';
+import type {PopoverMenuProps} from '@components/PopoverMenu';
// eslint-disable-next-line no-restricted-imports
import themeColors from '@styles/theme/themes/dark';
+type PopoverMenuStory = ComponentStory;
+
/**
* We use the Component Story Format for writing stories. Follow the docs here:
*
* https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
*/
-const story = {
+const story: ComponentMeta = {
title: 'Components/PopoverMenu',
component: PopoverMenu,
};
-function Template(args) {
+function Template(props: PopoverMenuProps) {
const [isVisible, setIsVisible] = React.useState(false);
const toggleVisibility = () => setIsVisible(!isVisible);
return (
<>
;
+
/**
* We use the Component Story Format for writing stories. Follow the docs here:
*
@@ -23,27 +27,27 @@ export default {
},
};
-function Template(args) {
+function Template(props: SubscriptAvatarProps) {
// eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+ return ;
}
// Arguments can be passed to the component by binding
// See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
-const Default = Template.bind({});
+const Default: SubscriptAvatarStory = Template.bind({});
-const AvatarURLStory = Template.bind({});
+const AvatarURLStory: SubscriptAvatarStory = Template.bind({});
AvatarURLStory.args = {
mainAvatar: {source: defaultAvatars.Avatar1, name: '', type: CONST.ICON_TYPE_AVATAR},
secondaryAvatar: {source: defaultAvatars.Avatar3, name: '', type: CONST.ICON_TYPE_AVATAR},
};
-const SubscriptIcon = Template.bind({});
+const SubscriptIcon: SubscriptAvatarStory = Template.bind({});
SubscriptIcon.args = {
subscriptIcon: {source: Expensicons.DownArrow, width: 8, height: 8},
};
-const WorkspaceSubscriptIcon = Template.bind({});
+const WorkspaceSubscriptIcon: SubscriptAvatarStory = Template.bind({});
WorkspaceSubscriptIcon.args = {
mainAvatar: {source: defaultAvatars.Avatar1, name: '', type: CONST.ICON_TYPE_WORKSPACE},
subscriptIcon: {source: Expensicons.DownArrow, width: 8, height: 8},
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 8a91291a0c71..b0c88d151484 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -286,6 +286,10 @@ const styles = (theme: ThemeColors) =>
...wordBreak.breakWord,
...spacing.pr4,
},
+ emojiTooltipWrapper: {
+ ...spacing.p2,
+ borderRadius: 8,
+ },
mentionSuggestionsAvatarContainer: {
width: 24,
@@ -1265,6 +1269,13 @@ const styles = (theme: ThemeColors) =>
color: theme.textSupporting,
},
+ textLabelSupportingEmptyValue: {
+ fontFamily: FontUtils.fontFamily.platform.EXP_NEUE,
+ fontSize: variables.fontSizeNormal,
+ fontWeight: FontUtils.fontWeight.normal,
+ color: theme.textSupporting,
+ },
+
textLabelSupportingNormal: {
fontFamily: FontUtils.fontFamily.platform.EXP_NEUE,
fontSize: variables.fontSizeLabel,
@@ -1443,9 +1454,9 @@ const styles = (theme: ThemeColors) =>
},
sidebarAvatar: {
- borderRadius: 28,
- height: 28,
- width: 28,
+ borderRadius: variables.sidebarAvatarSize,
+ height: variables.sidebarAvatarSize,
+ width: variables.sidebarAvatarSize,
},
selectedAvatarBorder: {
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 9a25313837fe..4c66967f52b9 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1541,7 +1541,6 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
getTestToolsModalStyle: (windowWidth: number): ViewStyle[] => [styles.settingsPageBody, styles.p5, {width: windowWidth * 0.9}],
getMultiselectListStyles: (isSelected: boolean, isDisabled: boolean): ViewStyle => ({
- ...styles.mr3,
...(isSelected && styles.checkedContainer),
...(isSelected && styles.borderColorFocus),
...(isDisabled && styles.cursorDisabled),
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 0ebc4e60991f..d911a2fc4b0e 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -83,6 +83,7 @@ export default {
iconSizeSuperLarge: 60,
iconSizeUltraLarge: 120,
iconBottomBar: 24,
+ sidebarAvatarSize: 28,
iconHeader: 48,
emojiSize: 20,
emojiLineHeight: 28,
diff --git a/src/types/form/PolicyDistanceRateEditForm.ts b/src/types/form/PolicyDistanceRateEditForm.ts
new file mode 100644
index 000000000000..2c7cb97b08d8
--- /dev/null
+++ b/src/types/form/PolicyDistanceRateEditForm.ts
@@ -0,0 +1,18 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ RATE: 'rate',
+} as const;
+
+type InputID = ValueOf;
+
+type PolicyDistanceRateEditForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.RATE]: string;
+ }
+>;
+
+export type {PolicyDistanceRateEditForm};
+export default INPUT_IDS;
diff --git a/src/types/form/WorkspaceTaxNameForm.ts b/src/types/form/WorkspaceTaxNameForm.ts
new file mode 100644
index 000000000000..dfe01ab55fae
--- /dev/null
+++ b/src/types/form/WorkspaceTaxNameForm.ts
@@ -0,0 +1,18 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ NAME: 'name',
+} as const;
+
+type InputID = ValueOf;
+
+type WorkspaceTaxNameForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.NAME]: string;
+ }
+>;
+
+export type {WorkspaceTaxNameForm};
+export default INPUT_IDS;
diff --git a/src/types/form/WorkspaceTaxValueForm.ts b/src/types/form/WorkspaceTaxValueForm.ts
new file mode 100644
index 000000000000..e53c6cd46cc2
--- /dev/null
+++ b/src/types/form/WorkspaceTaxValueForm.ts
@@ -0,0 +1,18 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ VALUE: 'value',
+} as const;
+
+type InputID = ValueOf;
+
+type WorkspaceTaxValueForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.VALUE]: string;
+ }
+>;
+
+export type {WorkspaceTaxValueForm};
+export default INPUT_IDS;
diff --git a/src/types/form/index.ts b/src/types/form/index.ts
index 50e5efb9716c..1f305769cec7 100644
--- a/src/types/form/index.ts
+++ b/src/types/form/index.ts
@@ -42,6 +42,9 @@ export type {AdditionalDetailStepForm} from './AdditionalDetailStepForm';
export type {PolicyTagNameForm} from './PolicyTagNameForm';
export type {WorkspaceTagForm} from './WorkspaceTagForm';
export type {WorkspaceNewTaxForm} from './WorkspaceNewTaxForm';
+export type {WorkspaceTaxNameForm} from './WorkspaceTaxNameForm';
+export type {WorkspaceTaxValueForm} from './WorkspaceTaxValueForm';
export type {WorkspaceTaxCustomName} from './WorkspaceTaxCustomName';
export type {PolicyCreateDistanceRateForm} from './PolicyCreateDistanceRateForm';
+export type {PolicyDistanceRateEditForm} from './PolicyDistanceRateEditForm';
export type {default as Form} from './Form';
diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts
index 534a8ad0f2bc..98ce460a7669 100644
--- a/src/types/onyx/Account.ts
+++ b/src/types/onyx/Account.ts
@@ -4,14 +4,6 @@ import type * as OnyxCommon from './OnyxCommon';
type TwoFactorAuthStep = ValueOf | '';
-type DismissedReferralBanners = {
- [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]?: boolean;
- [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]?: boolean;
- [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]?: boolean;
- [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]?: boolean;
- [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE]?: boolean;
-};
-
type Account = {
/** Whether SAML is enabled for the current account */
isSAMLEnabled?: boolean;
@@ -64,15 +56,11 @@ type Account = {
/** Whether a sign is loading */
isLoading?: boolean;
- /** The active policy ID. Initiating a SmartScan will create an expense on this policy by default. */
- activePolicyID?: string;
-
errors?: OnyxCommon.Errors | null;
success?: string;
codesAreCopied?: boolean;
twoFactorAuthStep?: TwoFactorAuthStep;
- dismissedReferralBanners?: DismissedReferralBanners;
};
export default Account;
-export type {TwoFactorAuthStep, DismissedReferralBanners};
+export type {TwoFactorAuthStep};
diff --git a/src/types/onyx/DismissedReferralBanners.ts b/src/types/onyx/DismissedReferralBanners.ts
new file mode 100644
index 000000000000..43fa6472a6ae
--- /dev/null
+++ b/src/types/onyx/DismissedReferralBanners.ts
@@ -0,0 +1,11 @@
+import type CONST from '@src/CONST';
+
+type DismissedReferralBanners = {
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]?: boolean;
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]?: boolean;
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]?: boolean;
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]?: boolean;
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE]?: boolean;
+};
+
+export default DismissedReferralBanners;
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index e3ba941482a0..196267dc28cc 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -37,6 +37,11 @@ type OriginalMessageHold = {
originalMessage: unknown;
};
+type OriginalMessageHoldComment = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.HOLDCOMMENT;
+ originalMessage: unknown;
+};
+
type OriginalMessageUnHold = {
actionName: typeof CONST.REPORT.ACTIONS.TYPE.UNHOLD;
originalMessage: unknown;
@@ -301,6 +306,7 @@ type OriginalMessage =
| OriginalMessageClosed
| OriginalMessageCreated
| OriginalMessageHold
+ | OriginalMessageHoldComment
| OriginalMessageUnHold
| OriginalMessageRenamed
| OriginalMessageChronosOOOList
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index dff6131d579c..5165fa2ee128 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -9,8 +9,9 @@ type Rate = OnyxCommon.OnyxValueWithOfflineFeedback<{
rate?: number;
currency?: string;
customUnitRateID?: string;
- errors?: OnyxCommon.Errors;
enabled?: boolean;
+ errors?: OnyxCommon.Errors;
+ errorFields?: OnyxCommon.ErrorFields;
}>;
type Attributes = {
@@ -51,6 +52,9 @@ type TaxRate = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** An error message to display to the user */
errors?: OnyxCommon.Errors;
+
+ /** An error object keyed by field name containing errors keyed by microtime */
+ errorFields?: OnyxCommon.ErrorFields;
}>;
type TaxRates = Record;
@@ -181,6 +185,58 @@ type Connections = {
type AutoReportingOffset = number | ValueOf;
+type PolicyReportFieldType = 'text' | 'date' | 'dropdown' | 'formula';
+
+type PolicyReportField = {
+ /** Name of the field */
+ name: string;
+
+ /** Default value assigned to the field */
+ defaultValue: string;
+
+ /** Unique id of the field */
+ fieldID: string;
+
+ /** Position at which the field should show up relative to the other fields */
+ orderWeight: number;
+
+ /** Type of report field */
+ type: PolicyReportFieldType;
+
+ /** Tells if the field is required or not */
+ deletable: boolean;
+
+ /** Value of the field */
+ value: string | null;
+
+ /** Options to select from if field is of type dropdown */
+ values: string[];
+
+ target: string;
+
+ /** Tax UDFs have keys holding the names of taxes (eg, VAT), values holding percentages (eg, 15%) and a value indicating the currently selected tax value (eg, 15%). */
+ keys: string[];
+
+ /** list of externalIDs, this are either imported from the integrations or auto generated by us, each externalID */
+ externalIDs: string[];
+
+ disabledOptions: boolean[];
+
+ /** Is this a tax user defined report field */
+ isTax: boolean;
+
+ /** This is the selected externalID in an expense. */
+ externalID?: string | null;
+
+ /** Automated action or integration that added this report field */
+ origin?: string | null;
+
+ /** This is indicates which default value we should use. It was preferred using this over having defaultValue (which we have anyway for historical reasons), since the values are not unique we can't determine which key the defaultValue is referring too. It was also preferred over having defaultKey since the keys are user editable and can be changed. The externalIDs work effectively as an ID, which never changes even after changing the key, value or position of the option. */
+ defaultExternalID?: string | null;
+};
+
+type PolicyFeatureName = ValueOf;
+
type PendingJoinRequestPolicy = {
isJoinRequestPending: boolean;
policyDetailsForNonMembers: Record<
@@ -341,6 +397,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
/** All the integration connections attached to the policy */
connections?: Connections;
+ /** Report fields attached to the policy */
+ fieldList?: Record;
+
/** Whether the Categories feature is enabled */
areCategoriesEnabled?: boolean;
@@ -364,4 +423,4 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
export default Policy;
-export type {Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault, PendingJoinRequestPolicy};
+export type {PolicyReportField, PolicyReportFieldType, Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault, PolicyFeatureName, PendingJoinRequestPolicy};
diff --git a/src/types/onyx/PolicyReportField.ts b/src/types/onyx/PolicyReportField.ts
deleted file mode 100644
index de385070aa25..000000000000
--- a/src/types/onyx/PolicyReportField.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-type PolicyReportFieldType = 'text' | 'date' | 'dropdown' | 'formula';
-
-type PolicyReportField = {
- /** Name of the field */
- name: string;
-
- /** Default value assigned to the field */
- defaultValue: string;
-
- /** Unique id of the field */
- fieldID: string;
-
- /** Position at which the field should show up relative to the other fields */
- orderWeight: number;
-
- /** Type of report field */
- type: PolicyReportFieldType;
-
- /** Tells if the field is required or not */
- deletable: boolean;
-
- /** Value of the field */
- value: string;
-
- /** Options to select from if field is of type dropdown */
- values: string[];
-};
-
-type PolicyReportFields = Record;
-export type {PolicyReportField, PolicyReportFields};
diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
index c34534c0f420..02dfcbbbfc5f 100644
--- a/src/types/onyx/Report.ts
+++ b/src/types/onyx/Report.ts
@@ -4,7 +4,7 @@ import type ONYXKEYS from '@src/ONYXKEYS';
import type CollectionDataSet from '@src/types/utils/CollectionDataSet';
import type * as OnyxCommon from './OnyxCommon';
import type PersonalDetails from './PersonalDetails';
-import type {PolicyReportField} from './PolicyReportField';
+import type {PolicyReportField} from './Policy';
type NotificationPreference = ValueOf;
@@ -183,7 +183,7 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
pendingChatMembers?: PendingChatMember[];
/** If the report contains reportFields, save the field id and its value */
- reportFields?: Record;
+ fieldList?: Record;
},
PolicyReportField['fieldID']
>;
diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts
index f6c34fe742a4..ad81ae480cd0 100644
--- a/src/types/onyx/ReportAction.ts
+++ b/src/types/onyx/ReportAction.ts
@@ -70,6 +70,9 @@ type Message = {
/** resolution for actionable mention whisper */
resolution?: ValueOf | null;
+
+ /** The time this report action was deleted */
+ deleted?: string;
};
type ImageMetadata = {
diff --git a/src/types/onyx/ReportActionsDrafts.ts b/src/types/onyx/ReportActionsDrafts.ts
index 70d16c62a3bc..e4c51c61ed25 100644
--- a/src/types/onyx/ReportActionsDrafts.ts
+++ b/src/types/onyx/ReportActionsDrafts.ts
@@ -1,5 +1,10 @@
+import type ONYXKEYS from '@src/ONYXKEYS';
+import type CollectionDataSet from '@src/types/utils/CollectionDataSet';
import type ReportActionsDraft from './ReportActionsDraft';
type ReportActionsDrafts = Record;
+type ReportActionsDraftCollectionDataSet = CollectionDataSet;
+
export default ReportActionsDrafts;
+export type {ReportActionsDraftCollectionDataSet};
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index 7d129d8e2cd9..de40dd4cf02f 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -11,6 +11,7 @@ import type Credentials from './Credentials';
import type Currency from './Currency';
import type {CurrencyList} from './Currency';
import type CustomStatusDraft from './CustomStatusDraft';
+import type DismissedReferralBanners from './DismissedReferralBanners';
import type Download from './Download';
import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji';
import type {FundList} from './Fund';
@@ -33,12 +34,11 @@ import type {PersonalDetailsList} from './PersonalDetails';
import type PersonalDetails from './PersonalDetails';
import type PlaidData from './PlaidData';
import type Policy from './Policy';
-import type {TaxRate, TaxRates, TaxRatesWithDefault} from './Policy';
+import type {PolicyReportField, TaxRate, TaxRates, TaxRatesWithDefault} from './Policy';
import type {PolicyCategories, PolicyCategory} from './PolicyCategory';
import type PolicyJoinMember from './PolicyJoinMember';
import type {PolicyMembers} from './PolicyMember';
import type PolicyMember from './PolicyMember';
-import type {PolicyReportField, PolicyReportFields} from './PolicyReportField';
import type {PolicyTag, PolicyTagList, PolicyTags} from './PolicyTag';
import type PreferredTheme from './PreferredTheme';
import type PriorityMode from './PriorityMode';
@@ -91,6 +91,7 @@ export type {
Currency,
CurrencyList,
CustomStatusDraft,
+ DismissedReferralBanners,
Download,
FrequentlyUsedEmoji,
Fund,
@@ -158,7 +159,6 @@ export type {
WorkspaceRateAndUnit,
ReportUserIsTyping,
PolicyReportField,
- PolicyReportFields,
RecentlyUsedReportFields,
DecisionName,
OriginalMessageIOU,
diff --git a/src/types/utils/CollectionDataSet.ts b/src/types/utils/CollectionDataSet.ts
index 05a0843b6e9b..b8065cee8f84 100644
--- a/src/types/utils/CollectionDataSet.ts
+++ b/src/types/utils/CollectionDataSet.ts
@@ -2,7 +2,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import type {OnyxCollectionKey, OnyxCollectionValuesMapping} from '@src/ONYXKEYS';
/** Helps with typing a collection item update inside Onyx.multiSet call */
-type CollectionDataSet = Record<`${TCollectionKey}${string}`, OnyxCollectionValuesMapping[TCollectionKey]>;
+type CollectionDataSet = Record<`${TCollectionKey}${string}`, OnyxEntry>;
const toCollectionDataSet = (
collectionKey: TCollectionKey,
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index 64c8edb134b1..5e9efcc00617 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -2231,7 +2231,7 @@ describe('actions/IOU', () => {
const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
// When Opening a thread report with the given details
- Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
+ Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID);
await waitForBatchedUpdates();
// Then The iou action has the transaction report id as a child report ID
@@ -2310,7 +2310,7 @@ describe('actions/IOU', () => {
const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
// When Opening a thread report with the given details
- Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
+ Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID);
await waitForBatchedUpdates();
// Then The iou action has the transaction report id as a child report ID
@@ -2380,7 +2380,7 @@ describe('actions/IOU', () => {
const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
jest.advanceTimersByTime(10);
- Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
+ Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID);
await waitForBatchedUpdates();
Onyx.connect({
@@ -2472,7 +2472,7 @@ describe('actions/IOU', () => {
jest.advanceTimersByTime(10);
const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
- Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
+ Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID);
await waitForBatchedUpdates();
@@ -2698,7 +2698,7 @@ describe('actions/IOU', () => {
jest.advanceTimersByTime(10);
const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
- Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
+ Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID);
await waitForBatchedUpdates();
const allReportActions = await new Promise((resolve) => {
diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.ts
similarity index 84%
rename from tests/actions/ReportTest.js
rename to tests/actions/ReportTest.ts
index a94db507637b..6f1ea2c5ee7f 100644
--- a/tests/actions/ReportTest.js
+++ b/tests/actions/ReportTest.ts
@@ -1,18 +1,19 @@
+/* eslint-disable @typescript-eslint/naming-convention */
import {afterEach, beforeAll, beforeEach, describe, expect, it} from '@jest/globals';
import {utcToZonedTime} from 'date-fns-tz';
-import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import CONST from '../../src/CONST';
-import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager';
-import * as PersistedRequests from '../../src/libs/actions/PersistedRequests';
-import * as Report from '../../src/libs/actions/Report';
-import * as User from '../../src/libs/actions/User';
-import DateUtils from '../../src/libs/DateUtils';
-import Log from '../../src/libs/Log';
-import * as SequentialQueue from '../../src/libs/Network/SequentialQueue';
-import * as ReportUtils from '../../src/libs/ReportUtils';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
+import CONST from '@src/CONST';
+import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
+import * as PersistedRequests from '@src/libs/actions/PersistedRequests';
+import * as Report from '@src/libs/actions/Report';
+import * as User from '@src/libs/actions/User';
+import DateUtils from '@src/libs/DateUtils';
+import Log from '@src/libs/Log';
+import * as SequentialQueue from '@src/libs/Network/SequentialQueue';
+import * as ReportUtils from '@src/libs/ReportUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx';
import getIsUsingFakeTimers from '../utils/getIsUsingFakeTimers';
import PusherHelper from '../utils/PusherHelper';
import * as TestHelper from '../utils/TestHelper';
@@ -20,8 +21,8 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import waitForNetworkPromises from '../utils/waitForNetworkPromises';
const UTC = 'UTC';
-jest.mock('../../src/libs/actions/Report', () => {
- const originalModule = jest.requireActual('../../src/libs/actions/Report');
+jest.mock('@src/libs/actions/Report', () => {
+ const originalModule = jest.requireActual('@src/libs/actions/Report');
return {
...originalModule,
@@ -35,7 +36,6 @@ describe('actions/Report', () => {
PusherHelper.setup();
Onyx.init({
keys: ONYXKEYS,
- registerStorageEventListener: () => {},
});
});
@@ -52,12 +52,13 @@ describe('actions/Report', () => {
afterEach(PusherHelper.teardown);
it('should store a new report action in Onyx when onyxApiUpdate event is handled via Pusher', () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
const TEST_USER_ACCOUNT_ID = 1;
const TEST_USER_LOGIN = 'test@test.com';
- const REPORT_ID = 1;
- let reportActionID;
+ const REPORT_ID = '1';
+ let reportActionID: string;
const REPORT_ACTION = {
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
actorAccountID: TEST_USER_ACCOUNT_ID,
@@ -68,7 +69,7 @@ describe('actions/Report', () => {
shouldShow: true,
};
- let reportActions;
+ let reportActions: OnyxEntry;
Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
callback: (val) => (reportActions = val),
@@ -88,7 +89,7 @@ describe('actions/Report', () => {
return waitForBatchedUpdates();
})
.then(() => {
- const resultAction = _.first(_.values(reportActions));
+ const resultAction: OnyxEntry = Object.values(reportActions ?? {})[0];
reportActionID = resultAction.reportActionID;
expect(resultAction.message).toEqual(REPORT_ACTION.message);
@@ -125,12 +126,12 @@ describe('actions/Report', () => {
})
.then(() => {
// Verify there is only one action and our optimistic comment has been removed
- expect(_.size(reportActions)).toBe(1);
+ expect(Object.keys(reportActions ?? {}).length).toBe(1);
- const resultAction = reportActions[reportActionID];
+ const resultAction = reportActions?.[reportActionID];
// Verify that our action is no longer in the loading state
- expect(resultAction.pendingAction).toBeUndefined();
+ expect(resultAction?.pendingAction).toBeUndefined();
});
});
@@ -139,10 +140,10 @@ describe('actions/Report', () => {
const TEST_USER_LOGIN = 'test@test.com';
const REPORT_ID = '1';
- let reportIsPinned;
+ let reportIsPinned: boolean;
Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`,
- callback: (val) => (reportIsPinned = lodashGet(val, 'isPinned')),
+ callback: (val) => (reportIsPinned = val?.isPinned ?? false),
});
// Set up Onyx with some test user data
@@ -167,6 +168,7 @@ describe('actions/Report', () => {
return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN)
.then(() => TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID))
.then(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
// WHEN we add enough logs to send a packet
@@ -186,27 +188,28 @@ describe('actions/Report', () => {
.then(() => {
// THEN only ONE call to AddComment will happen
const URL_ARGUMENT_INDEX = 0;
- const addCommentCalls = _.filter(global.fetch.mock.calls, (callArguments) => callArguments[URL_ARGUMENT_INDEX].includes('AddComment'));
+ const addCommentCalls = (global.fetch as jest.Mock).mock.calls.filter((callArguments) => callArguments[URL_ARGUMENT_INDEX].includes('AddComment'));
expect(addCommentCalls.length).toBe(1);
});
});
it('should be updated correctly when new comments are added, deleted or marked as unread', () => {
jest.useFakeTimers();
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
const REPORT_ID = '1';
- let report;
- let reportActionCreatedDate;
- let currentTime;
+ let report: OnyxEntry;
+ let reportActionCreatedDate: string;
+ let currentTime: string;
Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`,
callback: (val) => (report = val),
});
- let reportActions;
+ let reportActions: OnyxTypes.ReportActions;
Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
- callback: (val) => (reportActions = val),
+ callback: (val) => (reportActions = val ?? {}),
});
const USER_1_LOGIN = 'user@test.com';
@@ -276,7 +279,7 @@ describe('actions/Report', () => {
.then(() => {
// The report will be read
expect(ReportUtils.isUnread(report)).toBe(false);
- expect(utcToZonedTime(report.lastReadTime, UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
+ expect(utcToZonedTime(report?.lastReadTime ?? '', UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
// And no longer show the green dot for unread mentions in the LHN
expect(ReportUtils.isUnreadWithMention(report)).toBe(false);
@@ -290,7 +293,7 @@ describe('actions/Report', () => {
// Then the report will be unread and show the green dot for unread mentions in LHN
expect(ReportUtils.isUnread(report)).toBe(true);
expect(ReportUtils.isUnreadWithMention(report)).toBe(true);
- expect(report.lastReadTime).toBe(DateUtils.subtractMillisecondsFromDateTime(reportActionCreatedDate, 1));
+ expect(report?.lastReadTime).toBe(DateUtils.subtractMillisecondsFromDateTime(reportActionCreatedDate, 1));
// When a new comment is added by the current user
jest.advanceTimersByTime(10);
@@ -302,8 +305,8 @@ describe('actions/Report', () => {
// The report will be read, the green dot for unread mentions will go away, and the lastReadTime updated
expect(ReportUtils.isUnread(report)).toBe(false);
expect(ReportUtils.isUnreadWithMention(report)).toBe(false);
- expect(utcToZonedTime(report.lastReadTime, UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
- expect(report.lastMessageText).toBe('Current User Comment 1');
+ expect(utcToZonedTime(report?.lastReadTime ?? '', UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
+ expect(report?.lastMessageText).toBe('Current User Comment 1');
// When another comment is added by the current user
jest.advanceTimersByTime(10);
@@ -314,8 +317,8 @@ describe('actions/Report', () => {
.then(() => {
// The report will be read and the lastReadTime updated
expect(ReportUtils.isUnread(report)).toBe(false);
- expect(utcToZonedTime(report.lastReadTime, UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
- expect(report.lastMessageText).toBe('Current User Comment 2');
+ expect(utcToZonedTime(report?.lastReadTime ?? '', UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
+ expect(report?.lastMessageText).toBe('Current User Comment 2');
// When another comment is added by the current user
jest.advanceTimersByTime(10);
@@ -326,8 +329,8 @@ describe('actions/Report', () => {
.then(() => {
// The report will be read and the lastReadTime updated
expect(ReportUtils.isUnread(report)).toBe(false);
- expect(utcToZonedTime(report.lastReadTime, UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
- expect(report.lastMessageText).toBe('Current User Comment 3');
+ expect(utcToZonedTime(report?.lastReadTime ?? '', UTC).getTime()).toBeGreaterThanOrEqual(utcToZonedTime(currentTime, UTC).getTime());
+ expect(report?.lastMessageText).toBe('Current User Comment 3');
const USER_1_BASE_ACTION = {
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
@@ -340,7 +343,7 @@ describe('actions/Report', () => {
};
jest.advanceTimersByTime(10);
- const optimisticReportActions = {
+ const optimisticReportActions: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
value: {
@@ -350,12 +353,14 @@ describe('actions/Report', () => {
created: DateUtils.getDBTime(Date.now() - 2),
reportActionID: '200',
},
+
300: {
...USER_1_BASE_ACTION,
message: [{type: 'COMMENT', html: 'Current User Comment 2', text: 'Current User Comment 2'}],
created: DateUtils.getDBTime(Date.now() - 1),
reportActionID: '300',
},
+
400: {
...USER_1_BASE_ACTION,
message: [{type: 'COMMENT', html: 'Current User Comment 3', text: 'Current User Comment 3'}],
@@ -366,7 +371,10 @@ describe('actions/Report', () => {
};
jest.advanceTimersByTime(10);
reportActionCreatedDate = DateUtils.getDBTime();
- optimisticReportActions.value[400].created = reportActionCreatedDate;
+
+ if (optimisticReportActions.value?.[400]) {
+ optimisticReportActions.value[400].created = reportActionCreatedDate;
+ }
// When we emit the events for these pending created actions to update them to not pending
PusherHelper.emitOnyxUpdate([
@@ -394,7 +402,7 @@ describe('actions/Report', () => {
})
.then(() => {
// Then no change will occur
- expect(report.lastReadTime).toBe(reportActionCreatedDate);
+ expect(report?.lastReadTime).toBe(reportActionCreatedDate);
expect(ReportUtils.isUnread(report)).toBe(false);
// When the user manually marks a message as "unread"
@@ -404,7 +412,7 @@ describe('actions/Report', () => {
.then(() => {
// Then we should expect the report to be to be unread
expect(ReportUtils.isUnread(report)).toBe(true);
- expect(report.lastReadTime).toBe(DateUtils.subtractMillisecondsFromDateTime(reportActionCreatedDate, 1));
+ expect(report?.lastReadTime).toBe(DateUtils.subtractMillisecondsFromDateTime(reportActionCreatedDate, 1));
// If the user deletes the last comment after the lastReadTime the lastMessageText will reflect the new last comment
Report.deleteReportComment(REPORT_ID, {...reportActions[400]});
@@ -412,7 +420,7 @@ describe('actions/Report', () => {
})
.then(() => {
expect(ReportUtils.isUnread(report)).toBe(false);
- expect(report.lastMessageText).toBe('Current User Comment 2');
+ expect(report?.lastMessageText).toBe('Current User Comment 2');
});
waitForBatchedUpdates(); // flushing onyx.set as it will be batched
return setPromise;
@@ -424,6 +432,7 @@ describe('actions/Report', () => {
* already in the comment and the user deleted it on purpose.
*/
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
// User edits comment to add link
@@ -536,11 +545,12 @@ describe('actions/Report', () => {
});
it('should properly toggle reactions on a message', () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
const TEST_USER_ACCOUNT_ID = 1;
const TEST_USER_LOGIN = 'test@test.com';
- const REPORT_ID = 1;
+ const REPORT_ID = '1';
const EMOJI_CODE = '👍';
const EMOJI_SKIN_TONE = 2;
const EMOJI_NAME = '+1';
@@ -550,20 +560,20 @@ describe('actions/Report', () => {
types: ['👍🏿', '👍🏾', '👍🏽', '👍🏼', '👍🏻'],
};
- let reportActions;
+ let reportActions: OnyxTypes.ReportActions;
Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
- callback: (val) => (reportActions = val),
+ callback: (val) => (reportActions = val ?? {}),
});
- const reportActionsReactions = {};
+ const reportActionsReactions: OnyxCollection = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS,
callback: (val, key) => {
- reportActionsReactions[key] = val;
+ reportActionsReactions[key] = val ?? {};
},
});
- let reportAction;
- let reportActionID;
+ let reportAction: OnyxTypes.ReportAction;
+ let reportActionID: string;
// Set up Onyx with some test user data
return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN)
@@ -579,15 +589,15 @@ describe('actions/Report', () => {
return waitForBatchedUpdates();
})
.then(() => {
- reportAction = _.first(_.values(reportActions));
+ reportAction = Object.values(reportActions)[0];
reportActionID = reportAction.reportActionID;
// Add a reaction to the comment
- Report.toggleEmojiReaction(REPORT_ID, reportAction, EMOJI);
+ Report.toggleEmojiReaction(REPORT_ID, reportAction, EMOJI, reportActionsReactions[0]);
return waitForBatchedUpdates();
})
.then(() => {
- reportAction = _.first(_.values(reportActions));
+ reportAction = Object.values(reportActions)[0];
// Expect the reaction to exist in the reportActionsReactions collection
expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`);
@@ -597,8 +607,8 @@ describe('actions/Report', () => {
expect(reportActionReaction).toHaveProperty(EMOJI.name);
// Expect the emoji to have the user accountID
- const reportActionReactionEmoji = reportActionReaction[EMOJI.name];
- expect(reportActionReactionEmoji.users).toHaveProperty(`${TEST_USER_ACCOUNT_ID}`);
+ const reportActionReactionEmoji = reportActionReaction?.[EMOJI.name];
+ expect(reportActionReactionEmoji?.users).toHaveProperty(`${TEST_USER_ACCOUNT_ID}`);
// Now we remove the reaction
Report.toggleEmojiReaction(REPORT_ID, reportAction, EMOJI, reportActionReaction);
@@ -608,23 +618,23 @@ describe('actions/Report', () => {
// Expect the reaction to have null where the users reaction used to be
expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`);
const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`];
- expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined();
+ expect(reportActionReaction?.[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined();
})
.then(() => {
- reportAction = _.first(_.values(reportActions));
+ reportAction = Object.values(reportActions)[0];
// Add the same reaction to the same report action with a different skintone
- Report.toggleEmojiReaction(REPORT_ID, reportAction, EMOJI);
+ Report.toggleEmojiReaction(REPORT_ID, reportAction, EMOJI, reportActionsReactions[0]);
return waitForBatchedUpdates()
.then(() => {
- reportAction = _.first(_.values(reportActions));
+ reportAction = Object.values(reportActions)[0];
const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`];
Report.toggleEmojiReaction(REPORT_ID, reportAction, EMOJI, reportActionReaction, EMOJI_SKIN_TONE);
return waitForBatchedUpdates();
})
.then(() => {
- reportAction = _.first(_.values(reportActions));
+ reportAction = Object.values(reportActions)[0];
// Expect the reaction to exist in the reportActionsReactions collection
expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`);
@@ -634,11 +644,11 @@ describe('actions/Report', () => {
expect(reportActionReaction).toHaveProperty(EMOJI.name);
// Expect the emoji to have the user accountID
- const reportActionReactionEmoji = reportActionReaction[EMOJI.name];
- expect(reportActionReactionEmoji.users).toHaveProperty(`${TEST_USER_ACCOUNT_ID}`);
+ const reportActionReactionEmoji = reportActionReaction?.[EMOJI.name];
+ expect(reportActionReactionEmoji?.users).toHaveProperty(`${TEST_USER_ACCOUNT_ID}`);
// Expect two different skintone reactions
- const reportActionReactionEmojiUserSkinTones = reportActionReactionEmoji.users[TEST_USER_ACCOUNT_ID].skinTones;
+ const reportActionReactionEmojiUserSkinTones = reportActionReactionEmoji?.users[TEST_USER_ACCOUNT_ID].skinTones;
expect(reportActionReactionEmojiUserSkinTones).toHaveProperty('-1');
expect(reportActionReactionEmojiUserSkinTones).toHaveProperty('2');
@@ -650,17 +660,18 @@ describe('actions/Report', () => {
// Expect the reaction to have null where the users reaction used to be
expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`);
const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`];
- expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined();
+ expect(reportActionReaction?.[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined();
});
});
});
it("shouldn't add the same reaction twice when changing preferred skin color and reaction doesn't support skin colors", () => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
const TEST_USER_ACCOUNT_ID = 1;
const TEST_USER_LOGIN = 'test@test.com';
- const REPORT_ID = 1;
+ const REPORT_ID = '1';
const EMOJI_CODE = '😄';
const EMOJI_NAME = 'smile';
const EMOJI = {
@@ -668,20 +679,20 @@ describe('actions/Report', () => {
name: EMOJI_NAME,
};
- let reportActions;
+ let reportActions: OnyxTypes.ReportActions = {};
Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
- callback: (val) => (reportActions = val),
+ callback: (val) => (reportActions = val ?? {}),
});
- const reportActionsReactions = {};
+ const reportActionsReactions: OnyxCollection = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS,
callback: (val, key) => {
- reportActionsReactions[key] = val;
+ reportActionsReactions[key] = val ?? {};
},
});
- let resultAction;
+ let resultAction: OnyxTypes.ReportAction;
// Set up Onyx with some test user data
return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN)
@@ -697,14 +708,14 @@ describe('actions/Report', () => {
return waitForBatchedUpdates();
})
.then(() => {
- resultAction = _.first(_.values(reportActions));
+ resultAction = Object.values(reportActions)[0];
// Add a reaction to the comment
Report.toggleEmojiReaction(REPORT_ID, resultAction, EMOJI, {});
return waitForBatchedUpdates();
})
.then(() => {
- resultAction = _.first(_.values(reportActions));
+ resultAction = Object.values(reportActions)[0];
// Now we toggle the reaction while the skin tone has changed.
// As the emoji doesn't support skin tones, the emoji
@@ -717,7 +728,7 @@ describe('actions/Report', () => {
// Expect the reaction to have null where the users reaction used to be
expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${resultAction.reportActionID}`);
const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${resultAction.reportActionID}`];
- expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined();
+ expect(reportActionReaction?.[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined();
});
});
});
diff --git a/tests/e2e/nativeCommands/index.js b/tests/e2e/nativeCommands/index.ts
similarity index 74%
rename from tests/e2e/nativeCommands/index.js
rename to tests/e2e/nativeCommands/index.ts
index 90dcb00bbcae..31af618c8ec1 100644
--- a/tests/e2e/nativeCommands/index.js
+++ b/tests/e2e/nativeCommands/index.ts
@@ -1,14 +1,15 @@
+import type {NativeCommandPayload} from '@libs/E2E/client';
import adbBackspace from './adbBackspace';
import adbTypeText from './adbTypeText';
// eslint-disable-next-line rulesdir/prefer-import-module-contents
import {NativeCommandsAction} from './NativeCommandsAction';
-const executeFromPayload = (actionName, payload) => {
+const executeFromPayload = (actionName?: string, payload?: NativeCommandPayload): boolean => {
switch (actionName) {
case NativeCommandsAction.scroll:
throw new Error('Not implemented yet');
case NativeCommandsAction.type:
- return adbTypeText(payload.text);
+ return adbTypeText(payload?.text ?? '');
case NativeCommandsAction.backspace:
return adbBackspace();
default:
diff --git a/tests/perf-test/BaseOptionsList.perf-test.tsx b/tests/perf-test/BaseOptionsList.perf-test.tsx
new file mode 100644
index 000000000000..5e8b5e9f9289
--- /dev/null
+++ b/tests/perf-test/BaseOptionsList.perf-test.tsx
@@ -0,0 +1,127 @@
+import {fireEvent} from '@testing-library/react-native';
+import type {RenderResult} from '@testing-library/react-native';
+import React, {useState} from 'react';
+import {measurePerformance} from 'reassure';
+import BaseOptionsList from '@components/OptionsList/BaseOptionsList';
+import type {OptionData} from '@libs/ReportUtils';
+import variables from '@styles/variables';
+
+type BaseOptionsListWrapperProps = {
+ /** Whether this is a multi-select list */
+ canSelectMultipleOptions?: boolean;
+};
+
+describe('[BaseOptionsList]', () => {
+ function BaseOptionsListWrapper({canSelectMultipleOptions = false}: BaseOptionsListWrapperProps) {
+ const [selectedIds, setSelectedIds] = useState([]);
+
+ const sections = [
+ {
+ data: Array.from({length: 10000}, (_, index) => ({
+ text: `Item ${index}`,
+ keyForList: `item-${index}`,
+ isSelected: selectedIds.includes(`item-${index}`),
+ reportID: `report-${index}`,
+ })),
+ indexOffset: 0,
+ isDisabled: false,
+ shouldShow: true,
+ title: 'Section 1',
+ },
+ {
+ data: Array.from({length: 10000}, (_, index) => ({
+ text: `Item ${index}`,
+ keyForList: `item-${index}`,
+ isSelected: selectedIds.includes(`item-${index}`),
+ reportID: `report-${index}`,
+ })),
+ indexOffset: 0,
+ isDisabled: false,
+ shouldShow: true,
+ title: 'Section 2',
+ },
+ ];
+
+ const onSelectRow = (item: OptionData) => {
+ if (!item.keyForList) {
+ return;
+ }
+
+ if (canSelectMultipleOptions) {
+ if (selectedIds.includes(item.keyForList)) {
+ setSelectedIds(selectedIds.filter((selectedId) => selectedId === item.keyForList));
+ } else {
+ setSelectedIds([...selectedIds, item.keyForList]);
+ }
+ } else {
+ setSelectedIds([item.keyForList]);
+ }
+ };
+
+ return (
+
+ );
+ }
+
+ test('Should render 1 section and a thousand items', () => {
+ measurePerformance( );
+ });
+
+ test('Should press a list item', () => {
+ // eslint-disable-next-line @typescript-eslint/require-await
+ const scenario = async (screen: RenderResult) => {
+ fireEvent.press(screen.getByText('Item 5'));
+ };
+
+ measurePerformance( , {scenario});
+ });
+
+ test('Should render multiple selection and select 4 items', () => {
+ // eslint-disable-next-line @typescript-eslint/require-await
+ const scenario = async (screen: RenderResult) => {
+ fireEvent.press(screen.getByText('Item 1'));
+ fireEvent.press(screen.getByText('Item 2'));
+ fireEvent.press(screen.getByText('Item 3'));
+ fireEvent.press(screen.getByText('Item 4'));
+ };
+
+ measurePerformance( , {scenario});
+ });
+
+ test('Should scroll and select a few items', () => {
+ const eventData = {
+ nativeEvent: {
+ contentOffset: {
+ y: variables.optionRowHeight * 5,
+ },
+ contentSize: {
+ // Dimensions of the scrollable content
+ height: variables.optionRowHeight * 10,
+ width: 100,
+ },
+ layoutMeasurement: {
+ // Dimensions of the device
+ height: variables.optionRowHeight * 5,
+ width: 100,
+ },
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/require-await
+ const scenario = async (screen: RenderResult) => {
+ fireEvent.press(screen.getByText('Item 1'));
+ // see https://github.com/callstack/react-native-testing-library/issues/1540
+ fireEvent(screen.getByTestId('options-list'), 'onContentSizeChange', eventData.nativeEvent.contentSize.width, eventData.nativeEvent.contentSize.height);
+ fireEvent.scroll(screen.getByTestId('options-list'), eventData);
+ fireEvent.press(screen.getByText('Item 7'));
+ fireEvent.press(screen.getByText('Item 15'));
+ };
+
+ measurePerformance( , {scenario});
+ });
+});
diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx
index 61eb726686f2..0726dbc9c88d 100644
--- a/tests/ui/UnreadIndicatorsTest.tsx
+++ b/tests/ui/UnreadIndicatorsTest.tsx
@@ -253,15 +253,15 @@ function signInAndGetAppWithUnreadChat(): Promise {
},
],
},
- 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1'),
- 2: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 20), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '2'),
- 3: TestHelper.buildTestReportComment(reportAction3CreatedDate, USER_B_ACCOUNT_ID, '3'),
- 4: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 40), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '4'),
- 5: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 50), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '5'),
- 6: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 60), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '6'),
- 7: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 70), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '7'),
- 8: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 80), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '8'),
- 9: TestHelper.buildTestReportComment(reportAction9CreatedDate, USER_B_ACCOUNT_ID, '9'),
+ 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1', createdReportActionID),
+ 2: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 20), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '2', '1'),
+ 3: TestHelper.buildTestReportComment(reportAction3CreatedDate, USER_B_ACCOUNT_ID, '3', '2'),
+ 4: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 40), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '4', '3'),
+ 5: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 50), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '5', '4'),
+ 6: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 60), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '6', '5'),
+ 7: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 70), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '7', '6'),
+ 8: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 80), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '8', '7'),
+ 9: TestHelper.buildTestReportComment(reportAction9CreatedDate, USER_B_ACCOUNT_ID, '9', '8'),
});
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
[USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'),
@@ -413,7 +413,6 @@ describe('Unread Indicators', () => {
reportActionID: commentReportActionID,
},
},
- // @ts-expect-error -- it's necessary for the test
shouldNotify: true,
},
{
diff --git a/tests/unit/APITest.js b/tests/unit/APITest.ts
similarity index 84%
rename from tests/unit/APITest.js
rename to tests/unit/APITest.ts
index 30c935c48571..359288b2a1ef 100644
--- a/tests/unit/APITest.js
+++ b/tests/unit/APITest.ts
@@ -1,28 +1,46 @@
-import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import CONST from '../../src/CONST';
-import * as PersistedRequests from '../../src/libs/actions/PersistedRequests';
-import * as API from '../../src/libs/API';
-import HttpUtils from '../../src/libs/HttpUtils';
-import * as MainQueue from '../../src/libs/Network/MainQueue';
-import * as NetworkStore from '../../src/libs/Network/NetworkStore';
-import * as SequentialQueue from '../../src/libs/Network/SequentialQueue';
-import * as Request from '../../src/libs/Request';
-import * as RequestThrottle from '../../src/libs/RequestThrottle';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import MockedOnyx from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import CONST from '@src/CONST';
+import * as PersistedRequests from '@src/libs/actions/PersistedRequests';
+import * as API from '@src/libs/API';
+import HttpUtils from '@src/libs/HttpUtils';
+import * as MainQueue from '@src/libs/Network/MainQueue';
+import * as NetworkStore from '@src/libs/Network/NetworkStore';
+import * as SequentialQueue from '@src/libs/Network/SequentialQueue';
+import * as Request from '@src/libs/Request';
+import * as RequestThrottle from '@src/libs/RequestThrottle';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type ReactNativeOnyxMock from '../../__mocks__/react-native-onyx';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import waitForNetworkPromises from '../utils/waitForNetworkPromises';
-jest.mock('../../src/libs/Log');
+const Onyx = MockedOnyx as typeof ReactNativeOnyxMock;
+
+jest.mock('@src/libs/Log');
Onyx.init({
keys: ONYXKEYS,
});
+type Response = {
+ ok?: boolean;
+ status?: ValueOf | ValueOf;
+ jsonCode?: ValueOf;
+ json?: () => Promise;
+ title?: ValueOf;
+ type?: ValueOf;
+};
+
+type XhrCalls = Array<{
+ resolve: (value: Response | PromiseLike) => void;
+ reject: (value: unknown) => void;
+}>;
+
const originalXHR = HttpUtils.xhr;
beforeEach(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
HttpUtils.xhr = originalXHR;
MainQueue.clear();
@@ -53,8 +71,11 @@ describe('APITests', () => {
return Onyx.set(ONYXKEYS.NETWORK, {isOffline: true})
.then(() => {
// When API Writes and Reads are called
+ // @ts-expect-error - mocking the parameter
API.write('mock command', {param1: 'value1'});
+ // @ts-expect-error - mocking the parameter
API.read('mock command', {param2: 'value2'});
+ // @ts-expect-error - mocking the parameter
API.write('mock command', {param3: 'value3'});
return waitForBatchedUpdates();
})
@@ -89,7 +110,9 @@ describe('APITests', () => {
})
.then(() => {
// When API Write commands are made
+ // @ts-expect-error - mocking the parameter
API.write('mock command', {param1: 'value1'});
+ // @ts-expect-error - mocking the parameter
API.write('mock command', {param2: 'value2'});
return waitForBatchedUpdates();
})
@@ -120,8 +143,8 @@ describe('APITests', () => {
test('Write request should not be cleared until a backend response occurs', () => {
// We're setting up xhr handler that will resolve calls programmatically
- const xhrCalls = [];
- const promises = [];
+ const xhrCalls: XhrCalls = [];
+ const promises: Array> = [];
jest.spyOn(HttpUtils, 'xhr').mockImplementation(() => {
promises.push(
@@ -130,7 +153,7 @@ describe('APITests', () => {
}),
);
- return _.last(promises);
+ return promises.slice(-1)[0];
});
// Given we have some requests made while we're offline
@@ -138,7 +161,9 @@ describe('APITests', () => {
Onyx.set(ONYXKEYS.NETWORK, {isOffline: true})
.then(() => {
// When API Write commands are made
+ // @ts-expect-error - mocking the parameter
API.write('mock command', {param1: 'value1'});
+ // @ts-expect-error - mocking the parameter
API.write('mock command', {param2: 'value2'});
return waitForBatchedUpdates();
})
@@ -148,14 +173,14 @@ describe('APITests', () => {
.then(waitForBatchedUpdates)
.then(() => {
// Then requests should remain persisted until the xhr call is resolved
- expect(_.size(PersistedRequests.getAll())).toEqual(2);
+ expect(PersistedRequests.getAll().length).toEqual(2);
xhrCalls[0].resolve({jsonCode: CONST.JSON_CODE.SUCCESS});
return waitForBatchedUpdates();
})
.then(waitForBatchedUpdates)
.then(() => {
- expect(_.size(PersistedRequests.getAll())).toEqual(1);
+ expect(PersistedRequests.getAll().length).toEqual(1);
expect(PersistedRequests.getAll()).toEqual([expect.objectContaining({command: 'mock command', data: expect.objectContaining({param2: 'value2'})})]);
// When a request fails it should be retried
@@ -163,7 +188,7 @@ describe('APITests', () => {
return waitForBatchedUpdates();
})
.then(() => {
- expect(_.size(PersistedRequests.getAll())).toEqual(1);
+ expect(PersistedRequests.getAll().length).toEqual(1);
expect(PersistedRequests.getAll()).toEqual([expect.objectContaining({command: 'mock command', data: expect.objectContaining({param2: 'value2'})})]);
// We need to advance past the request throttle back off timer because the request won't be retried until then
@@ -177,32 +202,30 @@ describe('APITests', () => {
return waitForBatchedUpdates();
})
.then(() => {
- expect(_.size(PersistedRequests.getAll())).toEqual(0);
+ expect(PersistedRequests.getAll().length).toEqual(0);
})
);
});
// Given a retry response create a mock and run some expectations for retrying requests
- const retryExpectations = (retryResponse) => {
- let successfulResponse = {
+
+ const retryExpectations = (response: Response) => {
+ const successfulResponse: Response = {
ok: true,
jsonCode: CONST.JSON_CODE.SUCCESS,
- };
-
- // We have to mock response.json() too
- successfulResponse = {
- ...successfulResponse,
+ // We have to mock response.json() too
json: () => Promise.resolve(successfulResponse),
};
// Given a mock where a retry response is returned twice before a successful response
- global.fetch = jest.fn().mockResolvedValueOnce(retryResponse).mockResolvedValueOnce(retryResponse).mockResolvedValueOnce(successfulResponse);
+ global.fetch = jest.fn().mockResolvedValueOnce(response).mockResolvedValueOnce(response).mockResolvedValueOnce(successfulResponse);
// Given we have a request made while we're offline
return (
Onyx.set(ONYXKEYS.NETWORK, {isOffline: true})
.then(() => {
// When API Write commands are made
+ // @ts-expect-error - mocking the parameter
API.write('mock command', {param1: 'value1'});
return waitForNetworkPromises();
})
@@ -215,7 +238,7 @@ describe('APITests', () => {
expect(global.fetch).toHaveBeenCalledTimes(1);
// And we still have 1 persisted request since it failed
- expect(_.size(PersistedRequests.getAll())).toEqual(1);
+ expect(PersistedRequests.getAll().length).toEqual(1);
expect(PersistedRequests.getAll()).toEqual([expect.objectContaining({command: 'mock command', data: expect.objectContaining({param1: 'value1'})})]);
// We let the SequentialQueue process again after its wait time
@@ -228,7 +251,7 @@ describe('APITests', () => {
expect(global.fetch).toHaveBeenCalledTimes(2);
// And we still have 1 persisted request since it failed
- expect(_.size(PersistedRequests.getAll())).toEqual(1);
+ expect(PersistedRequests.getAll().length).toEqual(1);
expect(PersistedRequests.getAll()).toEqual([expect.objectContaining({command: 'mock command', data: expect.objectContaining({param1: 'value1'})})]);
// We let the SequentialQueue process again after its wait time
@@ -241,7 +264,7 @@ describe('APITests', () => {
expect(global.fetch).toHaveBeenCalledTimes(3);
// The request succeeds so the queue is empty
- expect(_.size(PersistedRequests.getAll())).toEqual(0);
+ expect(PersistedRequests.getAll().length).toEqual(0);
})
);
};
@@ -256,9 +279,9 @@ describe('APITests', () => {
test('write requests are retried when Auth is down', () => {
// Given the response data returned when auth is down
- const responseData = {
+ const responseData: Response = {
ok: true,
- status: 200,
+ status: CONST.JSON_CODE.SUCCESS,
jsonCode: CONST.JSON_CODE.EXP_ERROR,
title: CONST.ERROR_TITLE.SOCKET,
type: CONST.ERROR_TYPE.SOCKET,
@@ -289,6 +312,7 @@ describe('APITests', () => {
waitForBatchedUpdates()
.then(() => Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}))
.then(() => {
+ // @ts-expect-error - mocking the parameter
API.write('Mock', {param1: 'value1'});
return waitForBatchedUpdates();
})
@@ -297,7 +321,7 @@ describe('APITests', () => {
.then(() => Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}))
.then(waitForBatchedUpdates)
.then(() => {
- const nonLogCalls = _.filter(xhr.mock.calls, ([commandName]) => commandName !== 'Log');
+ const nonLogCalls = xhr.mock.calls.filter(([commandName]) => commandName !== 'Log');
// The request should be retried once and reauthenticate should be called the second time
// expect(xhr).toHaveBeenCalledTimes(3);
@@ -322,12 +346,19 @@ describe('APITests', () => {
})
.then(() => {
// When we queue 6 persistable commands and one not persistable
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value1'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value2'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value3'});
+ // @ts-expect-error - mocking the parameter
API.read('MockCommand', {content: 'not-persisted'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value4'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value5'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value6'});
return waitForBatchedUpdates();
@@ -359,11 +390,17 @@ describe('APITests', () => {
})
.then(() => {
// When we queue 6 persistable commands
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value1'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value2'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value3'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value4'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value5'});
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value6'});
return waitForBatchedUpdates();
})
@@ -402,7 +439,14 @@ describe('APITests', () => {
})
.then(() => {
// When we queue both non-persistable and persistable commands that will trigger reauthentication and go offline at the same time
- API.makeRequestWithSideEffects('AuthenticatePusher', {content: 'value1'});
+ API.makeRequestWithSideEffects('AuthenticatePusher', {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ socket_id: 'socket_id',
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ channel_name: 'channel_name',
+ shouldRetry: false,
+ forceNetworkRequest: false,
+ });
Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
expect(NetworkStore.isOffline()).toBe(false);
@@ -410,6 +454,7 @@ describe('APITests', () => {
return waitForBatchedUpdates();
})
.then(() => {
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand');
expect(PersistedRequests.getAll().length).toBe(1);
expect(NetworkStore.isOffline()).toBe(true);
@@ -479,6 +524,7 @@ describe('APITests', () => {
NetworkStore.resetHasReadRequiredDataFromStorage();
// And queue a Write request while offline
+ // @ts-expect-error - mocking the parameter
API.write('MockCommand', {content: 'value1'});
// Then we should expect the request to get persisted
@@ -515,8 +561,11 @@ describe('APITests', () => {
expect(NetworkStore.isOffline()).toBe(false);
// WHEN we make a request that should be retried, one that should not, and another that should
+ // @ts-expect-error - mocking the parameter
API.write('MockCommandOne');
+ // @ts-expect-error - mocking the parameter
API.read('MockCommandTwo');
+ // @ts-expect-error - mocking the parameter
API.write('MockCommandThree');
// THEN the retryable requests should immediately be added to the persisted requests
diff --git a/tests/unit/LocaleCompareTest.js b/tests/unit/LocaleCompareTest.ts
similarity index 100%
rename from tests/unit/LocaleCompareTest.js
rename to tests/unit/LocaleCompareTest.ts
diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js
deleted file mode 100644
index 65ab921ac9e1..000000000000
--- a/tests/unit/MigrationTest.js
+++ /dev/null
@@ -1,274 +0,0 @@
-import Onyx from 'react-native-onyx';
-import Log from '../../src/libs/Log';
-import CheckForPreviousReportActionID from '../../src/libs/migrations/CheckForPreviousReportActionID';
-import KeyReportActionsDraftByReportActionID from '../../src/libs/migrations/KeyReportActionsDraftByReportActionID';
-import ONYXKEYS from '../../src/ONYXKEYS';
-import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
-
-jest.mock('../../src/libs/getPlatform');
-
-let LogSpy;
-
-describe('Migrations', () => {
- beforeAll(() => {
- Onyx.init({keys: ONYXKEYS});
- LogSpy = jest.spyOn(Log, 'info');
- Log.serverLoggingCallback = () => {};
- return waitForBatchedUpdates();
- });
-
- beforeEach(() => {
- jest.clearAllMocks();
- Onyx.clear();
- return waitForBatchedUpdates();
- });
-
- describe('CheckForPreviousReportActionID', () => {
- it("Should work even if there's no reportAction data in Onyx", () =>
- CheckForPreviousReportActionID().then(() =>
- expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration CheckForPreviousReportActionID because there were no reportActions'),
- ));
-
- it('Should remove all report actions given that a previousReportActionID does not exist', () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: {
- 1: {
- reportActionID: '1',
- },
- 2: {
- reportActionID: '2',
- },
- },
- })
- .then(CheckForPreviousReportActionID)
- .then(() => {
- expect(LogSpy).toHaveBeenCalledWith(
- '[Migrate Onyx] CheckForPreviousReportActionID Migration: removing all reportActions because previousReportActionID not found in the first valid reportAction',
- );
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
- waitForCollectionCallback: true,
- callback: (allReportActions) => {
- Onyx.disconnect(connectionID);
- const expectedReportAction = {};
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction);
- },
- });
- }));
-
- it('Should not remove any report action given that previousReportActionID exists in first valid report action', () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: {
- 1: {
- reportActionID: '1',
- previousReportActionID: '0',
- },
- 2: {
- reportActionID: '2',
- previousReportActionID: '1',
- },
- },
- })
- .then(CheckForPreviousReportActionID)
- .then(() => {
- expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] CheckForPreviousReportActionID Migration: previousReportActionID found. Migration complete');
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
- waitForCollectionCallback: true,
- callback: (allReportActions) => {
- Onyx.disconnect(connectionID);
- const expectedReportAction = {
- 1: {
- reportActionID: '1',
- previousReportActionID: '0',
- },
- 2: {
- reportActionID: '2',
- previousReportActionID: '1',
- },
- };
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction);
- },
- });
- }));
-
- it('Should skip zombie report actions and proceed to remove all reportActions given that a previousReportActionID does not exist', () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: {},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: null,
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: null,
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]: {
- 1: {
- reportActionID: '1',
- },
- 2: {
- reportActionID: '2',
- },
- },
- })
- .then(CheckForPreviousReportActionID)
- .then(() => {
- expect(LogSpy).toHaveBeenCalledWith(
- '[Migrate Onyx] CheckForPreviousReportActionID Migration: removing all reportActions because previousReportActionID not found in the first valid reportAction',
- );
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
- waitForCollectionCallback: true,
- callback: (allReportActions) => {
- Onyx.disconnect(connectionID);
- const expectedReportAction = {};
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction);
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toBeUndefined();
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toBeUndefined();
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toMatchObject(expectedReportAction);
- },
- });
- }));
-
- it('Should skip zombie report actions and should not remove any report action given that previousReportActionID exists in first valid report action', () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: {},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: null,
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: null,
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]: {
- 1: {
- reportActionID: '1',
- previousReportActionID: '10',
- },
- 2: {
- reportActionID: '2',
- previousReportActionID: '23',
- },
- },
- })
- .then(CheckForPreviousReportActionID)
- .then(() => {
- expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] CheckForPreviousReportActionID Migration: previousReportActionID found. Migration complete');
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
- waitForCollectionCallback: true,
- callback: (allReportActions) => {
- Onyx.disconnect(connectionID);
- const expectedReportAction1 = {};
- const expectedReportAction4 = {
- 1: {
- reportActionID: '1',
- previousReportActionID: '10',
- },
- 2: {
- reportActionID: '2',
- previousReportActionID: '23',
- },
- };
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction1);
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toBeUndefined();
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toBeUndefined();
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toMatchObject(expectedReportAction4);
- },
- });
- }));
-
- it('Should skip if no valid reportActions', () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: null,
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: {},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: {},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]: null,
- })
- .then(CheckForPreviousReportActionID)
- .then(() => {
- expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration CheckForPreviousReportActionID because there were no valid reportActions');
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
- waitForCollectionCallback: true,
- callback: (allReportActions) => {
- Onyx.disconnect(connectionID);
- const expectedReportAction = {};
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toBeUndefined();
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toMatchObject(expectedReportAction);
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toMatchObject(expectedReportAction);
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toBeUndefined();
- },
- });
- }));
- });
-
- describe('KeyReportActionsDraftByReportActionID', () => {
- it("Should work even if there's no reportActionsDrafts data in Onyx", () =>
- KeyReportActionsDraftByReportActionID().then(() =>
- expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration KeyReportActionsDraftByReportActionID because there were no reportActionsDrafts'),
- ));
-
- it('Should move individual draft to a draft collection of report', () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`]: 'a',
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_2`]: 'b',
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2`]: {3: 'c'},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2_4`]: 'd',
- })
- .then(KeyReportActionsDraftByReportActionID)
- .then(() => {
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS,
- waitForCollectionCallback: true,
- callback: (allReportActionsDrafts) => {
- Onyx.disconnect(connectionID);
- const expectedReportActionDraft1 = {
- 1: 'a',
- 2: 'b',
- };
- const expectedReportActionDraft2 = {
- 3: 'c',
- 4: 'd',
- };
- expect(allReportActionsDrafts[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`]).toBeUndefined();
- expect(allReportActionsDrafts[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_2`]).toBeUndefined();
- expect(allReportActionsDrafts[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2_4`]).toBeUndefined();
- expect(allReportActionsDrafts[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1`]).toMatchObject(expectedReportActionDraft1);
- expect(allReportActionsDrafts[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2`]).toMatchObject(expectedReportActionDraft2);
- },
- });
- }));
-
- it('Should skip if nothing to migrate', () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`]: null,
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_2`]: null,
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2`]: {},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2_4`]: null,
- })
- .then(KeyReportActionsDraftByReportActionID)
- .then(() => {
- expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration KeyReportActionsDraftByReportActionID because there are no actions drafts to migrate');
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS,
- waitForCollectionCallback: true,
- callback: (allReportActions) => {
- Onyx.disconnect(connectionID);
- const expectedReportActionDraft = {};
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`]).toBeUndefined();
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_2`]).toBeUndefined();
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2_4`]).toBeUndefined();
- expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2`]).toMatchObject(expectedReportActionDraft);
- },
- });
- }));
-
- it("Shouldn't move empty individual draft to a draft collection of report", () =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`]: '',
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1`]: {},
- })
- .then(KeyReportActionsDraftByReportActionID)
- .then(() => {
- const connectionID = Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS,
- waitForCollectionCallback: true,
- callback: (allReportActionsDrafts) => {
- Onyx.disconnect(connectionID);
- expect(allReportActionsDrafts[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`]).toBeUndefined();
- },
- });
- }));
- });
-});
diff --git a/tests/unit/MigrationTest.ts b/tests/unit/MigrationTest.ts
new file mode 100644
index 000000000000..e50c7bdcee55
--- /dev/null
+++ b/tests/unit/MigrationTest.ts
@@ -0,0 +1,342 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import Onyx from 'react-native-onyx';
+import CONST from '@src/CONST';
+import Log from '@src/libs/Log';
+import CheckForPreviousReportActionID from '@src/libs/migrations/CheckForPreviousReportActionID';
+import KeyReportActionsDraftByReportActionID from '@src/libs/migrations/KeyReportActionsDraftByReportActionID';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {ReportActionsDraftCollectionDataSet} from '@src/types/onyx/ReportActionsDrafts';
+import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet';
+import type CollectionDataSet from '@src/types/utils/CollectionDataSet';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+jest.mock('@src/libs/getPlatform');
+
+let LogSpy: jest.SpyInstance>;
+
+describe('Migrations', () => {
+ beforeAll(() => {
+ Onyx.init({keys: ONYXKEYS});
+ LogSpy = jest.spyOn(Log, 'info');
+ Log.serverLoggingCallback = () => Promise.resolve({requestID: '123'});
+ return waitForBatchedUpdates();
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ Onyx.clear();
+ return waitForBatchedUpdates();
+ });
+
+ describe('CheckForPreviousReportActionID', () => {
+ it("Should work even if there's no reportAction data in Onyx", () =>
+ CheckForPreviousReportActionID().then(() =>
+ expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration CheckForPreviousReportActionID because there were no reportActions'),
+ ));
+
+ it('Should remove all report actions given that a previousReportActionID does not exist', () => {
+ const reportActionsCollectionDataSet = toCollectionDataSet(
+ ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ [
+ {
+ 1: {
+ reportActionID: '1',
+ created: '',
+ actionName: CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED,
+ reportID: '1',
+ },
+ 2: {reportActionID: '2', created: '', actionName: CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED, reportID: '1'},
+ },
+ ],
+ (item) => item[1].reportID ?? '',
+ );
+
+ return Onyx.multiSet(reportActionsCollectionDataSet)
+ .then(CheckForPreviousReportActionID)
+ .then(() => {
+ expect(LogSpy).toHaveBeenCalledWith(
+ '[Migrate Onyx] CheckForPreviousReportActionID Migration: removing all reportActions because previousReportActionID not found in the first valid reportAction',
+ );
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ waitForCollectionCallback: true,
+ callback: (allReportActions) => {
+ Onyx.disconnect(connectionID);
+ const expectedReportAction = {};
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction);
+ },
+ });
+ });
+ });
+
+ it('Should not remove any report action given that previousReportActionID exists in first valid report action', () => {
+ const reportActionsCollectionDataSet = toCollectionDataSet(
+ ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ [
+ {
+ 1: {
+ reportActionID: '1',
+ previousReportActionID: '0',
+ created: '',
+ actionName: CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED,
+ reportID: '1',
+ },
+ 2: {
+ reportActionID: '2',
+ previousReportActionID: '1',
+ created: '',
+ actionName: CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED,
+ reportID: '1',
+ },
+ },
+ ],
+ (item) => item[1].reportID ?? '',
+ );
+
+ return Onyx.multiSet(reportActionsCollectionDataSet)
+ .then(CheckForPreviousReportActionID)
+ .then(() => {
+ expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] CheckForPreviousReportActionID Migration: previousReportActionID found. Migration complete');
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ waitForCollectionCallback: true,
+ callback: (allReportActions) => {
+ Onyx.disconnect(connectionID);
+ const expectedReportAction = {
+ 1: {
+ reportActionID: '1',
+ previousReportActionID: '0',
+ },
+ 2: {
+ reportActionID: '2',
+ previousReportActionID: '1',
+ },
+ };
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction);
+ },
+ });
+ });
+ });
+
+ it('Should skip zombie report actions and proceed to remove all reportActions given that a previousReportActionID does not exist', () => {
+ const reportActionsCollectionDataSet = toCollectionDataSet(
+ ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ [
+ {
+ 1: {
+ reportActionID: '1',
+ created: '',
+ actionName: CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED,
+ reportID: '4',
+ },
+ 2: {
+ reportActionID: '2',
+ created: '',
+ actionName: CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED,
+ reportID: '4',
+ },
+ },
+ ],
+ (item) => item[1].reportID ?? '',
+ );
+
+ return Onyx.multiSet({
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: {},
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: null,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: null,
+ ...reportActionsCollectionDataSet,
+ })
+ .then(CheckForPreviousReportActionID)
+ .then(() => {
+ expect(LogSpy).toHaveBeenCalledWith(
+ '[Migrate Onyx] CheckForPreviousReportActionID Migration: removing all reportActions because previousReportActionID not found in the first valid reportAction',
+ );
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ waitForCollectionCallback: true,
+ callback: (allReportActions) => {
+ Onyx.disconnect(connectionID);
+ const expectedReportAction = {};
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction);
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toBeUndefined();
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toBeUndefined();
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toMatchObject(expectedReportAction);
+ },
+ });
+ });
+ });
+
+ it('Should skip zombie report actions and should not remove any report action given that previousReportActionID exists in first valid report action', () => {
+ const reportActionsCollectionDataSet = toCollectionDataSet(
+ ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ [
+ {
+ 1: {
+ reportActionID: '1',
+ previousReportActionID: '10',
+ created: '',
+ actionName: CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED,
+ reportID: '4',
+ },
+ 2: {
+ reportActionID: '2',
+ previousReportActionID: '23',
+ created: '',
+ actionName: CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED,
+ reportID: '4',
+ },
+ },
+ ],
+ (item) => item[1].reportID ?? '',
+ );
+
+ return Onyx.multiSet({
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: {},
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: null,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: null,
+ ...reportActionsCollectionDataSet,
+ })
+ .then(CheckForPreviousReportActionID)
+ .then(() => {
+ expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] CheckForPreviousReportActionID Migration: previousReportActionID found. Migration complete');
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ waitForCollectionCallback: true,
+ callback: (allReportActions) => {
+ Onyx.disconnect(connectionID);
+ const expectedReportAction1 = {};
+ const expectedReportAction4 = {
+ 1: {
+ reportActionID: '1',
+ previousReportActionID: '10',
+ },
+ 2: {
+ reportActionID: '2',
+ previousReportActionID: '23',
+ },
+ };
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction1);
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toBeUndefined();
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toBeUndefined();
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toMatchObject(expectedReportAction4);
+ },
+ });
+ });
+ });
+
+ it('Should skip if no valid reportActions', () => {
+ const setQueries: CollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: null,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: {},
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: {},
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]: null,
+ };
+ return Onyx.multiSet(setQueries)
+ .then(CheckForPreviousReportActionID)
+ .then(() => {
+ expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration CheckForPreviousReportActionID because there were no valid reportActions');
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ waitForCollectionCallback: true,
+ callback: (allReportActions) => {
+ const expectedReportAction = {};
+ Onyx.disconnect(connectionID);
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toBeUndefined();
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toMatchObject(expectedReportAction);
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toMatchObject(expectedReportAction);
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toBeUndefined();
+ },
+ });
+ });
+ });
+ });
+
+ describe('KeyReportActionsDraftByReportActionID', () => {
+ it("Should work even if there's no reportActionsDrafts data in Onyx", () =>
+ KeyReportActionsDraftByReportActionID().then(() =>
+ expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration KeyReportActionsDraftByReportActionID because there were no reportActionsDrafts'),
+ ));
+
+ it('Should move individual draft to a draft collection of report', () => {
+ const setQueries: ReportActionsDraftCollectionDataSet = {};
+
+ // @ts-expect-error preset invalid value
+ setQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`] = 'a';
+ // @ts-expect-error preset invalid value
+ setQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_2`] = 'b';
+ setQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2`] = {3: 'c'};
+ // @ts-expect-error preset invalid value
+ setQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2_4`] = 'd';
+
+ return Onyx.multiSet(setQueries)
+ .then(KeyReportActionsDraftByReportActionID)
+ .then(() => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS,
+ waitForCollectionCallback: true,
+ callback: (allReportActionsDrafts) => {
+ Onyx.disconnect(connectionID);
+ const expectedReportActionDraft1 = {
+ 1: 'a',
+ 2: 'b',
+ };
+ const expectedReportActionDraft2 = {
+ 3: 'c',
+ 4: 'd',
+ };
+ expect(allReportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`]).toBeUndefined();
+ expect(allReportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_2`]).toBeUndefined();
+ expect(allReportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2_4`]).toBeUndefined();
+ expect(allReportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1`]).toMatchObject(expectedReportActionDraft1);
+ expect(allReportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2`]).toMatchObject(expectedReportActionDraft2);
+ },
+ });
+ });
+ });
+
+ it('Should skip if nothing to migrate', () => {
+ const setQueries: ReportActionsDraftCollectionDataSet = {};
+
+ setQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2`] = {};
+
+ return Onyx.multiSet(setQueries)
+ .then(KeyReportActionsDraftByReportActionID)
+ .then(() => {
+ expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] Skipped migration KeyReportActionsDraftByReportActionID because there are no actions drafts to migrate');
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS,
+ waitForCollectionCallback: true,
+ callback: (allReportActions) => {
+ Onyx.disconnect(connectionID);
+ const expectedReportActionDraft = {};
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`]).toBeUndefined();
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_2`]).toBeUndefined();
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2_4`]).toBeUndefined();
+ expect(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}2`]).toMatchObject(expectedReportActionDraft);
+ },
+ });
+ });
+ });
+
+ it("Shouldn't move empty individual draft to a draft collection of report", () => {
+ const setQueries: ReportActionsDraftCollectionDataSet = {};
+
+ // @ts-expect-error preset empty string value
+ setQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`] = '';
+ setQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1`] = {};
+
+ return Onyx.multiSet(setQueries)
+ .then(KeyReportActionsDraftByReportActionID)
+ .then(() => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS,
+ waitForCollectionCallback: true,
+ callback: (allReportActionsDrafts) => {
+ Onyx.disconnect(connectionID);
+ expect(allReportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}1_1`]).toBeUndefined();
+ },
+ });
+ });
+ });
+ });
+});
diff --git a/tests/unit/NetworkTest.js b/tests/unit/NetworkTest.ts
similarity index 87%
rename from tests/unit/NetworkTest.js
rename to tests/unit/NetworkTest.ts
index 29f5e344b35a..63b275a1a6b6 100644
--- a/tests/unit/NetworkTest.js
+++ b/tests/unit/NetworkTest.ts
@@ -1,21 +1,24 @@
-import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import CONST from '../../src/CONST';
-import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager';
-import * as PersistedRequests from '../../src/libs/actions/PersistedRequests';
-import * as PersonalDetails from '../../src/libs/actions/PersonalDetails';
-import * as Session from '../../src/libs/actions/Session';
-import HttpUtils from '../../src/libs/HttpUtils';
-import Log from '../../src/libs/Log';
-import * as Network from '../../src/libs/Network';
-import * as MainQueue from '../../src/libs/Network/MainQueue';
-import * as NetworkStore from '../../src/libs/Network/NetworkStore';
-import NetworkConnection from '../../src/libs/NetworkConnection';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import type {Mock} from 'jest-mock';
+import MockedOnyx from 'react-native-onyx';
+import CONST from '@src/CONST';
+import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
+import * as PersistedRequests from '@src/libs/actions/PersistedRequests';
+import * as PersonalDetails from '@src/libs/actions/PersonalDetails';
+import * as Session from '@src/libs/actions/Session';
+import HttpUtils from '@src/libs/HttpUtils';
+import Log from '@src/libs/Log';
+import * as Network from '@src/libs/Network';
+import * as MainQueue from '@src/libs/Network/MainQueue';
+import * as NetworkStore from '@src/libs/Network/NetworkStore';
+import NetworkConnection from '@src/libs/NetworkConnection';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type ReactNativeOnyxMock from '../../__mocks__/react-native-onyx';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
-jest.mock('../../src/libs/Log');
+const Onyx = MockedOnyx as typeof ReactNativeOnyxMock;
+
+jest.mock('@src/libs/Log');
Onyx.init({
keys: ONYXKEYS,
@@ -25,6 +28,7 @@ OnyxUpdateManager();
const originalXHR = HttpUtils.xhr;
beforeEach(() => {
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
HttpUtils.xhr = originalXHR;
MainQueue.clear();
@@ -50,7 +54,7 @@ describe('NetworkTests', () => {
const TEST_USER_LOGIN = 'test@testguy.com';
const TEST_USER_ACCOUNT_ID = 1;
- let isOffline;
+ let isOffline: boolean | null = null;
Onyx.connect({
key: ONYXKEYS.NETWORK,
@@ -67,8 +71,9 @@ describe('NetworkTests', () => {
global.fetch = jest.fn().mockRejectedValue(new TypeError(CONST.ERROR.FAILED_TO_FETCH));
const actualXhr = HttpUtils.xhr;
- HttpUtils.xhr = jest.fn();
- HttpUtils.xhr
+
+ const mockedXhr = jest.fn();
+ mockedXhr
.mockImplementationOnce(() =>
Promise.resolve({
jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED,
@@ -100,6 +105,8 @@ describe('NetworkTests', () => {
}),
);
+ HttpUtils.xhr = mockedXhr;
+
// This should first trigger re-authentication and then a Failed to fetch
PersonalDetails.openPersonalDetails();
return waitForBatchedUpdates()
@@ -113,8 +120,8 @@ describe('NetworkTests', () => {
})
.then(() => {
// Then we will eventually have 1 call to OpenPersonalDetailsPage and 1 calls to Authenticate
- const callsToOpenPersonalDetails = _.filter(HttpUtils.xhr.mock.calls, ([command]) => command === 'OpenPersonalDetailsPage');
- const callsToAuthenticate = _.filter(HttpUtils.xhr.mock.calls, ([command]) => command === 'Authenticate');
+ const callsToOpenPersonalDetails = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'OpenPersonalDetailsPage');
+ const callsToAuthenticate = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'Authenticate');
expect(callsToOpenPersonalDetails.length).toBe(1);
expect(callsToAuthenticate.length).toBe(1);
@@ -133,8 +140,8 @@ describe('NetworkTests', () => {
// When we sign in
return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN)
.then(() => {
- HttpUtils.xhr = jest.fn();
- HttpUtils.xhr
+ const mockedXhr = jest.fn();
+ mockedXhr
// And mock the first call to openPersonalDetails return with an expired session code
.mockImplementationOnce(() =>
@@ -164,6 +171,8 @@ describe('NetworkTests', () => {
}),
);
+ HttpUtils.xhr = mockedXhr;
+
// And then make 3 API READ requests in quick succession with an expired authToken and handle the response
// It doesn't matter which requests these are really as all the response is mocked we just want to see
// that we get re-authenticated
@@ -175,8 +184,8 @@ describe('NetworkTests', () => {
.then(() => {
// We should expect to see the three calls to OpenApp, but only one call to Authenticate.
// And we should also see the reconnection callbacks triggered.
- const callsToOpenPersonalDetails = _.filter(HttpUtils.xhr.mock.calls, ([command]) => command === 'OpenPersonalDetailsPage');
- const callsToAuthenticate = _.filter(HttpUtils.xhr.mock.calls, ([command]) => command === 'Authenticate');
+ const callsToOpenPersonalDetails = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'OpenPersonalDetailsPage');
+ const callsToAuthenticate = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'Authenticate');
expect(callsToOpenPersonalDetails.length).toBe(3);
expect(callsToAuthenticate.length).toBe(1);
expect(reconnectionCallbacksSpy.mock.calls.length).toBe(3);
diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts
index 14c749fc92de..bf528eca3e81 100644
--- a/tests/unit/ReportActionsUtilsTest.ts
+++ b/tests/unit/ReportActionsUtilsTest.ts
@@ -305,116 +305,6 @@ describe('ReportActionsUtils', () => {
expect(result).toStrictEqual(input);
});
- describe('getSortedReportActionsForDisplay with marked the first reportAction', () => {
- it('should filter out non-whitelisted actions', () => {
- const input: ReportAction[] = [
- {
- created: '2022-11-13 22:27:01.825',
- reportActionID: '8401445780099176',
- actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
- originalMessage: {
- html: 'Hello world',
- whisperedTo: [],
- },
- message: [
- {
- html: 'Hello world',
- type: 'Action type',
- text: 'Action text',
- },
- ],
- },
- {
- created: '2022-11-12 22:27:01.825',
- reportActionID: '6401435781022176',
- actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
- originalMessage: {
- html: 'Hello world',
- whisperedTo: [],
- },
- message: [
- {
- html: 'Hello world',
- type: 'Action type',
- text: 'Action text',
- },
- ],
- },
- {
- created: '2022-11-11 22:27:01.825',
- reportActionID: '2962390724708756',
- actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
- originalMessage: {
- amount: 0,
- currency: 'USD',
- type: 'split', // change to const
- },
- message: [
- {
- html: 'Hello world',
- type: 'Action type',
- text: 'Action text',
- },
- ],
- },
- {
- created: '2022-11-10 22:27:01.825',
- reportActionID: '1609646094152486',
- actionName: CONST.REPORT.ACTIONS.TYPE.RENAMED,
- originalMessage: {
- html: 'Hello world',
- lastModified: '2022-11-10 22:27:01.825',
- oldName: 'old name',
- newName: 'new name',
- },
- message: [
- {
- html: 'Hello world',
- type: 'Action type',
- text: 'Action text',
- },
- ],
- },
- {
- created: '2022-11-09 22:27:01.825',
- reportActionID: '8049485084562457',
- actionName: CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.UPDATE_FIELD,
- originalMessage: {},
- message: [{html: 'updated the Approval Mode from "Submit and Approve" to "Submit and Close"', type: 'Action type', text: 'Action text'}],
- },
- {
- created: '2022-11-08 22:27:06.825',
- reportActionID: '1661970171066216',
- actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED,
- originalMessage: {
- paymentType: 'ACH',
- },
- message: [{html: 'Waiting for the bank account', type: 'Action type', text: 'Action text'}],
- },
- {
- created: '2022-11-06 22:27:08.825',
- reportActionID: '1661970171066220',
- actionName: CONST.REPORT.ACTIONS.TYPE.TASKEDITED,
- originalMessage: {
- html: 'Hello world',
- whisperedTo: [],
- },
- message: [{html: 'I have changed the task', type: 'Action type', text: 'Action text'}],
- },
- ];
-
- const resultWithoutNewestFlag = ReportActionsUtils.getSortedReportActionsForDisplay(input);
- const resultWithNewestFlag = ReportActionsUtils.getSortedReportActionsForDisplay(input, true);
- input.pop();
- // Mark the newest report action as the newest report action
- resultWithoutNewestFlag[0] = {
- ...resultWithoutNewestFlag[0],
- isNewestReportAction: true,
- };
- expect(resultWithoutNewestFlag).toStrictEqual(resultWithNewestFlag);
- });
- });
-
it('should filter out closed actions', () => {
const input: ReportAction[] = [
{
@@ -551,6 +441,1349 @@ describe('ReportActionsUtils', () => {
expect(result).toStrictEqual(input);
});
});
+ describe('getContinuousReportActionChain', () => {
+ it('given an input ID of 1, ..., 7 it will return the report actions with id 1 - 7', () => {
+ const input: ReportAction[] = [
+ // Given these sortedReportActions
+ {
+ reportActionID: '1',
+ previousReportActionID: undefined,
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '2',
+ previousReportActionID: '1',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '3',
+ previousReportActionID: '2',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '4',
+ previousReportActionID: '3',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '5',
+ previousReportActionID: '4',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '6',
+ previousReportActionID: '5',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '7',
+ previousReportActionID: '6',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+
+ // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7)
+ {
+ reportActionID: '9',
+ previousReportActionID: '8',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '10',
+ previousReportActionID: '9',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '11',
+ previousReportActionID: '10',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '12',
+ previousReportActionID: '11',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+
+ // Note: another gap
+ {
+ reportActionID: '14',
+ previousReportActionID: '13',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '15',
+ previousReportActionID: '14',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '16',
+ previousReportActionID: '15',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '17',
+ previousReportActionID: '16',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ ];
+
+ const expectedResult = [
+ {
+ reportActionID: '1',
+ previousReportActionID: undefined,
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '2',
+ previousReportActionID: '1',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '3',
+ previousReportActionID: '2',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '4',
+ previousReportActionID: '3',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '5',
+ previousReportActionID: '4',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '6',
+ previousReportActionID: '5',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '7',
+ previousReportActionID: '6',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ ];
+ // Reversing the input array to simulate descending order sorting as per our data structure
+ const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '3');
+ input.pop();
+ expect(result).toStrictEqual(expectedResult.reverse());
+ });
+
+ it('given an input ID of 9, ..., 12 it will return the report actions with id 9 - 12', () => {
+ const input: ReportAction[] = [
+ // Given these sortedReportActions
+ {
+ reportActionID: '1',
+ previousReportActionID: undefined,
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '2',
+ previousReportActionID: '1',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '3',
+ previousReportActionID: '2',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '4',
+ previousReportActionID: '3',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '5',
+ previousReportActionID: '4',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '6',
+ previousReportActionID: '5',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '7',
+ previousReportActionID: '6',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+
+ // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7)
+ {
+ reportActionID: '9',
+ previousReportActionID: '8',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '10',
+ previousReportActionID: '9',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '11',
+ previousReportActionID: '10',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '12',
+ previousReportActionID: '11',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+
+ // Note: another gap
+ {
+ reportActionID: '14',
+ previousReportActionID: '13',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '15',
+ previousReportActionID: '14',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '16',
+ previousReportActionID: '15',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '17',
+ previousReportActionID: '16',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ ];
+
+ const expectedResult = [
+ {
+ reportActionID: '9',
+ previousReportActionID: '8',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '10',
+ previousReportActionID: '9',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '11',
+ previousReportActionID: '10',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '12',
+ previousReportActionID: '11',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ ];
+ // Reversing the input array to simulate descending order sorting as per our data structure
+ const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '10');
+ input.pop();
+ expect(result).toStrictEqual(expectedResult.reverse());
+ });
+
+ it('given an input ID of 14, ..., 17 it will return the report actions with id 14 - 17', () => {
+ const input = [
+ // Given these sortedReportActions
+ {
+ reportActionID: '1',
+ previousReportActionID: undefined,
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '2',
+ previousReportActionID: '1',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '3',
+ previousReportActionID: '2',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '4',
+ previousReportActionID: '3',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '5',
+ previousReportActionID: '4',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '6',
+ previousReportActionID: '5',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '7',
+ previousReportActionID: '6',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+
+ // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7)
+ {
+ reportActionID: '9',
+ previousReportActionID: '8',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '10',
+ previousReportActionID: '9',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '11',
+ previousReportActionID: '10',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '12',
+ previousReportActionID: '11',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+
+ // Note: another gap
+ {
+ reportActionID: '14',
+ previousReportActionID: '13',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '15',
+ previousReportActionID: '14',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '16',
+ previousReportActionID: '15',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '17',
+ previousReportActionID: '16',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ ];
+
+ const expectedResult = [
+ {
+ reportActionID: '14',
+ previousReportActionID: '13',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '15',
+ previousReportActionID: '14',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '16',
+ previousReportActionID: '15',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '17',
+ previousReportActionID: '16',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ ];
+ // Reversing the input array to simulate descending order sorting as per our data structure
+ const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '16');
+ input.pop();
+ expect(result).toStrictEqual(expectedResult.reverse());
+ });
+
+ it('given an input ID of 8 or 13 which are not exist in Onyx it will return an empty array', () => {
+ const input: ReportAction[] = [
+ // Given these sortedReportActions
+ {
+ reportActionID: '1',
+ previousReportActionID: undefined,
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '2',
+ previousReportActionID: '1',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '3',
+ previousReportActionID: '2',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '4',
+ previousReportActionID: '3',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '5',
+ previousReportActionID: '4',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '6',
+ previousReportActionID: '5',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '7',
+ previousReportActionID: '6',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+
+ // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7)
+ {
+ reportActionID: '9',
+ previousReportActionID: '8',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '10',
+ previousReportActionID: '9',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '11',
+ previousReportActionID: '10',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '12',
+ previousReportActionID: '11',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+
+ // Note: another gap
+ {
+ reportActionID: '14',
+ previousReportActionID: '13',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '15',
+ previousReportActionID: '14',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '16',
+ previousReportActionID: '15',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ {
+ reportActionID: '17',
+ previousReportActionID: '16',
+ created: '2022-11-13 22:27:01.825',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
+ },
+ ];
+
+ const expectedResult: ReportAction[] = [];
+ // Reversing the input array to simulate descending order sorting as per our data structure
+ const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '8');
+ input.pop();
+ expect(result).toStrictEqual(expectedResult.reverse());
+ });
+ });
describe('getLastVisibleAction', () => {
it('should return the last visible action for a report', () => {
diff --git a/tests/unit/searchCountryOptionsTest.js b/tests/unit/searchCountryOptionsTest.ts
similarity index 100%
rename from tests/unit/searchCountryOptionsTest.js
rename to tests/unit/searchCountryOptionsTest.ts
diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx
index 85c2d67f80bc..44bfcd46d399 100644
--- a/tests/utils/LHNTestUtils.tsx
+++ b/tests/utils/LHNTestUtils.tsx
@@ -142,11 +142,14 @@ function getFakeReport(participantAccountIDs = [1, 2], millisecondsInThePast = 0
function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast = 0): ReportAction {
const timestamp = Date.now() - millisecondsInThePast;
const created = DateUtils.getDBTime(timestamp);
+ const previousReportActionID = lastFakeReportActionID;
+ const reportActionID = ++lastFakeReportActionID;
return {
actor,
actorAccountID: 1,
- reportActionID: `${++lastFakeReportActionID}`,
+ reportActionID: `${reportActionID}`,
+ previousReportActionID: `${previousReportActionID}`,
actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
shouldShow: true,
created,
@@ -183,7 +186,7 @@ function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast =
},
],
originalMessage: {
- childReportID: `${++lastFakeReportActionID}`,
+ childReportID: `${reportActionID}`,
emojiReactions: {
heart: {
createdAt: '2023-08-28 15:27:52',
diff --git a/tests/utils/PusherHelper.ts b/tests/utils/PusherHelper.ts
index 8726433c03ab..9f0f71baafd3 100644
--- a/tests/utils/PusherHelper.ts
+++ b/tests/utils/PusherHelper.ts
@@ -1,8 +1,8 @@
-import type {OnyxUpdate} from 'react-native-onyx';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import * as Pusher from '@src/libs/Pusher/pusher';
import PusherConnectionManager from '@src/libs/PusherConnectionManager';
+import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer';
import asMutable from '@src/types/utils/asMutable';
const CHANNEL_NAME = `${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}1${CONFIG.PUSHER.SUFFIX}`;
@@ -24,7 +24,7 @@ function setup() {
});
}
-function emitOnyxUpdate(args: OnyxUpdate[]) {
+function emitOnyxUpdate(args: OnyxServerUpdate[]) {
const channel = Pusher.getChannel(CHANNEL_NAME);
channel?.emit(Pusher.TYPE.MULTIPLE_EVENTS, {
type: 'pusher',
diff --git a/tests/utils/ReportTestUtils.ts b/tests/utils/ReportTestUtils.ts
index 4a4ce89d0296..3948baca3113 100644
--- a/tests/utils/ReportTestUtils.ts
+++ b/tests/utils/ReportTestUtils.ts
@@ -39,8 +39,8 @@ const getFakeReportAction = (index: number, actionName?: ActionName): ReportActi
text: 'email@test.com',
},
],
- previousReportActionID: '1',
reportActionID: index.toString(),
+ previousReportActionID: (index === 0 ? 0 : index - 1).toString(),
reportActionTimestamp: 1696243169753,
sequenceNumber: 0,
shouldShow: true,
@@ -48,7 +48,11 @@ const getFakeReportAction = (index: number, actionName?: ActionName): ReportActi
whisperedToAccountIDs: [],
} as ReportAction);
-const getMockedSortedReportActions = (length = 100): ReportAction[] => Array.from({length}, (element, index): ReportAction => getFakeReportAction(index));
+const getMockedSortedReportActions = (length = 100): ReportAction[] =>
+ Array.from({length}, (element, index): ReportAction => {
+ const actionName: ActionName = index === 0 ? 'CREATED' : 'ADDCOMMENT';
+ return getFakeReportAction(index + 1, actionName);
+ }).reverse();
const getMockedReportActionsMap = (length = 100): ReportActions => {
const mockReports: ReportActions[] = Array.from({length}, (element, index): ReportActions => {
@@ -60,6 +64,7 @@ const getMockedReportActionsMap = (length = 100): ReportActions => {
originalMessage: {
linkedReportID: reportID.toString(),
},
+ previousReportActionID: index.toString(),
} as ReportAction;
return {[reportID]: reportAction};
diff --git a/tests/utils/TestHelper.js b/tests/utils/TestHelper.js
index b26c601a1c06..c7bc95c58244 100644
--- a/tests/utils/TestHelper.js
+++ b/tests/utils/TestHelper.js
@@ -200,9 +200,10 @@ function setPersonalDetails(login, accountID) {
* @param {String} created
* @param {Number} actorAccountID
* @param {String} actionID
+ * @param {String} previousReportActionID
* @returns {Object}
*/
-function buildTestReportComment(created, actorAccountID, actionID = null) {
+function buildTestReportComment(created, actorAccountID, actionID = null, previousReportActionID = null) {
const reportActionID = actionID || NumberUtils.rand64();
return {
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
@@ -211,6 +212,7 @@ function buildTestReportComment(created, actorAccountID, actionID = null) {
message: [{type: 'COMMENT', html: `Comment ${actionID}`, text: `Comment ${actionID}`}],
reportActionID,
actorAccountID,
+ previousReportActionID,
};
}
diff --git a/tests/utils/collections/reportActions.ts b/tests/utils/collections/reportActions.ts
index dcfa896f1ae4..f19e939083d2 100644
--- a/tests/utils/collections/reportActions.ts
+++ b/tests/utils/collections/reportActions.ts
@@ -33,7 +33,7 @@ export default function createRandomReportAction(index: number): ReportAction {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actionName: rand(flattenActionNamesValues(CONST.REPORT.ACTIONS.TYPE)) as any,
reportActionID: index.toString(),
- previousReportActionID: index.toString(),
+ previousReportActionID: (index === 0 ? 0 : index - 1).toString(),
actorAccountID: index,
person: [
{