diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml
index 7e1b5fbbae90..0b32d8ee6dc1 100644
--- a/.github/actions/composite/setupNode/action.yml
+++ b/.github/actions/composite/setupNode/action.yml
@@ -1,6 +1,11 @@
name: Set up Node
description: Set up Node
+outputs:
+ cache-hit:
+ description: Was there a cache hit on the main node_modules?
+ value: ${{ steps.cache-node-modules.outputs.cache-hit }}
+
runs:
using: composite
steps:
diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml
index d2e0ec4f38e5..9eb5bc6eb409 100644
--- a/.github/workflows/failureNotifier.yml
+++ b/.github/workflows/failureNotifier.yml
@@ -88,7 +88,7 @@ jobs:
repo: context.repo.repo,
title: issueTitle,
body: issueBody,
- labels: [failureLabel, 'Daily'],
+ labels: [failureLabel, 'Hourly'],
assignees: [prMerger]
});
}
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index 7c7b51240fdb..04de0f5b5deb 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -184,6 +184,7 @@ jobs:
run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
- name: Setup Node
+ id: setup-node
uses: ./.github/actions/composite/setupNode
- name: Setup Ruby
@@ -206,7 +207,7 @@ jobs:
- name: Install cocoapods
uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350
- if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true'
+ if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true'
with:
timeout_minutes: 10
max_attempts: 5
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 157eaa16a24c..99d7a186e7ee 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 1001044306
- versionName "1.4.43-6"
+ versionCode 1001044318
+ versionName "1.4.43-18"
}
flavorDimensions "default"
diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java
index 3d16e607be49..8eff32dedf76 100644
--- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java
+++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java
@@ -15,6 +15,8 @@
import android.graphics.PorterDuff.Mode;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
+import android.media.AudioAttributes;
+import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
@@ -31,6 +33,7 @@
import androidx.core.graphics.drawable.IconCompat;
import androidx.versionedparcelable.ParcelUtils;
+import com.expensify.chat.R;
import com.urbanairship.AirshipConfigOptions;
import com.urbanairship.json.JsonMap;
import com.urbanairship.json.JsonValue;
@@ -105,6 +108,9 @@ protected NotificationCompat.Builder onExtendBuilder(@NonNull Context context, @
builder.setChannelId(CHANNEL_MESSAGES_ID);
} else {
builder.setPriority(PRIORITY_MAX);
+ // Set sound for versions below Oreo
+ // for Oreo and above we set sound on the notification's channel level
+ builder.setSound(getSoundFile(context));
}
// Attempt to parse data and apply custom notification styling
@@ -130,6 +136,13 @@ private void createAndRegisterNotificationChannel(@NonNull Context context) {
NotificationChannelGroup channelGroup = new NotificationChannelGroup(NOTIFICATION_GROUP_CHATS, CHANNEL_GROUP_NAME);
NotificationChannel channel = new NotificationChannel(CHANNEL_MESSAGES_ID, CHANNEL_MESSAGES_NAME, NotificationManager.IMPORTANCE_HIGH);
+ AudioAttributes audioAttributes = new AudioAttributes.Builder()
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION)
+ .build();
+
+ channel.setSound(getSoundFile(context), audioAttributes);
+
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.createNotificationChannelGroup(channelGroup);
notificationManager.createNotificationChannel(channel);
@@ -333,4 +346,8 @@ private Bitmap fetchIcon(@NonNull Context context, String urlString) {
return null;
}
+
+ private Uri getSoundFile(Context context) {
+ return Uri.parse("android.resource://" + context.getPackageName() + "/" + R.raw.receive);
+ }
}
diff --git a/assets/images/folder.svg b/assets/images/folder.svg
new file mode 100644
index 000000000000..17cef959132f
--- /dev/null
+++ b/assets/images/folder.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/product-illustrations/emptystate__expenses.svg b/assets/images/product-illustrations/emptystate__expenses.svg
new file mode 100644
index 000000000000..c01a89109cbf
--- /dev/null
+++ b/assets/images/product-illustrations/emptystate__expenses.svg
@@ -0,0 +1,58 @@
+
diff --git a/assets/images/product-illustrations/mushroom-top-hat.svg b/assets/images/product-illustrations/mushroom-top-hat.svg
new file mode 100644
index 000000000000..cb808f7289e0
--- /dev/null
+++ b/assets/images/product-illustrations/mushroom-top-hat.svg
@@ -0,0 +1,142 @@
+
+
+
diff --git a/assets/images/simple-illustrations/simple-illustration__approval.svg b/assets/images/simple-illustrations/simple-illustration__approval.svg
new file mode 100644
index 000000000000..bdef2436958b
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__approval.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__folder-open.svg b/assets/images/simple-illustrations/simple-illustration__folder-open.svg
new file mode 100644
index 000000000000..c104313a9b6c
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__folder-open.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg b/assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg
new file mode 100644
index 000000000000..fc7082e9932c
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg
@@ -0,0 +1 @@
+
diff --git a/assets/images/simple-illustrations/simple-illustration__wallet-alt.svg b/assets/images/simple-illustrations/simple-illustration__wallet-alt.svg
new file mode 100644
index 000000000000..33d1fc0fa044
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__wallet-alt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__workflows.svg b/assets/images/simple-illustrations/simple-illustration__workflows.svg
new file mode 100644
index 000000000000..47d30d54310f
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__workflows.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/workflows.svg b/assets/images/workflows.svg
new file mode 100644
index 000000000000..24156c66eb69
--- /dev/null
+++ b/assets/images/workflows.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md
index 09dd4de2867b..3fe5ec41f5f6 100644
--- a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md
+++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md
@@ -3,7 +3,7 @@ title: Billing Overview
description: An overview of how billing works in Expensify.
---
# Overview
-Expensify’s billing is based on monthly member activity. At the beginning of each month, you’ll be billed for the previous month’s activity. Your Expensify bill ultimately depends on your plan type, whether you're on an annual subscription or pay-per-use billing, and whether you’re using Expensify Cards.
+Expensify’s billing is based on monthly member activity. At the beginning of each month, you’ll be billed for the previous month’s activity. Your Expensify bill ultimately depends on your plan type, whether you're on an annual subscription or pay-per-use billing, and whether you’re using the Expensify Visa® Commercial Card.
# How billing works in Expensify
Expensify bills the owners of Group Workspaces on the first of each month for the previous month's usage. You can find billing receipts in **Settings > Account > Payments > Billing History**. We recommend that businesses have one billing owner for all of their Group Workspaces.
## Active members
@@ -23,7 +23,7 @@ Bundling the Expensify Card with an annual subscription ensures you pay the lowe
If at least 50% of your approved USD spend in a given month is on your company’s Expensify Cards, you will receive an additional 50% discount on the price per member. This additional 50% discount, when coupled with an annual subscription, brings the price per member to $5 on a Collect plan and $9 on a Control plan.
-Additionally, every month, you receive 1% cash back on all Expensify Card purchases, and 2% if the spend across your Expensify Cards is $250k or more. Any cash back from the Expensify Card is first applied to your Expensify bill, further reducing your price per member. Any leftover cash back is deposited directly into your connected bank account.
+Additionally, every month, you receive 1% cash back on all Expensify Card purchases, and 2% if the spend across your Expensify Cards is $250k or more (_applies to USD purchases only_). Any cash back from the Expensify Card is first applied to your Expensify bill, further reducing your price per member. Any leftover cash back is deposited directly into your connected bank account.
## Savings calculator
To see how much money you can save (and even earn!) by using the Expensify Card, check out our [savings calculator](https://use.expensify.com/price-savings-calculator). Just enter a few details and see how much you’ll save!
{% include faq-begin.md %}
diff --git a/docs/articles/expensify-classic/getting-started/Invite-Members.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members.md
similarity index 100%
rename from docs/articles/expensify-classic/getting-started/Invite-Members.md
rename to docs/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members.md
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 76b7bac3fc99..4ed309467f13 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -25,16 +25,16 @@ https://community.expensify.com/discussion/5194/how-to-assign-a-vacation-delegat
https://community.expensify.com/discussion/5190/how-to-individually-assign-a-vacation-delegate-from-account-settings,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate
https://community.expensify.com/discussion/5274/how-to-set-up-an-adp-indirect-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/integrations/HR-integrations/ADP
https://community.expensify.com/discussion/5776/how-to-create-mileage-expenses-in-expensify,https://help.expensify.com/articles/expensify-classic/get-paid-back/Distance-Tracking
-https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
-https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
-https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
-https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
-https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
+https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks.html,https://use.expensify.com/company-credit-card
https://help.expensify.com/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.html,https://use.expensify.com/accountants-program
https://help.expensify.com/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners, https://use.expensify.com/blog/maximizing-rewards-expensifyapproved-accounting-partners-now-earn-0-5-revenue-share
https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements
-https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts
+https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts
https://community.expensify.com/discussion/4783/how-to-add-or-remove-a-copilot,https://help.expensify.com/articles/expensify-classic/account-settings/Copilot
https://community.expensify.com/discussion/4343/expensify-anz-partnership-announcement,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-ANZ
https://community.expensify.com/discussion/7318/deep-dive-company-credit-card-import-options,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards
@@ -54,3 +54,9 @@ https://help.expensify.com/articles/expensify-classic/getting-started/Employees,
https://help.expensify.com/articles/expensify-classic/getting-started/Using-The-App,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace
https://help.expensify.com/articles/expensify-classic/getting-started/support/Expensify-Support,https://use.expensify.com/support
https://help.expensify.com/articles/expensify-classic/getting-started/Plan-Types,https://use.expensify.com/
+https://help.expensify.com/articles/new-expensify/payments/Referral-Program,https://help.expensify.com/articles/expensify-classic/get-paid-back/Referral-Program
+https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://help.expensify.com/articles/expensify-classic/account-settings/Preferences,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts
+https://help.expensify.com/articles/expensify-classic/getting-started/Individual-Users,https://help.expensify.com/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself
+https://help.expensify.com/articles/expensify-classic/getting-started/Invite-Members,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 1d97482676c8..574657c8c3f4 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.43.6
+ 1.4.43.18
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 28fdd7e1f174..e4962c94df8d 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.43.6
+ 1.4.43.18
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 796fa42f5306..308c4314ee68 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
1.4.43
CFBundleVersion
- 1.4.43.6
+ 1.4.43.18
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/NotificationServiceExtension/NotificationService.swift b/ios/NotificationServiceExtension/NotificationService.swift
index c4eb01981bf2..e489cb368d17 100644
--- a/ios/NotificationServiceExtension/NotificationService.swift
+++ b/ios/NotificationServiceExtension/NotificationService.swift
@@ -24,6 +24,8 @@ class NotificationService: UANotificationServiceExtension {
return
}
+ bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName("receive.mp3"))
+
if #available(iOSApplicationExtension 15.0, *) {
configureCommunicationNotification(notificationContent: bestAttemptContent, contentHandler: contentHandler)
} else {
diff --git a/jest.config.js b/jest.config.js
index 95ecc350ed9f..441507af4228 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -8,7 +8,7 @@ module.exports = {
`/?(*.)+(spec|test).${testFileExtension}`,
],
transform: {
- '^.+\\.jsx?$': 'babel-jest',
+ '^.+\\.[jt]sx?$': 'babel-jest',
'^.+\\.svg?$': 'jest-transformer-svg',
},
transformIgnorePatterns: ['/node_modules/(?!react-native)/'],
diff --git a/package-lock.json b/package-lock.json
index 2b8f1b57a95b..aab783e8bbb7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.43-6",
+ "version": "1.4.43-18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.43-6",
+ "version": "1.4.43-18",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -38,6 +38,7 @@
"@react-native-picker/picker": "2.5.1",
"@react-navigation/material-top-tabs": "^6.6.3",
"@react-navigation/native": "6.1.8",
+ "@react-navigation/native-stack": "^6.9.17",
"@react-navigation/stack": "6.3.16",
"@react-ng/bounds-observer": "^0.2.1",
"@rnmapbox/maps": "^10.1.11",
@@ -97,7 +98,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.2",
+ "react-native-onyx": "2.0.6",
"react-native-pager-view": "6.2.2",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -236,7 +237,7 @@
"style-loader": "^2.0.0",
"time-analytics-webpack-plugin": "^0.1.17",
"ts-node": "^10.9.2",
- "type-fest": "^3.12.0",
+ "type-fest": "^4.10.2",
"typescript": "^5.3.2",
"wait-port": "^0.2.9",
"webpack": "^5.76.0",
@@ -8138,9 +8139,9 @@
}
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
- "version": "0.5.10",
- "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz",
- "integrity": "sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==",
+ "version": "0.5.11",
+ "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz",
+ "integrity": "sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==",
"dev": true,
"dependencies": {
"ansi-html-community": "^0.0.8",
@@ -8160,7 +8161,7 @@
"@types/webpack": "4.x || 5.x",
"react-refresh": ">=0.10.0 <1.0.0",
"sockjs-client": "^1.4.0",
- "type-fest": ">=0.17.0 <4.0.0",
+ "type-fest": ">=0.17.0 <5.0.0",
"webpack": ">=4.43.0 <6.0.0",
"webpack-dev-server": "3.x || 4.x",
"webpack-hot-middleware": "2.x",
@@ -10258,6 +10259,17 @@
"react": "*"
}
},
+ "node_modules/@react-navigation/elements": {
+ "version": "1.3.21",
+ "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.21.tgz",
+ "integrity": "sha512-eyS2C6McNR8ihUoYfc166O1D8VYVh9KIl0UQPI8/ZJVsStlfSTgeEEh+WXge6+7SFPnZ4ewzEJdSAHH+jzcEfg==",
+ "peerDependencies": {
+ "@react-navigation/native": "^6.0.0",
+ "react": "*",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 3.0.0"
+ }
+ },
"node_modules/@react-navigation/material-top-tabs": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/@react-navigation/material-top-tabs/-/material-top-tabs-6.6.3.tgz",
@@ -10289,6 +10301,22 @@
"react-native": "*"
}
},
+ "node_modules/@react-navigation/native-stack": {
+ "version": "6.9.17",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-6.9.17.tgz",
+ "integrity": "sha512-X8p8aS7JptQq7uZZNFEvfEcPf6tlK4PyVwYDdryRbG98B4bh2wFQYMThxvqa+FGEN7USEuHdv2mF0GhFKfX0ew==",
+ "dependencies": {
+ "@react-navigation/elements": "^1.3.21",
+ "warn-once": "^0.1.0"
+ },
+ "peerDependencies": {
+ "@react-navigation/native": "^6.0.0",
+ "react": "*",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 3.0.0",
+ "react-native-screens": ">= 3.0.0"
+ }
+ },
"node_modules/@react-navigation/routers": {
"version": "6.1.9",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz",
@@ -10315,17 +10343,6 @@
"react-native-screens": ">= 3.0.0"
}
},
- "node_modules/@react-navigation/stack/node_modules/@react-navigation/elements": {
- "version": "1.3.17",
- "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz",
- "integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA==",
- "peerDependencies": {
- "@react-navigation/native": "^6.0.0",
- "react": "*",
- "react-native": "*",
- "react-native-safe-area-context": ">= 3.0.0"
- }
- },
"node_modules/@react-ng/bounds-observer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@react-ng/bounds-observer/-/bounds-observer-0.2.1.tgz",
@@ -26853,10 +26870,11 @@
}
},
"node_modules/core-js-pure": {
- "version": "3.24.1",
+ "version": "3.36.0",
+ "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.36.0.tgz",
+ "integrity": "sha512-cN28qmhRNgbMZZMc/RFu5w8pK9VJzpb2rJVR/lHuZJKwmXnoWOpXmMkxqBB514igkp1Hu8WGROsiOAzUcKdHOQ==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -38605,6 +38623,17 @@
"node": ">=8"
}
},
+ "node_modules/jest-watch-typeahead/node_modules/type-fest": {
+ "version": "3.13.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
+ "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/jest-watcher": {
"version": "29.4.1",
"license": "MIT",
@@ -45141,9 +45170,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.2.tgz",
- "integrity": "sha512-24kcG3ChBXp+uSSCXudFvZTdCnKLRHQRgvTcnh2eA7COtKvbL8ggEJNkglSYmcf5WoDzLgYyWiKvcjcXQnmBvw==",
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.6.tgz",
+ "integrity": "sha512-qsCxvNKc+mq/Y74v6Twe7VZxqgfpjBm0997R8OEtCUJEtgAp0riCQ3GvuVVIWYALz3S+ADokEAEPzeFW2frtpw==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -50832,11 +50861,12 @@
}
},
"node_modules/type-fest": {
- "version": "3.13.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
- "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+ "version": "4.10.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.3.tgz",
+ "integrity": "sha512-JLXyjizi072smKGGcZiAJDCNweT8J+AuRxmPZ1aG7TERg4ijx9REl8CNhbr36RV4qXqL1gO1FF9HL8OkVmmrsA==",
+ "dev": true,
"engines": {
- "node": ">=14.16"
+ "node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
diff --git a/package.json b/package.json
index 75be3665a4d6..f5ff807cdbec 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.43-6",
+ "version": "1.4.43-18",
"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.",
@@ -50,8 +50,8 @@
"analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production",
"symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map",
"symbolicate:ios": "npx metro-symbolicate main.jsbundle.map",
- "test:e2e": "ts-node tests/e2e/testRunner.js --development --skipCheckout --skipInstallDeps --buildMode none",
- "test:e2e:dev": "ts-node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps",
+ "test:e2e": "ts-node tests/e2e/testRunner.js --config ./config.local.ts",
+ "test:e2e:dev": "ts-node tests/e2e/testRunner.js --config ./config.dev.js",
"gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh",
"workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh",
"workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.js",
@@ -86,6 +86,7 @@
"@react-native-picker/picker": "2.5.1",
"@react-navigation/material-top-tabs": "^6.6.3",
"@react-navigation/native": "6.1.8",
+ "@react-navigation/native-stack": "^6.9.17",
"@react-navigation/stack": "6.3.16",
"@react-ng/bounds-observer": "^0.2.1",
"@rnmapbox/maps": "^10.1.11",
@@ -145,7 +146,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.2",
+ "react-native-onyx": "2.0.6",
"react-native-pager-view": "6.2.2",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -284,7 +285,7 @@
"style-loader": "^2.0.0",
"time-analytics-webpack-plugin": "^0.1.17",
"ts-node": "^10.9.2",
- "type-fest": "^3.12.0",
+ "type-fest": "^4.10.2",
"typescript": "^5.3.2",
"wait-port": "^0.2.9",
"webpack": "^5.76.0",
diff --git a/patches/@react-navigation+native-stack+6.9.17.patch b/patches/@react-navigation+native-stack+6.9.17.patch
new file mode 100644
index 000000000000..933ca6ce792e
--- /dev/null
+++ b/patches/@react-navigation+native-stack+6.9.17.patch
@@ -0,0 +1,39 @@
+diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx
+index 206fb0b..7a34a8e 100644
+--- a/node_modules/@react-navigation/native-stack/src/types.tsx
++++ b/node_modules/@react-navigation/native-stack/src/types.tsx
+@@ -490,6 +490,14 @@ export type NativeStackNavigationOptions = {
+ * Only supported on iOS and Android.
+ */
+ freezeOnBlur?: boolean;
++ // partial changes from https://github.com/react-navigation/react-navigation/commit/90cfbf23bcc5259f3262691a9eec6c5b906e5262
++ // patch can be removed when new version of `native-stack` will be released
++ /**
++ * Whether the keyboard should hide when swiping to the previous screen. Defaults to `false`.
++ *
++ * Only supported on iOS
++ */
++ keyboardHandlingEnabled?: boolean;
+ };
+
+ export type NativeStackNavigatorProps = DefaultNavigatorOptions<
+diff --git a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx
+index a005c43..03d8b50 100644
+--- a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx
++++ b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx
+@@ -161,6 +161,7 @@ const SceneView = ({
+ statusBarTranslucent,
+ statusBarColor,
+ freezeOnBlur,
++ keyboardHandlingEnabled,
+ } = options;
+
+ let {
+@@ -289,6 +290,7 @@ const SceneView = ({
+ onNativeDismissCancelled={onNativeDismissCancelled}
+ // this prop is available since rn-screens 3.16
+ freezeOnBlur={freezeOnBlur}
++ hideKeyboardOnSwipe={keyboardHandlingEnabled}
+ >
+
+
diff --git a/patches/expo-av+13.10.4.patch b/patches/expo-av+13.10.4.patch
new file mode 100644
index 000000000000..c7b1626e233a
--- /dev/null
+++ b/patches/expo-av+13.10.4.patch
@@ -0,0 +1,17 @@
+diff --git a/node_modules/expo-av/android/build.gradle b/node_modules/expo-av/android/build.gradle
+index 2d68ca6..c3fa3c5 100644
+--- a/node_modules/expo-av/android/build.gradle
++++ b/node_modules/expo-av/android/build.gradle
+@@ -7,10 +7,11 @@ apply plugin: 'maven-publish'
+ group = 'host.exp.exponent'
+ version = '13.10.4'
+
++def REACT_NATIVE_PATH = this.hasProperty('reactNativeProject') ? this.reactNativeProject + '/node_modules/react-native/package.json' : 'react-native/package.json'
+ def REACT_NATIVE_BUILD_FROM_SOURCE = findProject(":ReactAndroid") != null
+ def REACT_NATIVE_DIR = REACT_NATIVE_BUILD_FROM_SOURCE
+ ? findProject(":ReactAndroid").getProjectDir().parent
+- : new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).parent
++ : new File(["node", "--print", "require.resolve('${REACT_NATIVE_PATH}')"].execute(null, rootDir).text.trim()).parent
+
+ def reactNativeArchitectures() {
+ def value = project.getProperties().get("reactNativeArchitectures")
diff --git a/src/App.tsx b/src/App.tsx
index 9562ea647e25..cbe5948f8d4e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,7 +2,6 @@ import {PortalProvider} from '@gorhom/portal';
import React from 'react';
import {LogBox} from 'react-native';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
-import Onyx from 'react-native-onyx';
import {PickerStateProvider} from 'react-native-picker-select';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import '../wdyr';
@@ -31,8 +30,6 @@ import {WindowDimensionsProvider} from './components/withWindowDimensions';
import Expensify from './Expensify';
import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop';
import OnyxUpdateManager from './libs/actions/OnyxUpdateManager';
-import * as Session from './libs/actions/Session';
-import * as Environment from './libs/Environment/Environment';
import InitialUrlContext from './libs/InitialUrlContext';
import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext';
import type {Route} from './ROUTES';
@@ -42,12 +39,6 @@ type AppProps = {
url?: Route;
};
-// For easier debugging and development, when we are in web we expose Onyx to the window, so you can more easily set data into Onyx
-if (window && Environment.isDevelopment()) {
- window.Onyx = Onyx;
- window.setSupportToken = Session.setSupportAuthToken;
-}
-
LogBox.ignoreLogs([
// Basically it means that if the app goes in the background and back to foreground on Android,
// the timer is lost. Currently Expensify is using a 30 minutes interval to refresh personal details.
diff --git a/src/CONST.ts b/src/CONST.ts
index 6a57738d06ec..8abd4c087b16 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -895,6 +895,7 @@ const CONST = {
DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'},
DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
+ DEFAULT_NETWORK_DATA: {isOffline: false},
FORMS: {
LOGIN_FORM: 'LoginForm',
VALIDATE_CODE_FORM: 'ValidateCodeForm',
@@ -951,6 +952,9 @@ const CONST = {
EMOJI_DEFAULT_SKIN_TONE: -1,
+ // Amount of emojis to render ahead at the end of the update cycle
+ EMOJI_DRAW_AMOUNT: 250,
+
INVISIBLE_CODEPOINTS: ['fe0f', '200d', '2066'],
UNICODE: {
@@ -1554,7 +1558,9 @@ const CONST = {
WORKSPACE_INVOICES: 'WorkspaceSendInvoices',
WORKSPACE_TRAVEL: 'WorkspaceBookTravel',
WORKSPACE_MEMBERS: 'WorkspaceManageMembers',
+ WORKSPACE_WORKFLOWS: 'WorkspaceWorkflows',
WORKSPACE_BANK_ACCOUNT: 'WorkspaceBankAccount',
+ WORKSPACE_SETTINGS: 'WorkspaceSettings',
},
get EXPENSIFY_EMAILS() {
return [
@@ -3106,6 +3112,8 @@ const CONST = {
*/
ADDITIONAL_ALLOWED_CHARACTERS: 20,
+ VALIDATION_REIMBURSEMENT_INPUT_LIMIT: 20,
+
REFERRAL_PROGRAM: {
CONTENT_TYPES: {
MONEY_REQUEST: 'request',
@@ -3303,6 +3311,14 @@ const CONST = {
ADDRESS: 3,
},
},
+
+ EXIT_SURVEY: {
+ REASONS: {
+ FEATURE_NOT_AVAILABLE: 'featureNotAvailable',
+ DONT_UNDERSTAND: 'dontUnderstand',
+ PREFER_CLASSIC: 'preferClassic',
+ },
+ },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 9d35994875e1..f2d606bd62a6 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -205,6 +205,9 @@ const ONYXKEYS = {
/** Is report data loading? */
IS_LOADING_APP: 'isLoadingApp',
+ /** Is the user in the process of switching to OldDot? */
+ IS_SWITCHING_TO_OLD_DOT: 'isSwitchingToOldDot',
+
/** Is the test tools modal open? */
IS_TEST_TOOLS_MODAL_OPEN: 'isTestToolsModalOpen',
@@ -279,7 +282,6 @@ const ONYXKEYS = {
POLICY_CATEGORIES: 'policyCategories_',
POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_',
POLICY_TAGS: 'policyTags_',
- POLICY_TAX_RATE: 'policyTaxRates_',
POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_',
POLICY_REPORT_FIELDS: 'policyReportFields_',
WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_',
@@ -388,6 +390,10 @@ const ONYXKEYS = {
REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft',
PERSONAL_BANK_ACCOUNT: 'personalBankAccountForm',
PERSONAL_BANK_ACCOUNT_DRAFT: 'personalBankAccountFormDraft',
+ EXIT_SURVEY_REASON_FORM: 'exitSurveyReasonForm',
+ EXIT_SURVEY_REASON_FORM_DRAFT: 'exitSurveyReasonFormDraft',
+ EXIT_SURVEY_RESPONSE_FORM: 'exitSurveyResponseForm',
+ EXIT_SURVEY_RESPONSE_FORM_DRAFT: 'exitSurveyResponseFormDraft',
},
} as const;
@@ -396,36 +402,37 @@ type AllOnyxKeys = DeepValueOf;
type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: FormTypes.AddDebitCardForm;
[ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm;
- [ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceProfileDescriptionForm;
[ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm;
[ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm;
- [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm;
[ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm;
[ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm;
- [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm;
+ [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm;
+ [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.WorkspaceInviteMessageForm;
[ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: FormTypes.DateOfBirthForm;
- [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: FormTypes.HomeAddressForm;
[ONYXKEYS.FORMS.NEW_ROOM_FORM]: FormTypes.NewRoomForm;
- [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.RoomSettingsForm;
+ [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.NewTaskForm;
+ [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.EditTaskForm;
+ [ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM]: FormTypes.ExitSurveyReasonForm;
+ [ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM]: FormTypes.ExitSurveyResponseForm;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.MoneyRequestDescriptionForm;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.MoneyRequestMerchantForm;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.MoneyRequestAmountForm;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.MoneyRequestDateForm;
[ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm;
- [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.NewContactMethodForm;
+ [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.WaypointForm;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.SettingsStatusSetForm;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.SettingsStatusClearDateForm;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: FormTypes.SettingsStatusSetClearAfterForm;
[ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: FormTypes.PrivateNotesForm;
[ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: FormTypes.IKnowTeacherForm;
[ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: FormTypes.IntroSchoolPrincipalForm;
- [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.Form;
- [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.ReportVirtualCardFraudForm;
+ [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.ReportPhysicalCardForm;
[ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: FormTypes.GetPhysicalCardForm;
[ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: FormTypes.ReportFieldEditForm;
[ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm;
@@ -469,7 +476,6 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.SELECTED_TAB]: string;
[ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string;
[ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStep;
- [ONYXKEYS.COLLECTION.POLICY_TAX_RATE]: string[];
};
type OnyxValuesMapping = {
@@ -491,7 +497,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails;
[ONYXKEYS.TASK]: OnyxTypes.Task;
[ONYXKEYS.WORKSPACE_RATE_AND_UNIT]: OnyxTypes.WorkspaceRateAndUnit;
- [ONYXKEYS.CURRENCY_LIST]: Record;
+ [ONYXKEYS.CURRENCY_LIST]: OnyxTypes.CurrencyList;
[ONYXKEYS.UPDATE_AVAILABLE]: boolean;
[ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest;
[ONYXKEYS.COUNTRY_CODE]: number;
@@ -535,6 +541,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean;
[ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean;
[ONYXKEYS.IS_LOADING_APP]: boolean;
+ [ONYXKEYS.IS_SWITCHING_TO_OLD_DOT]: boolean;
[ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer;
[ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string;
[ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: boolean;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index c5480d363019..a8786bda3ffb 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -84,28 +84,28 @@ const ROUTES = {
SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links',
SETTINGS_WALLET: 'settings/wallet',
SETTINGS_WALLET_DOMAINCARD: {
- route: '/settings/wallet/card/:domain',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}` as const,
+ route: 'settings/wallet/card/:domain',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}` as const,
},
SETTINGS_REPORT_FRAUD: {
- route: '/settings/wallet/card/:domain/report-virtual-fraud',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud` as const,
+ route: 'settings/wallet/card/:domain/report-virtual-fraud',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/report-virtual-fraud` as const,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: {
- route: '/settings/wallet/card/:domain/get-physical/name',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name` as const,
+ route: 'settings/wallet/card/:domain/get-physical/name',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/name` as const,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: {
- route: '/settings/wallet/card/:domain/get-physical/phone',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone` as const,
+ route: 'settings/wallet/card/:domain/get-physical/phone',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/phone` as const,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: {
- route: '/settings/wallet/card/:domain/get-physical/address',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address` as const,
+ route: 'settings/wallet/card/:domain/get-physical/address',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/address` as const,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: {
- route: '/settings/wallet/card/:domain/get-physical/confirm',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm` as const,
+ route: 'settings/wallet/card/:domain/get-physical/confirm',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/confirm` as const,
},
SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card',
SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account',
@@ -117,8 +117,8 @@ const ROUTES = {
SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance',
SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account',
SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED: {
- route: '/settings/wallet/card/:domain/report-card-lost-or-damaged',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged` as const,
+ route: 'settings/wallet/card/:domain/report-card-lost-or-damaged',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/report-card-lost-or-damaged` as const,
},
SETTINGS_WALLET_CARD_ACTIVATE: {
route: 'settings/wallet/card/:domain/activate',
@@ -159,6 +159,17 @@ const ROUTES = {
getRoute: (source: string) => `settings/troubleshoot/console/share-log?source=${encodeURI(source)}` as const,
},
+ SETTINGS_EXIT_SURVEY_REASON: 'settings/exit-survey/reason',
+ SETTINGS_EXIT_SURVEY_RESPONSE: {
+ route: 'settings/exit-survey/response',
+ getRoute: (reason?: ValueOf, backTo?: string) =>
+ getUrlWithBackToParam(`settings/exit-survey/response${reason ? `?reason=${encodeURIComponent(reason)}` : ''}`, backTo),
+ },
+ SETTINGS_EXIT_SURVEY_CONFIRM: {
+ route: 'settings/exit-survey/confirm',
+ getRoute: (backTo?: string) => getUrlWithBackToParam('settings/exit-survey/confirm', backTo),
+ },
+
KEYBOARD_SHORTCUTS: 'keyboard-shortcuts',
NEW: 'new',
@@ -219,6 +230,10 @@ const ROUTES = {
route: 'r/:reportID/settings/who-can-post',
getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post` as const,
},
+ REPORT_SETTINGS_VISIBILITY: {
+ route: 'r/:reportID/settings/visibility',
+ getRoute: (reportID: string) => `r/${reportID}/settings/visibility` as const,
+ },
SPLIT_BILL_DETAILS: {
route: 'r/:reportID/split/:reportActionID',
getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}` as const,
@@ -283,10 +298,6 @@ const ROUTES = {
route: ':iouType/new/currency/:reportID?',
getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const,
},
- MONEY_REQUEST_CATEGORY: {
- route: ':iouType/new/category/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const,
- },
MONEY_REQUEST_HOLD_REASON: {
route: ':iouType/edit/reason/:transactionID?',
getRoute: (iouType: string, transactionID: string, reportID: string, backTo: string) => `${iouType}/edit/reason/${transactionID}?backTo=${backTo}&reportID=${reportID}` as const,
@@ -334,9 +345,9 @@ const ROUTES = {
getUrlWithBackToParam(`create/${iouType}/taxAmount/${transactionID}/${reportID}`, backTo),
},
MONEY_REQUEST_STEP_CATEGORY: {
- route: 'create/:iouType/category/:transactionID/:reportID',
- getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
- getUrlWithBackToParam(`create/${iouType}/category/${transactionID}/${reportID}`, backTo),
+ route: ':action/:iouType/category/:transactionID/:reportID',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
+ getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}`, backTo),
},
MONEY_REQUEST_STEP_CURRENCY: {
route: 'create/:iouType/currency/:transactionID/:reportID/:pageIndex?',
@@ -459,6 +470,10 @@ const ROUTES = {
route: 'workspace/:policyID/profile/description',
getRoute: (policyID: string) => `workspace/${policyID}/profile/description` as const,
},
+ WORKSPACE_PROFILE_SHARE: {
+ route: 'workspace/:policyID/profile/share',
+ getRoute: (policyID: string) => `workspace/${policyID}/profile/share` as const,
+ },
WORKSPACE_AVATAR: {
route: 'workspace/:policyID/avatar',
getRoute: (policyID: string) => `workspace/${policyID}/avatar` as const,
@@ -467,6 +482,10 @@ const ROUTES = {
route: 'workspace/:policyID/settings/currency',
getRoute: (policyID: string) => `workspace/${policyID}/settings/currency` as const,
},
+ WORKSPACE_WORKFLOWS: {
+ route: 'workspace/:policyID/workflows',
+ getRoute: (policyID: string) => `workspace/${policyID}/workflows` as const,
+ },
WORKSPACE_CARD: {
route: 'workspace/:policyID/card',
getRoute: (policyID: string) => `workspace/${policyID}/card` as const,
@@ -503,6 +522,10 @@ const ROUTES = {
route: 'workspace/:policyID/members',
getRoute: (policyID: string) => `workspace/${policyID}/members` as const,
},
+ WORKSPACE_CATEGORIES: {
+ route: 'workspace/:policyID/categories',
+ getRoute: (policyID: string) => `workspace/${policyID}/categories` as const,
+ },
// Referral program promotion
REFERRAL_DETAILS_MODAL: {
route: 'referral/:contentType',
@@ -542,4 +565,4 @@ type Route = RouteIsPlainString extends true ? never : AllRoutes;
type HybridAppRoute = (typeof HYBRID_APP_ROUTES)[keyof typeof HYBRID_APP_ROUTES];
-export type {Route, HybridAppRoute};
+export type {Route, HybridAppRoute, AllRoutes};
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index ee3c64e8d804..520895c89c98 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -80,6 +80,12 @@ const SCREENS = {
REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud',
CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address',
},
+
+ EXIT_SURVEY: {
+ REASON: 'Settings_ExitSurvey_Reason',
+ RESPONSE: 'Settings_ExitSurvey_Response',
+ CONFIRM: 'Settings_ExitSurvey_Confirm',
+ },
},
SAVE_THE_WORLD: {
ROOT: 'SaveTheWorld_Root',
@@ -149,7 +155,6 @@ const SCREENS = {
PARTICIPANTS: 'Money_Request_Participants',
CONFIRMATION: 'Money_Request_Confirmation',
CURRENCY: 'Money_Request_Currency',
- CATEGORY: 'Money_Request_Category',
WAYPOINT: 'Money_Request_Waypoint',
EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint',
DISTANCE: 'Money_Request_Distance',
@@ -167,6 +172,7 @@ const SCREENS = {
ROOM_NAME: 'Report_Settings_Room_Name',
NOTIFICATION_PREFERENCES: 'Report_Settings_Notification_Preferences',
WRITE_CAPABILITY: 'Report_Settings_Write_Capability',
+ VISIBILITY: 'Report_Settings_Visibility',
},
NEW_TASK: {
@@ -207,8 +213,11 @@ const SCREENS = {
MEMBERS: 'Workspace_Members',
INVITE: 'Workspace_Invite',
INVITE_MESSAGE: 'Workspace_Invite_Message',
+ CATEGORIES: 'Workspace_Categories',
CURRENCY: 'Workspace_Profile_Currency',
+ WORKFLOWS: 'Workspace_Workflows',
DESCRIPTION: 'Workspace_Profile_Description',
+ SHARE: 'Workspace_Profile_Share',
NAME: 'Workspace_Profile_Name',
},
diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx
index 89e87eeebe54..8ad26e5a7c46 100644
--- a/src/components/AddressSearch/index.tsx
+++ b/src/components/AddressSearch/index.tsx
@@ -272,7 +272,7 @@ function AddressSearch(
const renderHeaderComponent = () => (
<>
- {predefinedPlaces.length > 0 && (
+ {(predefinedPlaces?.length ?? 0) > 0 && (
<>
{/* This will show current location button in list if there are some recent destinations */}
{shouldShowCurrentLocationButton && (
@@ -339,7 +339,7 @@ function AddressSearch(
fetchDetails
suppressDefaultStyles
enablePoweredByContainer={false}
- predefinedPlaces={predefinedPlaces}
+ predefinedPlaces={predefinedPlaces ?? undefined}
listEmptyComponent={listEmptyComponent}
listLoaderComponent={listLoader}
renderHeaderComponent={renderHeaderComponent}
@@ -348,7 +348,7 @@ function AddressSearch(
const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text;
return (
- {title && {title}}
+ {!!title && {title}}
{subtitle}
);
@@ -398,10 +398,10 @@ function AddressSearch(
if (inputID) {
onInputChange?.(text);
} else {
- onInputChange({street: text});
+ onInputChange?.({street: text});
}
// If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering
- if (!text && !predefinedPlaces.length) {
+ if (!text && !predefinedPlaces?.length) {
setDisplayListViewBorder(false);
}
},
diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts
index 9b4254a9bc45..e4735e9d0020 100644
--- a/src/components/AddressSearch/types.ts
+++ b/src/components/AddressSearch/types.ts
@@ -20,6 +20,8 @@ type RenamedInputKeysProps = {
lat: string;
lng: string;
zipCode: string;
+ address?: string;
+ country?: string;
};
type OnPressProps = {
@@ -59,7 +61,7 @@ type AddressSearchProps = {
defaultValue?: string;
/** A callback function when the value of this field has changed */
- onInputChange: (value: string | number | RenamedInputKeysProps | StreetValue, key?: string) => void;
+ onInputChange?: (value: string | number | RenamedInputKeysProps | StreetValue, key?: string) => void;
/** A callback function when an address has been auto-selected */
onPress?: (props: OnPressProps) => void;
@@ -74,10 +76,10 @@ type AddressSearchProps = {
canUseCurrentLocation?: boolean;
/** A list of predefined places that can be shown when the user isn't searching for something */
- predefinedPlaces?: Place[];
+ predefinedPlaces?: Place[] | null;
/** A map of inputID key names */
- renamedInputKeys: RenamedInputKeysProps;
+ renamedInputKeys?: RenamedInputKeysProps;
/** Maximum number of characters allowed in search input */
maxInputLength?: number;
diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js
deleted file mode 100644
index 3fc90433f13e..000000000000
--- a/src/components/Alert/index.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import _ from 'underscore';
-
-/**
- * Shows an alert modal with ok and cancel options.
- *
- * @param {String} title The title of the alert
- * @param {String} description The description of the alert
- * @param {Object[]} [options] An array of objects with `style` and `onPress` properties
- */
-export default (title, description, options) => {
- const result = _.filter(window.confirm([title, description], Boolean)).join('\n');
-
- if (result) {
- const confirmOption = _.find(options, ({style}) => style !== 'cancel');
- if (confirmOption && confirmOption.onPress) {
- confirmOption.onPress();
- }
- } else {
- const cancelOption = _.find(options, ({style}) => style === 'cancel');
- if (cancelOption && cancelOption.onPress) {
- cancelOption.onPress();
- }
- }
-};
diff --git a/src/components/Alert/index.native.js b/src/components/Alert/index.native.js
deleted file mode 100644
index 31c837a7dd6b..000000000000
--- a/src/components/Alert/index.native.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import {Alert} from 'react-native';
-
-export default Alert.alert;
diff --git a/src/components/Alert/index.native.tsx b/src/components/Alert/index.native.tsx
new file mode 100644
index 000000000000..b72eff5d9b58
--- /dev/null
+++ b/src/components/Alert/index.native.tsx
@@ -0,0 +1,6 @@
+import {Alert as AlertRN} from 'react-native';
+import type Alert from './types';
+
+const alert: Alert = AlertRN.alert;
+
+export default alert;
diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx
new file mode 100644
index 000000000000..f212f06aa9d3
--- /dev/null
+++ b/src/components/Alert/index.tsx
@@ -0,0 +1,16 @@
+import type Alert from './types';
+
+/** Shows an alert modal with ok and cancel options. */
+const alert: Alert = (title, description, options) => {
+ const result = window.confirm([title, description].filter(Boolean).join('\n'));
+
+ if (result) {
+ const confirmOption = options?.find(({style}) => style !== 'cancel');
+ confirmOption?.onPress?.();
+ } else {
+ const cancelOption = options?.find(({style}) => style === 'cancel');
+ cancelOption?.onPress?.();
+ }
+};
+
+export default alert;
diff --git a/src/components/Alert/types.ts b/src/components/Alert/types.ts
new file mode 100644
index 000000000000..25454abfe8b8
--- /dev/null
+++ b/src/components/Alert/types.ts
@@ -0,0 +1,5 @@
+import type {AlertStatic} from 'react-native';
+
+type Alert = AlertStatic['alert'];
+
+export default Alert;
diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js
index 59928b80c4b1..d4d3d0696c59 100644
--- a/src/components/AttachmentPicker/index.native.js
+++ b/src/components/AttachmentPicker/index.native.js
@@ -1,7 +1,7 @@
import lodashCompact from 'lodash/compact';
import PropTypes from 'prop-types';
import React, {useCallback, useMemo, useRef, useState} from 'react';
-import {Alert, View} from 'react-native';
+import {Alert, Image as RNImage, View} from 'react-native';
import RNFetchBlob from 'react-native-blob-util';
import RNDocumentPicker from 'react-native-document-picker';
import {launchImageLibrary} from 'react-native-image-picker';
@@ -57,11 +57,22 @@ const getImagePickerOptions = (type) => {
};
/**
- * See https://github.com/rnmods/react-native-document-picker#options for DocumentPicker configuration options
+ * Return documentPickerOptions based on the type
+ * @param {String} type
+ * @returns {Object}
*/
-const documentPickerOptions = {
- type: [RNDocumentPicker.types.allFiles],
- copyTo: 'cachesDirectory',
+
+const getDocumentPickerOptions = (type) => {
+ if (type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE) {
+ return {
+ type: [RNDocumentPicker.types.images],
+ copyTo: 'cachesDirectory',
+ };
+ }
+ return {
+ type: [RNDocumentPicker.types.allFiles],
+ copyTo: 'cachesDirectory',
+ };
};
/**
@@ -158,7 +169,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
*/
const showDocumentPicker = useCallback(
() =>
- RNDocumentPicker.pick(documentPickerOptions).catch((error) => {
+ RNDocumentPicker.pick(getDocumentPickerOptions(type)).catch((error) => {
if (RNDocumentPicker.isCancel(error)) {
return;
}
@@ -166,7 +177,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
showGeneralAlert(error.message);
throw error;
}),
- [showGeneralAlert],
+ [showGeneralAlert, type],
);
const menuItemData = useMemo(() => {
@@ -181,7 +192,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
textTranslationKey: 'attachmentPicker.chooseFromGallery',
pickAttachment: () => showImagePicker(launchImageLibrary),
},
- type !== CONST.ATTACHMENT_PICKER_TYPE.IMAGE && {
+ {
icon: Expensicons.Paperclip,
textTranslationKey: 'attachmentPicker.chooseDocument',
pickAttachment: showDocumentPicker,
@@ -189,7 +200,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
]);
return data;
- }, [showDocumentPicker, showImagePicker, type, shouldHideCameraOption]);
+ }, [showDocumentPicker, showImagePicker, shouldHideCameraOption]);
const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible});
@@ -232,22 +243,23 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
onCanceled.current();
return Promise.resolve();
}
-
const fileData = _.first(attachments);
-
- if (fileData.width === -1 || fileData.height === -1) {
- showImageCorruptionAlert();
- return Promise.resolve();
- }
-
- return getDataForUpload(fileData)
- .then((result) => {
- completeAttachmentSelection.current(result);
- })
- .catch((error) => {
- showGeneralAlert(error.message);
- throw error;
- });
+ RNImage.getSize(fileData.uri, (width, height) => {
+ fileData.width = width;
+ fileData.height = height;
+ if (fileData.width === -1 || fileData.height === -1) {
+ showImageCorruptionAlert();
+ return Promise.resolve();
+ }
+ return getDataForUpload(fileData)
+ .then((result) => {
+ completeAttachmentSelection.current(result);
+ })
+ .catch((error) => {
+ showGeneralAlert(error.message);
+ throw error;
+ });
+ });
},
[showGeneralAlert, showImageCorruptionAlert],
);
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts
index fd9b57511cc4..f1b9d16de654 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts
+++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts
@@ -26,6 +26,7 @@ type AttachmentCarouselPagerContextValue = {
isScrollEnabled: SharedValue;
onTap: () => void;
onScaleChanged: (scale: number) => void;
+ onSwipeDown: () => void;
};
const AttachmentCarouselPagerContext = createContext(null);
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
index 8704584c3e18..33d9f20b5e57 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
+++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
@@ -42,9 +42,15 @@ type AttachmentCarouselPagerProps = {
* @param showArrows If set, it will show/hide the arrows. If not set, it will toggle the arrows.
*/
onRequestToggleArrows: (showArrows?: boolean) => void;
+
+ /** A callback that is called when swipe-down-to-close gesture happens */
+ onClose: () => void;
};
-function AttachmentCarouselPager({items, activeSource, initialPage, onPageSelected, onRequestToggleArrows}: AttachmentCarouselPagerProps, ref: ForwardedRef) {
+function AttachmentCarouselPager(
+ {items, activeSource, initialPage, onPageSelected, onRequestToggleArrows, onClose}: AttachmentCarouselPagerProps,
+ ref: ForwardedRef,
+) {
const styles = useThemeStyles();
const pagerRef = useRef(null);
@@ -114,9 +120,10 @@ function AttachmentCarouselPager({items, activeSource, initialPage, onPageSelect
isScrollEnabled,
pagerRef,
onTap: handleTap,
+ onSwipeDown: onClose,
onScaleChanged: handleScaleChange,
}),
- [pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange],
+ [pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, onClose, handleScaleChange],
);
const animatedProps = useAnimatedProps(() => ({
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js
index 228f0d597a32..a4d3e1392095 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.js
+++ b/src/components/Attachments/AttachmentCarousel/index.native.js
@@ -102,6 +102,10 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
[setShouldShowArrows],
);
+ const goBack = useCallback(() => {
+ Navigation.goBack();
+ }, []);
+
return (
{page == null ? (
@@ -133,6 +137,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
activeSource={activeSource}
onRequestToggleArrows={toggleArrows}
onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)}
+ onClose={goBack}
ref={pagerRef}
/>
>
diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.tsx
similarity index 77%
rename from src/components/AvatarWithImagePicker.js
rename to src/components/AvatarWithImagePicker.tsx
index 26d41ea82e00..fa8a6d71516f 100644
--- a/src/components/AvatarWithImagePicker.js
+++ b/src/components/AvatarWithImagePicker.tsx
@@ -1,18 +1,20 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useEffect, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
-import _ from 'underscore';
+import type {StyleProp, ViewStyle} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
+import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import getImageResolution from '@libs/fileDownload/getImageResolution';
-import stylePropTypes from '@styles/stylePropTypes';
+import type {AvatarSource} from '@libs/UserUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+import type IconAsset from '@src/types/utils/IconAsset';
import AttachmentModal from './AttachmentModal';
import AttachmentPicker from './AttachmentPicker';
import Avatar from './Avatar';
@@ -20,167 +22,140 @@ import AvatarCropModal from './AvatarCropModal/AvatarCropModal';
import DotIndicatorMessage from './DotIndicatorMessage';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
-import sourcePropTypes from './Image/sourcePropTypes';
import OfflineWithFeedback from './OfflineWithFeedback';
import PopoverMenu from './PopoverMenu';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import Tooltip from './Tooltip';
import withNavigationFocus from './withNavigationFocus';
-const propTypes = {
+type ErrorData = {
+ validationError?: TranslationPaths | null | '';
+ phraseParam: Record;
+};
+
+type OpenPickerParams = {
+ onPicked: (image: File) => void;
+};
+type OpenPicker = (args: OpenPickerParams) => void;
+
+type MenuItem = {
+ icon: IconAsset;
+ text: string;
+ onSelected: () => void;
+};
+
+type AvatarWithImagePickerProps = {
/** Avatar source to display */
- source: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]),
+ source?: AvatarSource;
/** Additional style props */
- style: stylePropTypes,
+ style?: StyleProp;
/** Additional style props for disabled picker */
- disabledStyle: stylePropTypes,
+ disabledStyle?: StyleProp;
/** Executed once an image has been selected */
- onImageSelected: PropTypes.func,
+ onImageSelected?: (file: File | CustomRNImageManipulatorResult) => void;
/** Execute when the user taps "remove" */
- onImageRemoved: PropTypes.func,
+ onImageRemoved?: () => void;
/** A default avatar component to display when there is no source */
- DefaultAvatar: PropTypes.func,
+ DefaultAvatar?: () => React.ReactNode;
/** Whether we are using the default avatar */
- isUsingDefaultAvatar: PropTypes.bool,
+ isUsingDefaultAvatar?: boolean;
/** Size of Indicator */
- size: PropTypes.oneOf([CONST.AVATAR_SIZE.XLARGE, CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]),
+ size?: typeof CONST.AVATAR_SIZE.XLARGE | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT;
/** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */
- fallbackIcon: sourcePropTypes,
+ fallbackIcon?: AvatarSource;
/** Denotes whether it is an avatar or a workspace avatar */
- type: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_WORKSPACE]),
+ type?: typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE;
/** Image crop vector mask */
- editorMaskImage: sourcePropTypes,
+ editorMaskImage?: IconAsset;
/** Additional style object for the error row */
- errorRowStyles: stylePropTypes,
+ errorRowStyles?: StyleProp;
/** A function to run when the X button next to the error is clicked */
- onErrorClose: PropTypes.func,
+ onErrorClose?: () => void;
/** The type of action that's pending */
- pendingAction: PropTypes.oneOf(['add', 'update', 'delete']),
+ pendingAction?: OnyxCommon.PendingAction;
/** The errors to display */
- // eslint-disable-next-line react/forbid-prop-types
- errors: PropTypes.object,
+ errors?: OnyxCommon.Errors | null;
/** Title for avatar preview modal */
- headerTitle: PropTypes.string,
+ headerTitle?: string;
/** Avatar source for avatar preview modal */
- previewSource: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]),
+ previewSource?: AvatarSource;
/** File name of the avatar */
- originalFileName: PropTypes.string,
+ originalFileName?: string;
/** Whether navigation is focused */
- isFocused: PropTypes.bool.isRequired,
+ isFocused: boolean;
/** Style applied to the avatar */
- avatarStyle: stylePropTypes.isRequired,
+ avatarStyle: StyleProp;
/** Indicates if picker feature should be disabled */
- disabled: PropTypes.bool,
+ disabled?: boolean;
/** Executed once click on view photo option */
- onViewPhotoPress: PropTypes.func,
-
- /** Where the popover should be positioned relative to the anchor points. */
- anchorAlignment: PropTypes.shape({
- horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
- vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
- }),
+ onViewPhotoPress?: () => void;
/** Allows to open an image without Attachment Picker. */
- enablePreview: PropTypes.bool,
-};
-
-const defaultProps = {
- source: '',
- onImageSelected: () => {},
- onImageRemoved: () => {},
- style: [],
- disabledStyle: [],
- DefaultAvatar: () => {},
- isUsingDefaultAvatar: false,
- size: CONST.AVATAR_SIZE.DEFAULT,
- fallbackIcon: Expensicons.FallbackAvatar,
- type: CONST.ICON_TYPE_AVATAR,
- editorMaskImage: undefined,
- errorRowStyles: [],
- onErrorClose: () => {},
- pendingAction: null,
- errors: null,
- headerTitle: '',
- previewSource: '',
- originalFileName: '',
- disabled: false,
- onViewPhotoPress: undefined,
- anchorAlignment: {
- horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
- vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
- },
- enablePreview: false,
+ enablePreview?: boolean;
};
function AvatarWithImagePicker({
isFocused,
- DefaultAvatar,
+ DefaultAvatar = () => null,
style,
disabledStyle,
pendingAction,
errors,
errorRowStyles,
- onErrorClose,
- source,
- fallbackIcon,
- size,
- type,
- headerTitle,
- previewSource,
- originalFileName,
- isUsingDefaultAvatar,
- onImageRemoved,
- onImageSelected,
+ onErrorClose = () => {},
+ source = '',
+ fallbackIcon = Expensicons.FallbackAvatar,
+ size = CONST.AVATAR_SIZE.DEFAULT,
+ type = CONST.ICON_TYPE_AVATAR,
+ headerTitle = '',
+ previewSource = '',
+ originalFileName = '',
+ isUsingDefaultAvatar = false,
+ onImageSelected = () => {},
+ onImageRemoved = () => {},
editorMaskImage,
avatarStyle,
- disabled,
+ disabled = false,
onViewPhotoPress,
- enablePreview,
-}) {
+ enablePreview = false,
+}: AvatarWithImagePickerProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {windowWidth} = useWindowDimensions();
const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0});
const [isMenuVisible, setIsMenuVisible] = useState(false);
- const [errorData, setErrorData] = useState({
- validationError: null,
- phraseParam: {},
- });
+ const [errorData, setErrorData] = useState({validationError: null, phraseParam: {}});
const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false);
const [imageData, setImageData] = useState({
uri: '',
name: '',
type: '',
});
- const anchorRef = useRef();
+ const anchorRef = useRef(null);
const {translate} = useLocalize();
- /**
- * @param {String} error
- * @param {Object} phraseParam
- */
- const setError = (error, phraseParam) => {
+ const setError = (error: TranslationPaths | null, phraseParam: Record) => {
setErrorData({
validationError: error,
phraseParam,
@@ -198,40 +173,29 @@ function AvatarWithImagePicker({
/**
* Check if the attachment extension is allowed.
- *
- * @param {Object} image
- * @returns {Boolean}
*/
- const isValidExtension = (image) => {
- const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', ''));
- return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase());
+ const isValidExtension = (image: File): boolean => {
+ const {fileExtension} = FileUtils.splitExtensionFromFileName(image?.name ?? '');
+ return CONST.AVATAR_ALLOWED_EXTENSIONS.some((extension) => extension === fileExtension.toLowerCase());
};
/**
* Check if the attachment size is less than allowed size.
- *
- * @param {Object} image
- * @returns {Boolean}
*/
- const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
+ const isValidSize = (image: File): boolean => (image?.size ?? 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
/**
* Check if the attachment resolution matches constraints.
- *
- * @param {Object} image
- * @returns {Promise}
*/
- const isValidResolution = (image) =>
+ const isValidResolution = (image: File): Promise =>
getImageResolution(image).then(
({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX,
);
/**
* Validates if an image has a valid resolution and opens an avatar crop modal
- *
- * @param {Object} image
*/
- const showAvatarCropModal = (image) => {
+ const showAvatarCropModal = (image: File) => {
if (!isValidExtension(image)) {
setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS});
return;
@@ -269,11 +233,8 @@ function AvatarWithImagePicker({
/**
* Create menu items list for avatar menu
- *
- * @param {Function} openPicker
- * @returns {Array}
*/
- const createMenuItems = (openPicker) => {
+ const createMenuItems = (openPicker: OpenPicker): MenuItem[] => {
const menuItems = [
{
icon: Expensicons.Upload,
@@ -318,6 +279,7 @@ function AvatarWithImagePicker({
vertical: y + height + variables.spacing2,
});
});
+
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMenuVisible, windowWidth]);
@@ -383,7 +345,11 @@ function AvatarWithImagePicker({
maybeIcon={isUsingDefaultAvatar}
>
{({show}) => (
-
+
+ {/* @ts-expect-error TODO: Remove this once AttachmentPicker (https://github.com/Expensify/App/issues/25134) is migrated to TypeScript. */}
{({openPicker}) => {
const menuItems = createMenuItems(openPicker);
@@ -432,7 +398,8 @@ function AvatarWithImagePicker({
{errorData.validationError && (
)}
@@ -449,8 +416,6 @@ function AvatarWithImagePicker({
);
}
-AvatarWithImagePicker.propTypes = propTypes;
-AvatarWithImagePicker.defaultProps = defaultProps;
AvatarWithImagePicker.displayName = 'AvatarWithImagePicker';
export default withNavigationFocus(AvatarWithImagePicker);
diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js
index 0e57bcf4db03..2374fc9e5d0c 100644
--- a/src/components/CategoryPicker/index.js
+++ b/src/components/CategoryPicker/index.js
@@ -3,6 +3,7 @@ import React, {useMemo} from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import * as OptionsListUtils from '@libs/OptionsListUtils';
@@ -67,6 +68,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC
textInputLabel={shouldShowTextInput && translate('common.search')}
onChangeText={setSearchValue}
onSelectRow={onSubmit}
+ ListItem={RadioListItem}
initiallyFocusedOptionKey={selectedOptionKey}
/>
);
diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx
index 25dc99459064..5b5e99ac0621 100644
--- a/src/components/CountrySelector.tsx
+++ b/src/components/CountrySelector.tsx
@@ -1,4 +1,5 @@
-import React, {forwardRef, useEffect} from 'react';
+import {useIsFocused} from '@react-navigation/native';
+import React, {forwardRef, useEffect, useRef} from 'react';
import type {ForwardedRef} from 'react';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
@@ -23,15 +24,28 @@ type CountrySelectorProps = {
/** inputID used by the Form component */
// eslint-disable-next-line react/no-unused-prop-types
inputID: string;
+
+ /** Callback to call when the picker modal is dismissed */
+ onBlur?: () => void;
};
-function CountrySelector({errorText = '', value: countryCode, onInputChange}: CountrySelectorProps, ref: ForwardedRef) {
+function CountrySelector({errorText = '', value: countryCode, onInputChange, onBlur}: CountrySelectorProps, ref: ForwardedRef) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const title = countryCode ? translate(`allCountries.${countryCode}`) : '';
const countryTitleDescStyle = title.length === 0 ? styles.textNormal : null;
+ const didOpenContrySelector = useRef(false);
+ const isFocused = useIsFocused();
+ useEffect(() => {
+ if (!isFocused || !didOpenContrySelector.current) {
+ return;
+ }
+ didOpenContrySelector.current = false;
+ onBlur?.();
+ }, [isFocused, onBlur]);
+
useEffect(() => {
// This will cause the form to revalidate and remove any error related to country name
onInputChange(countryCode);
@@ -48,6 +62,7 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange}: Co
description={translate('common.country')}
onPress={() => {
const activeRoute = Navigation.getActiveRouteWithoutParams();
+ didOpenContrySelector.current = true;
Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute));
}}
/>
diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx
index 49915ebfbf1b..f8c4a12ec188 100644
--- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx
+++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx
@@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Modal from '@components/Modal';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
@@ -79,6 +80,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear
showScrollIndicator
shouldStopPropagation
shouldUseDynamicMaxToRenderPerBatch
+ ListItem={RadioListItem}
/>
diff --git a/src/components/DistanceRequest/index.tsx b/src/components/DistanceRequest/index.tsx
index 29987f716565..3f74c148de70 100644
--- a/src/components/DistanceRequest/index.tsx
+++ b/src/components/DistanceRequest/index.tsx
@@ -28,6 +28,7 @@ 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';
@@ -176,7 +177,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit
);
};
- const getError = () => {
+ 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');
@@ -186,8 +187,12 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit
// eslint-disable-next-line @typescript-eslint/naming-convention
return {0: 'iou.error.atLeastTwoDifferentWaypoints'};
}
- return {};
- };
+
+ 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) => {
@@ -211,7 +216,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit
const submitWaypoints = useCallback(() => {
// If there is any error or loading state, don't let user go to next page.
- if (Object.keys(validatedWaypoints).length < 2 || hasRouteError || isLoadingRoute || (isLoading && !isOffline)) {
+ if (!isEmptyObject(getError()) || isLoadingRoute || (isLoading && !isOffline)) {
setHasError(true);
return;
}
@@ -221,7 +226,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit
}
onSubmit(waypoints);
- }, [onSubmit, setHasError, hasRouteError, isLoadingRoute, isLoading, validatedWaypoints, waypoints, isEditingNewRequest, isEditingRequest, isOffline]);
+ }, [onSubmit, setHasError, getError, isLoadingRoute, isLoading, waypoints, isEditingNewRequest, isEditingRequest, isOffline]);
const content = (
<>
@@ -254,10 +259,10 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit
{/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */}
- {((hasError && Object.keys(validatedWaypoints).length < 2) || hasRouteError) && (
+ {((hasError && !isEmptyObject(getError())) || hasRouteError) && (
)}
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js
index b263885f0a60..1d2d15e4564c 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js
@@ -131,6 +131,7 @@ function BaseEmojiPickerMenu({headerEmojis, scrollToHeader, isFiltered, listWrap
ref={forwardedRef}
keyboardShouldPersistTaps="handled"
data={data}
+ drawDistance={CONST.EMOJI_DRAW_AMOUNT}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={CONST.EMOJI_NUM_PER_ROW}
diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts
index ae98978ffcad..37d0f730c9e9 100644
--- a/src/components/Form/types.ts
+++ b/src/components/Form/types.ts
@@ -7,6 +7,7 @@ import type AmountTextInput from '@components/AmountTextInput';
import type CheckboxWithLabel from '@components/CheckboxWithLabel';
import type CountrySelector from '@components/CountrySelector';
import type Picker from '@components/Picker';
+import type RadioButtons from '@components/RadioButtons';
import type SingleChoiceQuestion from '@components/SingleChoiceQuestion';
import type StatePicker from '@components/StatePicker';
import type TextInput from '@components/TextInput';
@@ -34,7 +35,8 @@ type ValidInputs =
| typeof AmountForm
| typeof BusinessTypePicker
| typeof StatePicker
- | typeof ValuePicker;
+ | typeof ValuePicker
+ | typeof RadioButtons;
type ValueTypeKey = 'string' | 'boolean' | 'date';
type ValueTypeMap = {
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx
index 8e0ce759d021..f2e38ccb74af 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx
@@ -10,7 +10,6 @@ import Text from '@components/Text';
import UserDetailsTooltip from '@components/UserDetailsTooltip';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
-import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import * as LocalePhoneNumber from '@libs/LocalePhoneNumber';
@@ -27,7 +26,6 @@ type MentionUserRendererProps = WithCurrentUserPersonalDetailsProps & CustomRend
function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersonalDetails, ...defaultRendererProps}: MentionUserRendererProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const {translate} = useLocalize();
const htmlAttribAccountID = tnode.attributes.accountid;
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
@@ -39,7 +37,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona
const user = personalDetails[htmlAttribAccountID];
accountID = parseInt(htmlAttribAccountID, 10);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || user?.displayName || translate('common.hidden');
+ displayNameOrLogin = PersonalDetailsUtils.getDisplayNameOrDefault(user, LocalePhoneNumber.formatPhoneNumber(user?.login ?? ''));
navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID);
} else if ('data' in tnode && !isEmptyObject(tnode.data)) {
// We need to remove the LTR unicode and leading @ from data as it is not part of the login
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx
index 2e47b97ec7af..43d1be85d21a 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx
@@ -1,8 +1,8 @@
import React from 'react';
import type {CustomRendererProps, TBlock} from 'react-native-render-html';
+import {ShowContextMenuContext} from '@components/ShowContextMenuContext';
import VideoPlayerPreview from '@components/VideoPlayerPreview';
import * as FileUtils from '@libs/fileDownload/FileUtils';
-import {parseReportRouteParams} from '@libs/ReportUtils';
import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
import Navigation from '@navigation/Navigation';
import CONST from '@src/CONST';
@@ -22,22 +22,24 @@ function VideoRenderer({tnode, key}: VideoRendererProps) {
const width = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE]);
const height = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE]);
const duration = Number(htmlAttribs[CONST.ATTACHMENT_DURATION_ATTRIBUTE]);
- const activeRoute = Navigation.getActiveRoute();
- const {reportID} = parseReportRouteParams(activeRoute);
return (
- {
- const route = ROUTES.REPORT_ATTACHMENTS.getRoute(reportID, sourceURL);
- Navigation.navigate(route);
- }}
- />
+
+ {({report}) => (
+ {
+ const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report?.reportID ?? '', sourceURL);
+ Navigation.navigate(route);
+ }}
+ />
+ )}
+
);
}
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 553a60e568ec..2a7ed30abf1a 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -68,6 +68,7 @@ import Flag from '@assets/images/flag.svg';
import FlagLevelOne from '@assets/images/flag_level_01.svg';
import FlagLevelTwo from '@assets/images/flag_level_02.svg';
import FlagLevelThree from '@assets/images/flag_level_03.svg';
+import Folder from '@assets/images/folder.svg';
import Fullscreen from '@assets/images/fullscreen.svg';
import Gallery from '@assets/images/gallery.svg';
import Gear from '@assets/images/gear.svg';
@@ -144,6 +145,7 @@ import Users from '@assets/images/users.svg';
import VolumeHigh from '@assets/images/volume-high.svg';
import VolumeLow from '@assets/images/volume-low.svg';
import Wallet from '@assets/images/wallet.svg';
+import Workflows from '@assets/images/workflows.svg';
import Workspace from '@assets/images/workspace-default-avatar.svg';
import Wrench from '@assets/images/wrench.svg';
import Zoom from '@assets/images/zoom.svg';
@@ -216,6 +218,7 @@ export {
FlagLevelTwo,
FlagLevelThree,
Fullscreen,
+ Folder,
Gallery,
Gear,
Globe,
@@ -287,6 +290,7 @@ export {
VolumeHigh,
VolumeLow,
Wallet,
+ Workflows,
Workspace,
Zoom,
Twitter,
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 3f6b6ca20540..e03b393dc81f 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -5,6 +5,7 @@ import BankUserGreen from '@assets/images/product-illustrations/bank-user--green
import ConciergeBlue from '@assets/images/product-illustrations/concierge--blue.svg';
import ConciergeExclamation from '@assets/images/product-illustrations/concierge--exclamation.svg';
import CreditCardsBlue from '@assets/images/product-illustrations/credit-cards--blue.svg';
+import EmptyStateExpenses from '@assets/images/product-illustrations/emptystate__expenses.svg';
import GpsTrackOrange from '@assets/images/product-illustrations/gps-track--orange.svg';
import Hands from '@assets/images/product-illustrations/home-illustration-hands.svg';
import InvoiceOrange from '@assets/images/product-illustrations/invoice--orange.svg';
@@ -15,6 +16,7 @@ import JewelBoxYellow from '@assets/images/product-illustrations/jewel-box--yell
import MagicCode from '@assets/images/product-illustrations/magic-code.svg';
import MoneyEnvelopeBlue from '@assets/images/product-illustrations/money-envelope--blue.svg';
import MoneyMousePink from '@assets/images/product-illustrations/money-mouse--pink.svg';
+import MushroomTopHat from '@assets/images/product-illustrations/mushroom-top-hat.svg';
import PaymentHands from '@assets/images/product-illustrations/payment-hands.svg';
import ReceiptYellow from '@assets/images/product-illustrations/receipt--yellow.svg';
import ReceiptsSearchYellow from '@assets/images/product-illustrations/receipts-search--yellow.svg';
@@ -26,6 +28,7 @@ import TadaBlue from '@assets/images/product-illustrations/tada--blue.svg';
import TadaYellow from '@assets/images/product-illustrations/tada--yellow.svg';
import TeleScope from '@assets/images/product-illustrations/telescope.svg';
import ToddBehindCloud from '@assets/images/product-illustrations/todd-behind-cloud.svg';
+import Approval from '@assets/images/simple-illustrations/simple-illustration__approval.svg';
import BankArrow from '@assets/images/simple-illustrations/simple-illustration__bank-arrow.svg';
import BigRocket from '@assets/images/simple-illustrations/simple-illustration__bigrocket.svg';
import PinkBill from '@assets/images/simple-illustrations/simple-illustration__bill.svg';
@@ -36,6 +39,7 @@ import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustra
import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg';
import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg';
import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg';
+import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg';
import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg';
import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg';
import HandEarth from '@assets/images/simple-illustrations/simple-illustration__handearth.svg';
@@ -55,6 +59,7 @@ import OpenSafe from '@assets/images/simple-illustrations/simple-illustration__o
import PalmTree from '@assets/images/simple-illustrations/simple-illustration__palmtree.svg';
import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg';
import QRCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg';
+import ReceiptEnvelope from '@assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg';
import ReceiptWrangler from '@assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg';
import SanFrancisco from '@assets/images/simple-illustrations/simple-illustration__sanfrancisco.svg';
import ShieldYellow from '@assets/images/simple-illustrations/simple-illustration__shield.svg';
@@ -63,6 +68,8 @@ import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustrati
import TrackShoe from '@assets/images/simple-illustrations/simple-illustration__track-shoe.svg';
import TrashCan from '@assets/images/simple-illustrations/simple-illustration__trashcan.svg';
import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg';
+import WalletAlt from '@assets/images/simple-illustrations/simple-illustration__wallet-alt.svg';
+import Workflows from '@assets/images/simple-illustrations/simple-illustration__workflows.svg';
export {
Abracadabra,
@@ -76,6 +83,8 @@ export {
ConciergeExclamation,
CreditCardsBlue,
EmailAddress,
+ EmptyStateExpenses,
+ FolderOpen,
HandCard,
HotDogStand,
InvoiceOrange,
@@ -88,6 +97,7 @@ export {
Mailbox,
MoneyEnvelopeBlue,
MoneyMousePink,
+ MushroomTopHat,
ReceiptsSearchYellow,
ReceiptYellow,
ReceiptWrangler,
@@ -129,5 +139,9 @@ export {
LockClosed,
Gears,
QRCode,
+ ReceiptEnvelope,
+ Approval,
+ WalletAlt,
+ Workflows,
House,
};
diff --git a/src/components/Image/index.js b/src/components/Image/index.js
index ef1a69e19c12..59fcde8273fd 100644
--- a/src/components/Image/index.js
+++ b/src/components/Image/index.js
@@ -3,12 +3,15 @@ import React, {useEffect, useMemo} from 'react';
import {Image as RNImage} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import useNetwork from '@hooks/useNetwork';
import ONYXKEYS from '@src/ONYXKEYS';
import {defaultProps, imagePropTypes} from './imagePropTypes';
import RESIZE_MODES from './resizeModes';
function Image(props) {
const {source: propsSource, isAuthTokenRequired, onLoad, session} = props;
+ const {isOffline} = useNetwork();
+
/**
* Check if the image source is a URL - if so the `encryptedAuthToken` is appended
* to the source.
@@ -39,7 +42,7 @@ function Image(props) {
RNImage.getSize(source.uri, (width, height) => {
onLoad({nativeEvent: {width, height}});
});
- }, [onLoad, source]);
+ }, [onLoad, source, isOffline]);
// Omit the props which the underlying RNImage won't use
const forwardedProps = _.omit(props, ['source', 'onLoad', 'session', 'isAuthTokenRequired']);
diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx
index b3fc1dc91c16..0ca4a0456e33 100644
--- a/src/components/ImageWithSizeCalculation.tsx
+++ b/src/components/ImageWithSizeCalculation.tsx
@@ -2,6 +2,7 @@ import delay from 'lodash/delay';
import React, {useEffect, useMemo, useRef, useState} from 'react';
import type {ImageSourcePropType, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
+import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import Log from '@libs/Log';
import FullscreenLoadingIndicator from './FullscreenLoadingIndicator';
@@ -44,16 +45,27 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT
const isLoadedRef = useRef(null);
const [isImageCached, setIsImageCached] = useState(true);
const [isLoading, setIsLoading] = useState(false);
+ const {isOffline} = useNetwork();
const source = useMemo(() => ({uri: url}), [url]);
const onError = () => {
Log.hmmm('Unable to fetch image to calculate size', {url});
onLoadFailure?.();
+ if (isLoadedRef.current) {
+ isLoadedRef.current = false;
+ setIsImageCached(false);
+ }
+ if (isOffline) {
+ return;
+ }
+ setIsLoading(false);
};
const imageLoadedSuccessfully = (event: OnLoadNativeEvent) => {
isLoadedRef.current = true;
+ setIsLoading(false);
+ setIsImageCached(true);
onMeasure({
width: event.nativeEvent.width,
height: event.nativeEvent.height,
@@ -87,10 +99,6 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT
}
setIsLoading(true);
}}
- onLoadEnd={() => {
- setIsLoading(false);
- setIsImageCached(true);
- }}
onError={onError}
onLoad={imageLoadedSuccessfully}
/>
diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx
index ae225b3db9e9..923337ba9ada 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHN.tsx
@@ -52,8 +52,13 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti
return null;
}
- const isHidden = optionItem?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
- if (isHidden && !isFocused && !optionItem?.isPinned) {
+ 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;
}
@@ -74,8 +79,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti
const hoveredBackgroundColor = !!styles.sidebarLinkHover && 'backgroundColor' in styles.sidebarLinkHover ? styles.sidebarLinkHover.backgroundColor : theme.sidebar;
const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
- const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
- const shouldShowGreenDotIndicator = !hasBrickError && ReportUtils.requiresAttentionFromCurrentUser(optionItem, optionItem.parentReportAction);
/**
* Show the ReportActionContextMenu modal popover.
*
diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx
index 36cb175e3c45..69fa0d5e6e41 100644
--- a/src/components/Lightbox/index.tsx
+++ b/src/components/Lightbox/index.tsx
@@ -59,6 +59,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
activePage,
onTap,
onScaleChanged: onScaleChangedContext,
+ onSwipeDown,
pagerRef,
} = useMemo(() => {
if (attachmentCarouselPagerContext === null) {
@@ -70,6 +71,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
activePage: 0,
onTap: () => {},
onScaleChanged: () => {},
+ onSwipeDown: () => {},
pagerRef: undefined,
};
}
@@ -212,6 +214,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
shouldDisableTransformationGestures={isPagerScrolling}
onTap={onTap}
onScaleChanged={scaleChange}
+ onSwipeDown={onSwipeDown}
>
string;
+ formatPhoneNumber: (phoneNumber: string) => string;
/** Gets the locale digit corresponding to a standard digit */
toLocaleDigit: (digit: string) => string;
diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx
index 46c96fd706a9..93febc4fd3c0 100644
--- a/src/components/MagicCodeInput.tsx
+++ b/src/components/MagicCodeInput.tsx
@@ -430,3 +430,4 @@ function MagicCodeInput(
MagicCodeInput.displayName = 'MagicCodeInput';
export default forwardRef(MagicCodeInput);
+export type {AutoCompleteVariant, MagicCodeInputHandle};
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
index 8fc9c62bfb38..1c2a8a3197fe 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -573,10 +573,12 @@ function MenuItem(
{badgeText && (
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 0de601bc9f61..df2781d3ea89 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -24,6 +24,7 @@ import * as ReceiptUtils from '@libs/ReceiptUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes';
+import {policyPropTypes} from '@pages/workspace/withPolicy';
import * as IOU from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -41,7 +42,6 @@ import SettlementButton from './SettlementButton';
import ShowMoreButton from './ShowMoreButton';
import Switch from './Switch';
import tagPropTypes from './tagPropTypes';
-import taxPropTypes from './taxPropTypes';
import Text from './Text';
import transactionPropTypes from './transactionPropTypes';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails';
@@ -167,8 +167,8 @@ const propTypes = {
policyTags: tagPropTypes,
/* Onyx Props */
- /** Collection of tax rates attached to a policy */
- policyTaxRates: taxPropTypes,
+ /** The policy of the report */
+ policy: policyPropTypes.policy,
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: iouPropTypes,
@@ -196,6 +196,7 @@ const defaultProps = {
receiptPath: '',
receiptFilename: '',
listStyles: [],
+ policy: {},
policyCategories: {},
policyTags: {},
transactionID: '',
@@ -206,7 +207,6 @@ const defaultProps = {
shouldShowSmartScanFields: true,
isPolicyExpenseChat: false,
iou: iouDefaultProps,
- policyTaxRates: {},
};
function MoneyRequestConfirmationList(props) {
@@ -228,6 +228,7 @@ function MoneyRequestConfirmationList(props) {
const {unit, rate, currency} = props.mileageRate;
const distance = lodashGet(transaction, 'routes.route0.distance', 0);
const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0;
+ const taxRates = lodashGet(props.policy, 'taxRates', {});
// A flag for showing the categories field
const shouldShowCategories = props.isPolicyExpenseChat && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories)));
@@ -262,8 +263,8 @@ function MoneyRequestConfirmationList(props) {
);
const formattedTaxAmount = CurrencyUtils.convertToDisplayString(props.transaction.taxAmount, props.iouCurrencyCode);
- const defaultTaxKey = props.policyTaxRates.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${props.policyTaxRates.taxes[defaultTaxKey].name} (${props.policyTaxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
+ const defaultTaxKey = taxRates.defaultExternalID;
+ const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
const taxRateTitle = (props.transaction.taxRate && props.transaction.taxRate.text) || defaultTaxName;
const isFocused = useIsFocused();
@@ -766,11 +767,15 @@ function MoneyRequestConfirmationList(props) {
description={translate('common.category')}
numberOfLinesTitle={2}
onPress={() => {
- if (props.isEditingSplitBill) {
- Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.CATEGORY));
- return;
- }
- Navigation.navigate(ROUTES.MONEY_REQUEST_CATEGORY.getRoute(props.iouType, props.reportID));
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ props.iouType,
+ props.transaction.transactionID,
+ props.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
}}
style={[styles.moneyRequestMenuItem]}
titleStyle={styles.flex1}
@@ -814,7 +819,7 @@ function MoneyRequestConfirmationList(props) {
@@ -831,7 +836,7 @@ function MoneyRequestConfirmationList(props) {
@@ -886,9 +891,6 @@ export default compose(
policy: {
key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
},
- policyTaxRates: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${policyID}`,
- },
iou: {
key: ONYXKEYS.IOU,
},
diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
index 3939e847707d..8eeaeaf87eff 100755
--- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
+++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
@@ -23,6 +23,7 @@ import * as ReceiptUtils from '@libs/ReceiptUtils';
import * as ReportUtils from '@libs/ReportUtils';
import playSound, {SOUNDS} from '@libs/Sound';
import * as TransactionUtils from '@libs/TransactionUtils';
+import {policyPropTypes} from '@pages/workspace/withPolicy';
import * as IOU from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -41,7 +42,6 @@ import ReceiptEmptyState from './ReceiptEmptyState';
import SettlementButton from './SettlementButton';
import Switch from './Switch';
import tagPropTypes from './tagPropTypes';
-import taxPropTypes from './taxPropTypes';
import Text from './Text';
import transactionPropTypes from './transactionPropTypes';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails';
@@ -161,8 +161,8 @@ const propTypes = {
policyTags: tagPropTypes,
/* Onyx Props */
- /** Collection of tax rates attached to a policy */
- policyTaxRates: taxPropTypes,
+ /** The policy of the report */
+ policy: policyPropTypes.policy,
/** Transaction that represents the money request */
transaction: transactionPropTypes,
@@ -189,6 +189,7 @@ const defaultProps = {
receiptPath: '',
receiptFilename: '',
listStyles: [],
+ policy: {},
policyCategories: {},
policyTags: {},
transactionID: '',
@@ -197,7 +198,6 @@ const defaultProps = {
isDistanceRequest: false,
shouldShowSmartScanFields: true,
isPolicyExpenseChat: false,
- policyTaxRates: {},
};
function MoneyTemporaryForRefactorRequestConfirmationList({
@@ -238,7 +238,6 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
session: {accountID},
shouldShowSmartScanFields,
transaction,
- policyTaxRates,
}) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -252,6 +251,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
const {unit, rate, currency} = mileageRate;
const distance = lodashGet(transaction, 'routes.route0.distance', 0);
const shouldCalculateDistanceAmount = isDistanceRequest && iouAmount === 0;
+ const taxRates = lodashGet(policy, 'taxRates', {});
// A flag for showing the categories field
const shouldShowCategories = isPolicyExpenseChat && (iouCategory || OptionsListUtils.hasEnabledOptions(_.values(policyCategories)));
@@ -286,8 +286,8 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
);
const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction.taxAmount, iouCurrencyCode);
- const defaultTaxKey = policyTaxRates.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${policyTaxRates.taxes[defaultTaxKey].name} (${policyTaxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
+ const defaultTaxKey = taxRates.defaultExternalID;
+ const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
const taxRateTitle = (transaction.taxRate && transaction.taxRate.text) || defaultTaxName;
const isFocused = useIsFocused();
@@ -747,7 +747,11 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
title={iouCategory}
description={translate('common.category')}
numberOfLinesTitle={2}
- onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))}
+ onPress={() =>
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()),
+ )
+ }
style={[styles.moneyRequestMenuItem]}
titleStyle={styles.flex1}
disabled={didConfirm}
@@ -763,7 +767,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
@@ -783,10 +787,10 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
{
item: (
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))}
@@ -800,10 +804,10 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
{
item: (
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))}
@@ -932,8 +936,5 @@ export default compose(
policy: {
key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
},
- policyTaxRates: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${policyID}`,
- },
}),
)(MoneyTemporaryForRefactorRequestConfirmationList);
diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx
index 1efbe1827b85..0bdd53719173 100644
--- a/src/components/MultiGestureCanvas/index.tsx
+++ b/src/components/MultiGestureCanvas/index.tsx
@@ -10,7 +10,7 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import {DEFAULT_ZOOM_RANGE, SPRING_CONFIG, ZOOM_RANGE_BOUNCE_FACTORS} from './constants';
-import type {CanvasSize, ContentSize, OnScaleChangedCallback, OnTapCallback, ZoomRange} from './types';
+import type {CanvasSize, ContentSize, OnScaleChangedCallback, OnSwipeDownCallback, OnTapCallback, ZoomRange} from './types';
import usePanGesture from './usePanGesture';
import usePinchGesture from './usePinchGesture';
import useTapGestures from './useTapGestures';
@@ -47,6 +47,8 @@ type MultiGestureCanvasProps = ChildrenProps & {
/** Handles scale changed event */
onTap?: OnTapCallback;
+
+ onSwipeDown?: OnSwipeDownCallback;
};
function MultiGestureCanvas({
@@ -59,6 +61,7 @@ function MultiGestureCanvas({
shouldDisableTransformationGestures: shouldDisableTransformationGesturesProp,
onTap,
onScaleChanged,
+ onSwipeDown,
}: MultiGestureCanvasProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -88,6 +91,7 @@ function MultiGestureCanvas({
const panTranslateX = useSharedValue(0);
const panTranslateY = useSharedValue(0);
+ const isSwipingDownToClose = useSharedValue(false);
const panGestureRef = useRef(Gesture.Pan());
const pinchScale = useSharedValue(1);
@@ -113,8 +117,8 @@ function MultiGestureCanvas({
stopAnimation();
if (animated) {
- offsetX.value = withSpring(0, SPRING_CONFIG);
- offsetY.value = withSpring(0, SPRING_CONFIG);
+ offsetX.value = 0;
+ offsetY.value = 0;
panTranslateX.value = withSpring(0, SPRING_CONFIG);
panTranslateY.value = withSpring(0, SPRING_CONFIG);
pinchTranslateX.value = withSpring(0, SPRING_CONFIG);
@@ -172,6 +176,8 @@ function MultiGestureCanvas({
panTranslateY,
stopAnimation,
shouldDisableTransformationGestures,
+ isSwipingDownToClose,
+ onSwipeDown,
})
.simultaneousWithExternalGesture(...panGestureSimultaneousList)
.withRef(panGestureRef);
diff --git a/src/components/MultiGestureCanvas/types.ts b/src/components/MultiGestureCanvas/types.ts
index 40fcc1462a09..fbb2f3deb88c 100644
--- a/src/components/MultiGestureCanvas/types.ts
+++ b/src/components/MultiGestureCanvas/types.ts
@@ -24,6 +24,9 @@ type OnScaleChangedCallback = (zoomScale: number) => void;
/** Triggered when the canvas is tapped (single tap) */
type OnTapCallback = () => void;
+/** Triggered when the swipe down gesture on canvas occurs */
+type OnSwipeDownCallback = () => void;
+
/** Types used of variables used within the MultiGestureCanvas component and it's hooks */
type MultiGestureCanvasVariables = {
canvasSize: CanvasSize;
@@ -32,6 +35,7 @@ type MultiGestureCanvasVariables = {
minContentScale: number;
maxContentScale: number;
shouldDisableTransformationGestures: SharedValue;
+ isSwipingDownToClose: SharedValue;
zoomScale: SharedValue;
totalScale: SharedValue;
pinchScale: SharedValue;
@@ -45,6 +49,7 @@ type MultiGestureCanvasVariables = {
reset: (animated: boolean, callback: () => void) => void;
onTap: OnTapCallback | undefined;
onScaleChanged: OnScaleChangedCallback | undefined;
+ onSwipeDown: OnSwipeDownCallback | undefined;
};
-export type {CanvasSize, ContentSize, ZoomRange, OnScaleChangedCallback, OnTapCallback, MultiGestureCanvasVariables};
+export type {CanvasSize, ContentSize, ZoomRange, OnScaleChangedCallback, OnTapCallback, MultiGestureCanvasVariables, OnSwipeDownCallback};
diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts
index a3f9c7d62df0..97843e118871 100644
--- a/src/components/MultiGestureCanvas/usePanGesture.ts
+++ b/src/components/MultiGestureCanvas/usePanGesture.ts
@@ -1,7 +1,8 @@
/* eslint-disable no-param-reassign */
+import {Dimensions} from 'react-native';
import type {PanGesture} from 'react-native-gesture-handler';
import {Gesture} from 'react-native-gesture-handler';
-import {useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated';
+import {runOnJS, useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated';
import {SPRING_CONFIG} from './constants';
import type {MultiGestureCanvasVariables} from './types';
import * as MultiGestureCanvasUtils from './utils';
@@ -10,10 +11,24 @@ import * as MultiGestureCanvasUtils from './utils';
// We're using a "withDecay" animation to smoothly phase out the pan animation
// https://docs.swmansion.com/react-native-reanimated/docs/animations/withDecay/
const PAN_DECAY_DECELARATION = 0.9915;
+const SCREEN_HEIGHT = Dimensions.get('screen').height;
+const SNAP_POINT = SCREEN_HEIGHT / 4;
+const SNAP_POINT_HIDDEN = SCREEN_HEIGHT / 1.2;
type UsePanGestureProps = Pick<
MultiGestureCanvasVariables,
- 'canvasSize' | 'contentSize' | 'zoomScale' | 'totalScale' | 'offsetX' | 'offsetY' | 'panTranslateX' | 'panTranslateY' | 'shouldDisableTransformationGestures' | 'stopAnimation'
+ | 'canvasSize'
+ | 'contentSize'
+ | 'zoomScale'
+ | 'totalScale'
+ | 'offsetX'
+ | 'offsetY'
+ | 'panTranslateX'
+ | 'panTranslateY'
+ | 'shouldDisableTransformationGestures'
+ | 'stopAnimation'
+ | 'onSwipeDown'
+ | 'isSwipingDownToClose'
>;
const usePanGesture = ({
@@ -27,16 +42,24 @@ const usePanGesture = ({
panTranslateY,
shouldDisableTransformationGestures,
stopAnimation,
+ isSwipingDownToClose,
+ onSwipeDown,
}: UsePanGestureProps): PanGesture => {
// The content size after fitting it to the canvas and zooming
const zoomedContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]);
const zoomedContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]);
+ // Used to track previous touch position for the "swipe down to close" gesture
+ const previousTouch = useSharedValue<{x: number; y: number} | null>(null);
+
// Velocity of the pan gesture
// We need to keep track of the velocity to properly phase out/decay the pan animation
const panVelocityX = useSharedValue(0);
const panVelocityY = useSharedValue(0);
+ // Disable "swipe down to close" gesture when content is bigger than the canvas
+ const enableSwipeDownToClose = useDerivedValue(() => canvasSize.height < zoomedContentHeight.value, [canvasSize.height]);
+
// Calculates bounds of the scaled content
// Can we pan left/right/up/down
// Can be used to limit gesture or implementing tension effect
@@ -113,8 +136,22 @@ const usePanGesture = ({
});
}
} else {
- // Animated back to the boundary
- offsetY.value = withSpring(clampedOffset.y, SPRING_CONFIG);
+ const finalTranslateY = offsetY.value + panVelocityY.value * 0.2;
+
+ if (finalTranslateY > SNAP_POINT && zoomScale.value <= 1) {
+ offsetY.value = withSpring(SNAP_POINT_HIDDEN, SPRING_CONFIG, () => {
+ isSwipingDownToClose.value = false;
+ });
+
+ if (onSwipeDown) {
+ runOnJS(onSwipeDown)();
+ }
+ } else {
+ // Animated back to the boundary
+ offsetY.value = withSpring(clampedOffset.y, SPRING_CONFIG, () => {
+ isSwipingDownToClose.value = false;
+ });
+ }
}
// Reset velocity variables after we finished the pan gesture
@@ -125,14 +162,36 @@ const usePanGesture = ({
const panGesture = Gesture.Pan()
.manualActivation(true)
.averageTouches(true)
- // eslint-disable-next-line @typescript-eslint/naming-convention
- .onTouchesMove((_evt, state) => {
+ .onTouchesUp(() => {
+ previousTouch.value = null;
+ })
+ .onTouchesMove((evt, state) => {
// We only allow panning when the content is zoomed in
- if (zoomScale.value <= 1 || shouldDisableTransformationGestures.value) {
- return;
+ if (zoomScale.value > 1 && !shouldDisableTransformationGestures.value) {
+ state.activate();
}
- state.activate();
+ // TODO: this needs tuning to work properly
+ if (!shouldDisableTransformationGestures.value && zoomScale.value === 1 && previousTouch.value !== null) {
+ const velocityX = Math.abs(evt.allTouches[0].x - previousTouch.value.x);
+ const velocityY = evt.allTouches[0].y - previousTouch.value.y;
+
+ if (Math.abs(velocityY) > velocityX && velocityY > 20) {
+ state.activate();
+
+ isSwipingDownToClose.value = true;
+ previousTouch.value = null;
+
+ return;
+ }
+ }
+
+ if (previousTouch.value === null) {
+ previousTouch.value = {
+ x: evt.allTouches[0].x,
+ y: evt.allTouches[0].y,
+ };
+ }
})
.onStart(() => {
stopAnimation();
@@ -147,15 +206,23 @@ const usePanGesture = ({
panVelocityX.value = evt.velocityX;
panVelocityY.value = evt.velocityY;
- panTranslateX.value += evt.changeX;
- panTranslateY.value += evt.changeY;
+ if (!isSwipingDownToClose.value) {
+ panTranslateX.value += evt.changeX;
+ }
+
+ if (enableSwipeDownToClose.value || isSwipingDownToClose.value) {
+ panTranslateY.value += evt.changeY;
+ }
})
.onEnd(() => {
// Add pan translation to total offset and reset gesture variables
offsetX.value += panTranslateX.value;
offsetY.value += panTranslateY.value;
+
+ // Reset pan gesture variables
panTranslateX.value = 0;
panTranslateY.value = 0;
+ previousTouch.value = null;
// If we are swiping (in the pager), we don't want to return to boundaries
if (shouldDisableTransformationGestures.value) {
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 5310163a7433..a391ff061baa 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -79,7 +79,7 @@ type PopoverMenuProps = Partial & {
anchorPosition: AnchorPosition;
/** Ref of the anchor */
- anchorRef: RefObject;
+ anchorRef: RefObject;
/** Where the popover should be positioned relative to the anchor points. */
anchorAlignment?: AnchorAlignment;
diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx
index 69728d7be126..b8d4efbd916d 100644
--- a/src/components/PopoverProvider/index.tsx
+++ b/src/components/PopoverProvider/index.tsx
@@ -21,14 +21,15 @@ function PopoverContextProvider(props: PopoverContextProps) {
const [isOpen, setIsOpen] = useState(false);
const activePopoverRef = useRef(null);
- const closePopover = useCallback((anchorRef?: RefObject) => {
+ const closePopover = useCallback((anchorRef?: RefObject): boolean => {
if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) {
- return;
+ return false;
}
activePopoverRef.current.close();
activePopoverRef.current = null;
setIsOpen(false);
+ return true;
}, []);
useEffect(() => {
@@ -63,11 +64,13 @@ function PopoverContextProvider(props: PopoverContextProps) {
if (e.key !== 'Escape') {
return;
}
- closePopover();
+ if (closePopover()) {
+ e.stopImmediatePropagation();
+ }
};
- document.addEventListener('keydown', listener, true);
+ document.addEventListener('keyup', listener, true);
return () => {
- document.removeEventListener('keydown', listener, true);
+ document.removeEventListener('keyup', listener, true);
};
}, [closePopover]);
diff --git a/src/components/Pressable/PressableWithFeedback.tsx b/src/components/Pressable/PressableWithFeedback.tsx
index b717c4890a2d..74ea4596046e 100644
--- a/src/components/Pressable/PressableWithFeedback.tsx
+++ b/src/components/Pressable/PressableWithFeedback.tsx
@@ -2,6 +2,7 @@ import React, {forwardRef, useState} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import type {AnimatedStyle} from 'react-native-reanimated';
import OpacityView from '@components/OpacityView';
+import type {Color} from '@styles/theme/types';
import variables from '@styles/variables';
import GenericPressable from './GenericPressable';
import type {PressableRef} from './GenericPressable/types';
@@ -27,6 +28,9 @@ type PressableWithFeedbackProps = PressableProps & {
/** Whether the view needs to be rendered offscreen (for Android only) */
needsOffscreenAlphaCompositing?: boolean;
+
+ /** The color of the underlay that will show through when the Pressable is active. */
+ underlayColor?: Color;
};
function PressableWithFeedback(
diff --git a/src/components/QRShare/index.tsx b/src/components/QRShare/index.tsx
index 45a4a4fd4964..c7e9e7637a6c 100644
--- a/src/components/QRShare/index.tsx
+++ b/src/components/QRShare/index.tsx
@@ -9,15 +9,12 @@ import QRCode from '@components/QRCode';
import Text from '@components/Text';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import useWindowDimensions from '@hooks/useWindowDimensions';
import variables from '@styles/variables';
-import CONST from '@src/CONST';
import type {QRShareHandle, QRShareProps} from './types';
function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRShareProps, ref: ForwardedRef) {
const styles = useThemeStyles();
const theme = useTheme();
- const {isSmallScreenWidth} = useWindowDimensions();
const [qrCodeSize, setQrCodeSize] = useState(1);
const svgRef = useRef
);
}
diff --git a/src/components/WorkspaceEmptyStateSection.tsx b/src/components/WorkspaceEmptyStateSection.tsx
new file mode 100644
index 000000000000..330f8e1ebbf5
--- /dev/null
+++ b/src/components/WorkspaceEmptyStateSection.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import {View} from 'react-native';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import type IconAsset from '@src/types/utils/IconAsset';
+import Icon from './Icon';
+import Text from './Text';
+
+type WorkspaceEmptyStateSectionProps = {
+ /** The text to display in the title of the section */
+ title: string;
+
+ /** The text to display in the subtitle of the section */
+ subtitle?: string;
+
+ /** The icon to display along with the title */
+ icon: IconAsset;
+};
+
+function WorkspaceEmptyStateSection({icon, subtitle, title}: WorkspaceEmptyStateSectionProps) {
+ const styles = useThemeStyles();
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ return (
+ <>
+
+
+
+
+
+ {title}
+
+
+ {!!subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ >
+ );
+}
+WorkspaceEmptyStateSection.displayName = 'WorkspaceEmptyStateSection';
+
+export default WorkspaceEmptyStateSection;
diff --git a/src/components/__mocks__/ConfirmedRoute.tsx b/src/components/__mocks__/ConfirmedRoute.tsx
new file mode 100644
index 000000000000..3c78e764ebea
--- /dev/null
+++ b/src/components/__mocks__/ConfirmedRoute.tsx
@@ -0,0 +1,8 @@
+import {View} from 'react-native';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
+function ConfirmedRoute(props: any) {
+ return ;
+}
+
+export default ConfirmedRoute;
diff --git a/src/components/withKeyboardState.tsx b/src/components/withKeyboardState.tsx
index 74d10945fbcb..560576fdbf5c 100755
--- a/src/components/withKeyboardState.tsx
+++ b/src/components/withKeyboardState.tsx
@@ -8,27 +8,34 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps';
type KeyboardStateContextValue = {
/** Whether the keyboard is open */
isKeyboardShown: boolean;
+
+ /** Height of the keyboard in pixels */
+ keyboardHeight: number;
};
// TODO: Remove - left for backwards compatibility with existing components (https://github.com/Expensify/App/issues/25151)
const keyboardStatePropTypes = {
/** Whether the keyboard is open */
isKeyboardShown: PropTypes.bool.isRequired,
+
+ /** Height of the keyboard in pixels */
+ keyboardHeight: PropTypes.number.isRequired,
};
const KeyboardStateContext = createContext({
isKeyboardShown: false,
+ keyboardHeight: 0,
});
function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null {
- const [isKeyboardShown, setIsKeyboardShown] = useState(false);
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
- const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => {
- setIsKeyboardShown(true);
+ const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', (e) => {
+ setKeyboardHeight(e.endCoordinates.height);
});
const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
- setIsKeyboardShown(false);
+ setKeyboardHeight(0);
});
return () => {
@@ -39,9 +46,10 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null {
const contextValue = useMemo(
() => ({
- isKeyboardShown,
+ keyboardHeight,
+ isKeyboardShown: keyboardHeight !== 0,
}),
- [isKeyboardShown],
+ [keyboardHeight],
);
return {children};
}
diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx
index 17fda7fd5e30..9da862ecdebe 100644
--- a/src/components/withToggleVisibilityView.tsx
+++ b/src/components/withToggleVisibilityView.tsx
@@ -7,7 +7,7 @@ import getComponentDisplayName from '@libs/getComponentDisplayName';
type WithToggleVisibilityViewProps = {
/** Whether the content is visible. */
- isVisible?: boolean;
+ isVisible: boolean;
};
export default function withToggleVisibilityView(
diff --git a/src/hooks/useLocationBias.ts b/src/hooks/useLocationBias.ts
index b95ffbb57e9d..e18aba4a907c 100644
--- a/src/hooks/useLocationBias.ts
+++ b/src/hooks/useLocationBias.ts
@@ -1,15 +1,18 @@
import {useMemo} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import type {UserLocation} from '@src/types/onyx';
+import type {WaypointCollection} from '@src/types/onyx/Transaction';
/**
* Construct the rectangular boundary based on user location and waypoints
*/
-export default function useLocationBias(allWaypoints: Record, userLocation?: {latitude: number; longitude: number}) {
+export default function useLocationBias(allWaypoints: WaypointCollection, userLocation?: OnyxEntry) {
return useMemo(() => {
const hasFilledWaypointCount = Object.values(allWaypoints).some((waypoint) => Object.keys(waypoint).length > 0);
// If there are no filled wayPoints and if user's current location cannot be retrieved,
// it is futile to arrive at a biased location. Let's return
if (!hasFilledWaypointCount && userLocation === undefined) {
- return null;
+ return undefined;
}
// Gather the longitudes and latitudes from filled waypoints.
@@ -29,8 +32,8 @@ export default function useLocationBias(allWaypoints: Record void;
};
-type UseNetwork = {isOffline?: boolean};
+type UseNetwork = {isOffline: boolean};
export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork {
const callback = useRef(onReconnect);
callback.current = onReconnect;
- const {isOffline} = useContext(NetworkContext) ?? {};
+ const {isOffline} = useContext(NetworkContext) ?? CONST.DEFAULT_NETWORK_DATA;
const prevOfflineStatusRef = useRef(isOffline);
useEffect(() => {
// If we were offline before and now we are not offline then we just reconnected
@@ -28,5 +29,5 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {
prevOfflineStatusRef.current = isOffline;
}, [isOffline]);
- return {isOffline};
+ return {isOffline: isOffline ?? false};
}
diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts
index ea825b45bc0b..29b2dcb86718 100644
--- a/src/hooks/useViolations.ts
+++ b/src/hooks/useViolations.ts
@@ -58,19 +58,7 @@ function useViolations(violations: TransactionViolation[]) {
}
return violationGroups ?? new Map();
}, [violations]);
-
- const getViolationsForField = useCallback(
- (field: ViolationField, data?: TransactionViolation['data']) => {
- const currentViolations = violationsByField.get(field) ?? [];
-
- if (data?.tagName) {
- return currentViolations.filter((violation) => violation.data?.tagName === data.tagName);
- }
-
- return currentViolations;
- },
- [violationsByField],
- );
+ const getViolationsForField = useCallback((field: ViolationField) => violationsByField.get(field) ?? [], [violationsByField]);
return {
getViolationsForField,
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 0553d6470ddc..4d7041d4a791 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -121,6 +121,7 @@ export default {
no: 'No',
ok: 'OK',
buttonConfirm: 'Got it',
+ name: 'Name',
attachment: 'Attachment',
to: 'To',
optional: 'Optional',
@@ -205,6 +206,7 @@ export default {
iAcceptThe: 'I accept the ',
remove: 'Remove',
admin: 'Admin',
+ owner: 'Owner',
dateFormat: 'YYYY-MM-DD',
send: 'Send',
notifications: 'Notifications',
@@ -308,6 +310,8 @@ export default {
of: 'of',
default: 'Default',
update: 'Update',
+ member: 'Member',
+ role: 'Role',
},
location: {
useCurrent: 'Use current location',
@@ -856,7 +860,6 @@ export default {
noLogsAvailable: 'No logs available',
logSizeTooLarge: ({size}: LogSizeParams) => `Log size exceeds the limit of ${size} MB. Please use "Save log" to download the log file instead.`,
},
- goToExpensifyClassic: 'Go to Expensify Classic',
security: 'Security',
signOut: 'Sign out',
signOutConfirmationText: "You'll lose any offline changes if you sign-out.",
@@ -1026,6 +1029,25 @@ export default {
},
cardDetailsLoadingFailure: 'An error occurred while loading the card details. Please check your internet connection and try again.',
},
+ workflowsPage: {
+ workflowTitle: 'Spend',
+ workflowDescription: 'Configure a workflow from the moment spend occurs, including approval and payment.',
+ delaySubmissionTitle: 'Delay submissions',
+ delaySubmissionDescription: 'Expenses are shared right away for better spend visibility. Set a slower cadence if needed.',
+ submissionFrequency: 'Submission frequency',
+ weeklyFrequency: 'Weekly',
+ monthlyFrequency: 'Monthly',
+ twiceAMonthFrequency: 'Twice a month',
+ byTripFrequency: 'By trip',
+ manuallyFrequency: 'Manually',
+ dailyFrequency: 'Daily',
+ addApprovalsTitle: 'Add approvals',
+ approver: 'Approver',
+ connectBankAccount: 'Connect bank account',
+ addApprovalsDescription: 'Require additional approval before authorizing a payment.',
+ makeOrTrackPaymentsTitle: 'Make or track payments',
+ makeOrTrackPaymentsDescription: 'Add an authorized payer for payments made in Expensify, or simply track payments made elsewhere.',
+ },
reportFraudPage: {
title: 'Report virtual card fraud',
description: 'If your virtual card details have been stolen or compromised, we’ll permanently deactivate your existing card and provide you with a new virtual card and number.',
@@ -1679,11 +1701,15 @@ export default {
workspace: {
common: {
card: 'Cards',
+ workflows: 'Workflows',
workspace: 'Workspace',
edit: 'Edit workspace',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
delete: 'Delete workspace',
settings: 'Settings',
reimburse: 'Reimbursements',
+ categories: 'Categories',
bills: 'Bills',
invoices: 'Invoices',
travel: 'Travel',
@@ -1712,6 +1738,13 @@ export default {
control: 'Control',
collect: 'Collect',
},
+ categories: {
+ subtitle: 'Get a better overview of where money is being spent. Use our default categories or add your own.',
+ emptyCategories: {
+ title: "You haven't created any categories",
+ subtitle: 'Add a category to organize your spend.',
+ },
+ },
emptyWorkspace: {
title: 'Create a workspace',
subtitle: 'Workspaces are where you’ll chat with your team, reimburse expenses, issue cards, send invoices, pay bills, and more - all in one place.',
@@ -1745,6 +1778,7 @@ export default {
},
addedWithPrimary: 'Some users were added with their primary logins.',
invitedBySecondaryLogin: ({secondaryLogin}) => `Added by secondary login ${secondaryLogin}.`,
+ membersListTitle: 'Directory of all workspace members.',
},
card: {
header: 'Unlock free Expensify Cards',
@@ -2344,4 +2378,28 @@ export default {
mute: 'Mute',
unmute: 'Unmute',
},
+ exitSurvey: {
+ header: 'Before you go',
+ reasonPage: {
+ title: "Please tell us why you're leaving",
+ subtitle: 'Before you go, please tell us why you’d like to switch to Expensify Classic.',
+ },
+ reasons: {
+ [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "I need a feature that's only available in Expensify Classic.",
+ [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: "I don't understand how to use New Expensify.",
+ [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'I understand how to use New Expensify, but I prefer Expensify Classic.',
+ },
+ prompts: {
+ [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "What feature do you need that isn't available in New Expensify?",
+ [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'What are you trying to do?',
+ [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Why do you prefer Expensify Classic?',
+ },
+ responsePlaceholder: 'Your response',
+ thankYou: 'Thanks for the feedback!',
+ thankYouSubtitle: 'Your responses will help us build a better product to get stuff done. Thank you so much!',
+ goToExpensifyClassic: 'Switch to Expensify Classic',
+ offlineTitle: "Looks like you're stuck here...",
+ offline:
+ "You appear to be offline. Unfortunately, Expensify Classic doesn't work offline, but New Expensify does. If you prefer to use Expensify Classic, try again when you have an internet connection.",
+ },
} satisfies TranslationBase;
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 2a2eb96bd488..c9ff087d0de7 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -111,6 +111,7 @@ export default {
no: 'No',
ok: 'OK',
buttonConfirm: 'Ok, entendido',
+ name: 'Nombre',
attachment: 'Archivo adjunto',
to: 'A',
optional: 'Opcional',
@@ -195,6 +196,7 @@ export default {
iAcceptThe: 'Acepto los ',
remove: 'Eliminar',
admin: 'Administrador',
+ owner: 'Dueño',
dateFormat: 'AAAA-MM-DD',
send: 'Enviar',
notifications: 'Notificaciones',
@@ -298,6 +300,8 @@ export default {
of: 'de',
default: 'Predeterminado',
update: 'Actualizar',
+ member: 'Miembro',
+ role: 'Role',
},
location: {
useCurrent: 'Usar ubicación actual',
@@ -855,7 +859,6 @@ export default {
signOut: 'Desconectar',
signOutConfirmationText: 'Si cierras sesión perderás los cambios hechos mientras estabas desconectado',
versionLetter: 'v',
- goToExpensifyClassic: 'Ir a Expensify Classic',
readTheTermsAndPrivacy: {
phrase1: 'Leer los',
phrase2: 'Términos de Servicio',
@@ -1022,6 +1025,25 @@ export default {
},
cardDetailsLoadingFailure: 'Se ha producido un error al cargar los datos de la tarjeta. Comprueba tu conexión a Internet e inténtalo de nuevo.',
},
+ workflowsPage: {
+ workflowTitle: 'Gasto',
+ workflowDescription: 'Configure un flujo de trabajo desde el momento en que se produce el gasto, incluida la aprobación y el pago',
+ delaySubmissionTitle: 'Retrasar envíos',
+ delaySubmissionDescription: 'Los gastos se comparten de inmediato para una mejor visibilidad del gasto. Establece una cadencia más lenta si es necesario.',
+ submissionFrequency: 'Frecuencia de envíos',
+ weeklyFrequency: 'Semanal',
+ monthlyFrequency: 'Mensual',
+ twiceAMonthFrequency: 'Dos veces al mes',
+ byTripFrequency: 'Por viaje',
+ manuallyFrequency: 'Manual',
+ dailyFrequency: 'Diaria',
+ addApprovalsTitle: 'Requerir aprobaciones',
+ approver: 'Aprobador',
+ connectBankAccount: 'Conectar cuenta bancaria',
+ addApprovalsDescription: 'Requiere una aprobación adicional antes de autorizar un pago.',
+ makeOrTrackPaymentsTitle: 'Realizar o seguir pagos',
+ makeOrTrackPaymentsDescription: 'Añade un pagador autorizado para los pagos realizados en Expensify, o simplemente realiza un seguimiento de los pagos realizados en otro lugar.',
+ },
reportFraudPage: {
title: 'Reportar fraude con la tarjeta virtual',
description:
@@ -1703,11 +1725,15 @@ export default {
workspace: {
common: {
card: 'Tarjetas',
+ workflows: 'Flujos de trabajo',
workspace: 'Espacio de trabajo',
edit: 'Editar espacio de trabajo',
+ enabled: 'Activada',
+ disabled: 'Desactivada',
delete: 'Eliminar espacio de trabajo',
settings: 'Configuración',
reimburse: 'Reembolsos',
+ categories: 'Categorías',
bills: 'Pagar facturas',
invoices: 'Enviar facturas',
travel: 'Viajes',
@@ -1736,6 +1762,13 @@ export default {
control: 'Control',
collect: 'Recolectar',
},
+ categories: {
+ subtitle: 'Obtén una visión general de dónde te gastas el dinero. Utiliza las categorías predeterminadas o añade las tuyas propias.',
+ emptyCategories: {
+ title: 'No has creado ninguna categoría',
+ subtitle: 'Añade una categoría para organizar tu gasto.',
+ },
+ },
emptyWorkspace: {
title: 'Crea un espacio de trabajo',
subtitle: 'En los espacios de trabajo podrás chatear con tu equipo, reembolsar gastos, emitir tarjetas, enviar y pagar facturas, y mucho más - todo en un mismo lugar.',
@@ -1769,6 +1802,7 @@ export default {
},
addedWithPrimary: 'Se agregaron algunos usuarios con sus nombres de usuario principales.',
invitedBySecondaryLogin: ({secondaryLogin}) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`,
+ membersListTitle: 'Directorio de todos los miembros del espacio de trabajo.',
},
card: {
header: 'Desbloquea Tarjetas Expensify gratis',
@@ -2836,4 +2870,28 @@ export default {
mute: 'Silenciar',
unmute: 'Activar sonido',
},
+ exitSurvey: {
+ header: 'Antes de irte',
+ reasonPage: {
+ title: 'Dinos por qué te vas',
+ subtitle: 'Antes de irte, por favor dinos por qué te gustaría cambiarte a Expensify Classic.',
+ },
+ reasons: {
+ [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: 'Necesito una función que sólo está disponible en Expensify Classic.',
+ [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'No entiendo cómo usar New Expensify.',
+ [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Entiendo cómo usar New Expensify, pero prefiero Expensify Classic.',
+ },
+ prompts: {
+ [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: '¿Qué función necesitas que no esté disponible en New Expensify?',
+ [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: '¿Qué estás tratando de hacer?',
+ [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: '¿Por qué prefieres Expensify Classic?',
+ },
+ responsePlaceholder: 'Su respuesta',
+ thankYou: '¡Gracias por tus comentarios!',
+ thankYouSubtitle: 'Sus respuestas nos ayudarán a crear un mejor producto para hacer las cosas bien. ¡Muchas gracias!',
+ goToExpensifyClassic: 'Cambiar a Expensify Classic',
+ offlineTitle: 'Parece que estás atrapado aquí...',
+ offline:
+ 'Parece que estás desconectado. Desafortunadamente, Expensify Classic no funciona sin conexión, pero New Expensify sí. Si prefieres utilizar Expensify Classic, inténtalo de nuevo cuando tengas conexión a internet.',
+ },
} satisfies EnglishTranslation;
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 6a67217a6129..bb56d5c38cd3 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -105,7 +105,7 @@ type SettleExpensifyCardParams = {
formattedAmount: string;
};
-type RequestAmountParams = {amount: number};
+type RequestAmountParams = {amount: string};
type RequestedAmountMessageParams = {formattedAmount: string; comment?: string};
@@ -157,7 +157,7 @@ type EnterMagicCodeParams = {contactMethod: string};
type TransferParams = {amount: string};
-type InstantSummaryParams = {rate: number; minAmount: number};
+type InstantSummaryParams = {rate: string; minAmount: string};
type NotYouParams = {user: string};
diff --git a/src/libs/API/parameters/CreateDistanceRequestParams.ts b/src/libs/API/parameters/CreateDistanceRequestParams.ts
index c1eb1003a698..62f90a64cf05 100644
--- a/src/libs/API/parameters/CreateDistanceRequestParams.ts
+++ b/src/libs/API/parameters/CreateDistanceRequestParams.ts
@@ -12,6 +12,8 @@ type CreateDistanceRequestParams = {
category?: string;
tag?: string;
billable?: boolean;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
};
export default CreateDistanceRequestParams;
diff --git a/src/libs/API/parameters/RequestMoneyParams.ts b/src/libs/API/parameters/RequestMoneyParams.ts
index 983394008ba7..b55f9fd7a2a9 100644
--- a/src/libs/API/parameters/RequestMoneyParams.ts
+++ b/src/libs/API/parameters/RequestMoneyParams.ts
@@ -25,6 +25,8 @@ type RequestMoneyParams = {
taxAmount: number;
billable?: boolean;
gpsPoints?: string;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
};
export default RequestMoneyParams;
diff --git a/src/libs/API/parameters/SendMoneyParams.ts b/src/libs/API/parameters/SendMoneyParams.ts
index b737ba2ea48b..ac6f42de5aa5 100644
--- a/src/libs/API/parameters/SendMoneyParams.ts
+++ b/src/libs/API/parameters/SendMoneyParams.ts
@@ -9,6 +9,8 @@ type SendMoneyParams = {
newIOUReportDetails: string;
createdReportActionID: string;
reportPreviewReportActionID: string;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
};
export default SendMoneyParams;
diff --git a/src/libs/API/parameters/SetWorkspaceApprovalModeParams.ts b/src/libs/API/parameters/SetWorkspaceApprovalModeParams.ts
new file mode 100644
index 000000000000..df84fbabbf95
--- /dev/null
+++ b/src/libs/API/parameters/SetWorkspaceApprovalModeParams.ts
@@ -0,0 +1,6 @@
+type SetWorkspaceApprovalModeParams = {
+ policyID: string;
+ value: string;
+};
+
+export default SetWorkspaceApprovalModeParams;
diff --git a/src/libs/API/parameters/SetWorkspaceAutoReportingParams.ts b/src/libs/API/parameters/SetWorkspaceAutoReportingParams.ts
new file mode 100644
index 000000000000..a87817986ffa
--- /dev/null
+++ b/src/libs/API/parameters/SetWorkspaceAutoReportingParams.ts
@@ -0,0 +1,6 @@
+type SetWorkspaceAutoReportingParams = {
+ policyID: string;
+ enabled: boolean;
+};
+
+export default SetWorkspaceAutoReportingParams;
diff --git a/src/libs/API/parameters/SwitchToOldDotParams.ts b/src/libs/API/parameters/SwitchToOldDotParams.ts
new file mode 100644
index 000000000000..95449a123dc9
--- /dev/null
+++ b/src/libs/API/parameters/SwitchToOldDotParams.ts
@@ -0,0 +1,9 @@
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+
+type SwitchToOldDotParams = {
+ reason?: ValueOf;
+ surveyResponse?: string;
+};
+
+export default SwitchToOldDotParams;
diff --git a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts
index f5cc3f664d12..dedc45d0365f 100644
--- a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts
+++ b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts
@@ -1,5 +1,5 @@
-import type {BeneficialOwnersStepProps} from '@src/types/form/ReimbursementAccountForm';
+import type {ACHContractStepProps} from '@src/types/form/ReimbursementAccountForm';
-type UpdateBeneficialOwnersForBankAccountParams = BeneficialOwnersStepProps & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean};
+type UpdateBeneficialOwnersForBankAccountParams = Partial & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean};
export default UpdateBeneficialOwnersForBankAccountParams;
diff --git a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts
index 21ca49839aec..6421fe02f571 100644
--- a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts
+++ b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts
@@ -1,5 +1,7 @@
-import type {CompanyStepProps} from '@src/types/form/ReimbursementAccountForm';
+import type {BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps} from '@src/types/form/ReimbursementAccountForm';
-type UpdateCompanyInformationForBankAccountParams = CompanyStepProps & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean};
+type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps;
+
+type UpdateCompanyInformationForBankAccountParams = Partial & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean};
export default UpdateCompanyInformationForBankAccountParams;
diff --git a/src/libs/API/parameters/UpdateRoomVisibilityParams.ts b/src/libs/API/parameters/UpdateRoomVisibilityParams.ts
new file mode 100644
index 000000000000..a69559f0ce47
--- /dev/null
+++ b/src/libs/API/parameters/UpdateRoomVisibilityParams.ts
@@ -0,0 +1,8 @@
+import type {RoomVisibility} from '@src/types/onyx/Report';
+
+type UpdateRoomVisibilityParams = {
+ reportID: string;
+ visibility: RoomVisibility;
+};
+
+export default UpdateRoomVisibilityParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 371fb8ddb404..0b0a81eb21f8 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -84,6 +84,7 @@ export type {default as DeleteCommentParams} from './DeleteCommentParams';
export type {default as UpdateCommentParams} from './UpdateCommentParams';
export type {default as UpdateReportNotificationPreferenceParams} from './UpdateReportNotificationPreferenceParams';
export type {default as UpdateRoomDescriptionParams} from './UpdateRoomDescriptionParams';
+export type {default as UpdateRoomVisibilityParams} from './UpdateRoomVisibilityParams';
export type {default as UpdateReportWriteCapabilityParams} from './UpdateReportWriteCapabilityParams';
export type {default as AddWorkspaceRoomParams} from './AddWorkspaceRoomParams';
export type {default as UpdatePolicyRoomNameParams} from './UpdatePolicyRoomNameParams';
@@ -144,3 +145,6 @@ export type {default as UnHoldMoneyRequestParams} from './UnHoldMoneyRequestPara
export type {default as CancelPaymentParams} from './CancelPaymentParams';
export type {default as AcceptACHContractForBankAccount} from './AcceptACHContractForBankAccount';
export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams';
+export type {default as SetWorkspaceAutoReportingParams} from './SetWorkspaceAutoReportingParams';
+export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams';
+export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 9c0d57b1cf14..17cc366ba3b7 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -8,6 +8,8 @@ import type UpdateBeneficialOwnersForBankAccountParams from './parameters/Update
type ApiRequest = ValueOf;
const WRITE_COMMANDS = {
+ SET_WORKSPACE_AUTO_REPORTING: 'SetWorkspaceAutoReporting',
+ SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode',
DISMISS_REFERRAL_BANNER: 'DismissReferralBanner',
UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale',
RECONNECT_APP: 'ReconnectApp',
@@ -85,6 +87,7 @@ const WRITE_COMMANDS = {
DELETE_COMMENT: 'DeleteComment',
UPDATE_COMMENT: 'UpdateComment',
UPDATE_REPORT_NOTIFICATION_PREFERENCE: 'UpdateReportNotificationPreference',
+ UPDATE_ROOM_VISIBILITY: 'UpdateRoomVisibility',
UPDATE_ROOM_DESCRIPTION: 'UpdateRoomDescription',
UPDATE_REPORT_WRITE_CAPABILITY: 'UpdateReportWriteCapability',
ADD_WORKSPACE_ROOM: 'AddWorkspaceRoom',
@@ -146,6 +149,7 @@ const WRITE_COMMANDS = {
PAY_MONEY_REQUEST: 'PayMoneyRequest',
CANCEL_PAYMENT: 'CancelPayment',
ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT: 'AcceptACHContractForBankAccount',
+ SWITCH_TO_OLD_DOT: 'SwitchToOldDot',
} as const;
type WriteCommand = ValueOf;
@@ -226,6 +230,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.DELETE_COMMENT]: Parameters.DeleteCommentParams;
[WRITE_COMMANDS.UPDATE_COMMENT]: Parameters.UpdateCommentParams;
[WRITE_COMMANDS.UPDATE_REPORT_NOTIFICATION_PREFERENCE]: Parameters.UpdateReportNotificationPreferenceParams;
+ [WRITE_COMMANDS.UPDATE_ROOM_VISIBILITY]: Parameters.UpdateRoomVisibilityParams;
[WRITE_COMMANDS.UPDATE_ROOM_DESCRIPTION]: Parameters.UpdateRoomDescriptionParams;
[WRITE_COMMANDS.UPDATE_REPORT_WRITE_CAPABILITY]: Parameters.UpdateReportWriteCapabilityParams;
[WRITE_COMMANDS.ADD_WORKSPACE_ROOM]: Parameters.AddWorkspaceRoomParams;
@@ -290,6 +295,9 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.CANCEL_PAYMENT]: Parameters.CancelPaymentParams;
[WRITE_COMMANDS.ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT]: Parameters.AcceptACHContractForBankAccount;
[WRITE_COMMANDS.UPDATE_WORKSPACE_DESCRIPTION]: Parameters.UpdateWorkspaceDescriptionParams;
+ [WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING]: Parameters.SetWorkspaceAutoReportingParams;
+ [WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams;
+ [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams;
};
const READ_COMMANDS = {
diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts
index 14c880edc593..204e78aa5458 100644
--- a/src/libs/Environment/Environment.ts
+++ b/src/libs/Environment/Environment.ts
@@ -24,6 +24,13 @@ function isDevelopment(): boolean {
return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV;
}
+/**
+ * Are we running the app in production?
+ */
+function isProduction(): Promise {
+ return getEnvironment().then((environment) => environment === CONST.ENVIRONMENT.PRODUCTION);
+}
+
/**
* Are we running an internal test build?
*/
@@ -47,4 +54,4 @@ function getOldDotEnvironmentURL(): Promise {
return getEnvironment().then((environment) => OLDDOT_ENVIRONMENT_URLS[environment]);
}
-export {getEnvironment, isInternalTestBuild, isDevelopment, getEnvironmentURL, getOldDotEnvironmentURL};
+export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL};
diff --git a/src/libs/FormUtils.ts b/src/libs/FormUtils.ts
deleted file mode 100644
index 4d0571ada6f2..000000000000
--- a/src/libs/FormUtils.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type {OnyxFormDraftKey, OnyxFormKey} from '@src/ONYXKEYS';
-
-function getDraftKey(formID: OnyxFormKey): OnyxFormDraftKey {
- return `${formID}Draft`;
-}
-
-export default {getDraftKey};
diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts
index cf49ba03f287..48c537f0f14c 100644
--- a/src/libs/GetPhysicalCardUtils.ts
+++ b/src/libs/GetPhysicalCardUtils.ts
@@ -3,17 +3,18 @@ import ROUTES from '@src/ROUTES';
import type {Route} from '@src/ROUTES';
import type {GetPhysicalCardForm} from '@src/types/form';
import type {LoginList, PrivatePersonalDetails} from '@src/types/onyx';
+import * as LoginUtils from './LoginUtils';
import Navigation from './Navigation/Navigation';
import * as PersonalDetailsUtils from './PersonalDetailsUtils';
import * as UserUtils from './UserUtils';
-function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry): Route {
+function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route {
const {address, legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {};
if (!legalFirstName && !legalLastName) {
return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain);
}
- if (!phoneNumber && !UserUtils.getSecondaryPhoneLogin(loginList)) {
+ if (!phoneNumber || !LoginUtils.validateNumber(phoneNumber)) {
return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain);
}
if (!(address?.street && address?.city && address?.state && address?.country && address?.zip)) {
@@ -23,8 +24,8 @@ function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry) {
- Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails, loginList));
+function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: OnyxEntry) {
+ Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails));
}
/**
@@ -35,8 +36,8 @@ function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: OnyxE
* @param loginList
* @returns
*/
-function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry) {
- const expectedRoute = getCurrentRoute(domain, privatePersonalDetails, loginList);
+function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: OnyxEntry) {
+ const expectedRoute = getCurrentRoute(domain, privatePersonalDetails);
// If the user is on the current route or the current route is confirmation, then he's allowed to stay on the current step
if ([currentRoute, ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain)].includes(expectedRoute)) {
@@ -59,14 +60,14 @@ function getUpdatedDraftValues(draftValues: OnyxEntry, priv
return {
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
// we do not need to use nullish coalescing here because we want to allow empty strings
- legalFirstName: draftValues?.legalFirstName || legalFirstName,
- legalLastName: draftValues?.legalLastName || legalLastName,
- addressLine1: draftValues?.addressLine1 || address?.street.split('\n')[0],
+ legalFirstName: draftValues?.legalFirstName || legalFirstName || '',
+ legalLastName: draftValues?.legalLastName || legalLastName || '',
+ addressLine1: draftValues?.addressLine1 || address?.street.split('\n')[0] || '',
addressLine2: draftValues?.addressLine2 || address?.street.split('\n')[1] || '',
- city: draftValues?.city || address?.city,
- country: draftValues?.country || address?.country,
+ city: draftValues?.city || address?.city || '',
+ country: draftValues?.country || address?.country || '',
phoneNumber: draftValues?.phoneNumber || phoneNumber || UserUtils.getSecondaryPhoneLogin(loginList) || '',
- state: draftValues?.state || address?.state,
+ state: draftValues?.state || address?.state || '',
zipPostCode: draftValues?.zipPostCode || address?.zip || '',
/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
};
diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts
index 5d925ae1c684..58a82de3df53 100644
--- a/src/libs/GroupChatUtils.ts
+++ b/src/libs/GroupChatUtils.ts
@@ -1,5 +1,6 @@
import type {OnyxEntry} from 'react-native-onyx';
import type {Report} from '@src/types/onyx';
+import localeCompare from './LocaleCompare';
import * as ReportUtils from './ReportUtils';
/**
@@ -11,7 +12,7 @@ function getGroupChatName(report: OnyxEntry): string | undefined {
return participants
.map((participant) => ReportUtils.getDisplayNameForParticipant(participant, isMultipleParticipantReport))
- .sort((first, second) => first?.localeCompare(second ?? '') ?? 0)
+ .sort((first, second) => localeCompare(first ?? '', second ?? ''))
.filter(Boolean)
.join(', ');
}
diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts
index 0bd5d3162236..56ac47676a37 100644
--- a/src/libs/IOUUtils.ts
+++ b/src/libs/IOUUtils.ts
@@ -109,18 +109,18 @@ function isValidMoneyRequestType(iouType: string): boolean {
}
/**
- * Inserts a newly selected tag into the already existed report tags like a string
+ * Inserts a newly selected tag into the already existing tags like a string
*
- * @param reportTags - currently selected tags for a report
- * @param tag - a newly selected tag, that should be added to the reportTags
+ * @param transactionTags - currently selected tags for a report
+ * @param tag - a newly selected tag, that should be added to the transactionTags
* @param tagIndex - the index of a tag list
* @returns
*/
-function insertTagIntoReportTagsString(reportTags: string, tag: string, tagIndex: number): string {
- const splittedReportTags = reportTags.split(CONST.COLON);
- splittedReportTags[tagIndex] = tag;
+function insertTagIntoTransactionTagsString(transactionTags: string, tag: string, tagIndex: number): string {
+ const tagArray = TransactionUtils.getTagArrayFromName(transactionTags);
+ tagArray[tagIndex] = tag;
- return splittedReportTags.join(CONST.COLON).replace(/:*$/, '');
+ return tagArray.join(CONST.COLON).replace(/:*$/, '');
}
-export {calculateAmount, updateIOUOwnerAndTotal, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType, navigateToStartMoneyRequestStep, insertTagIntoReportTagsString};
+export {calculateAmount, updateIOUOwnerAndTotal, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType, navigateToStartMoneyRequestStep, insertTagIntoTransactionTagsString};
diff --git a/src/libs/KeyboardShortcut/index.ts b/src/libs/KeyboardShortcut/index.ts
index 44ba54953c40..0571f5e271ab 100644
--- a/src/libs/KeyboardShortcut/index.ts
+++ b/src/libs/KeyboardShortcut/index.ts
@@ -1,6 +1,7 @@
import Str from 'expensify-common/lib/str';
import * as KeyCommand from 'react-native-key-command';
import getOperatingSystem from '@libs/getOperatingSystem';
+import localeCompare from '@libs/LocaleCompare';
import CONST from '@src/CONST';
import bindHandlerToKeydownEvent from './bindHandlerToKeydownEvent';
@@ -32,7 +33,7 @@ type Shortcut = {
const documentedShortcuts: Record = {};
function getDocumentedShortcuts(): Shortcut[] {
- return Object.values(documentedShortcuts).sort((a, b) => a.displayName.localeCompare(b.displayName));
+ return Object.values(documentedShortcuts).sort((a, b) => localeCompare(a.displayName, b.displayName));
}
const keyInputEnter = KeyCommand?.constants?.keyInputEnter?.toString() ?? 'keyInputEnter';
diff --git a/src/libs/LocaleCompare.ts b/src/libs/LocaleCompare.ts
index 5142c5b43d9a..b2c48b410d32 100644
--- a/src/libs/LocaleCompare.ts
+++ b/src/libs/LocaleCompare.ts
@@ -1,19 +1,26 @@
import Onyx from 'react-native-onyx';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-const DEFAULT_LOCALE = 'en';
-
const COLLATOR_OPTIONS: Intl.CollatorOptions = {usage: 'sort', sensitivity: 'base'};
-let collator = new Intl.Collator(DEFAULT_LOCALE, COLLATOR_OPTIONS);
+let collator = new Intl.Collator(CONST.LOCALES.DEFAULT, COLLATOR_OPTIONS);
Onyx.connect({
key: ONYXKEYS.NVP_PREFERRED_LOCALE,
callback: (locale) => {
- collator = new Intl.Collator(locale ?? DEFAULT_LOCALE, COLLATOR_OPTIONS);
+ collator = new Intl.Collator(locale ?? CONST.LOCALES.DEFAULT, COLLATOR_OPTIONS);
},
});
+/**
+ * This is a wrapper around the localeCompare function that uses the preferred locale from the user's settings.
+ *
+ * It re-uses Intl.Collator with static options for performance reasons. See https://github.com/facebook/hermes/issues/867 for more details.
+ * @param a
+ * @param b
+ * @returns -1 if a < b, 1 if a > b, 0 if a === b
+ */
function localeCompare(a: string, b: string) {
return collator.compare(a, b);
}
diff --git a/src/libs/LocalePhoneNumber.ts b/src/libs/LocalePhoneNumber.ts
index 9aacc6968e1e..933aa7937560 100644
--- a/src/libs/LocalePhoneNumber.ts
+++ b/src/libs/LocalePhoneNumber.ts
@@ -13,7 +13,7 @@ Onyx.connect({
* Returns a locally converted phone number for numbers from the same region
* and an internationally converted phone number with the country code for numbers from other regions
*/
-function formatPhoneNumber(number: string | undefined): string {
+function formatPhoneNumber(number: string): string {
if (!number) {
return '';
}
diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts
index 13a58834860b..f501244a725d 100644
--- a/src/libs/ModifiedExpenseMessage.ts
+++ b/src/libs/ModifiedExpenseMessage.ts
@@ -9,6 +9,7 @@ import * as Localize from './Localize';
import * as PolicyUtils from './PolicyUtils';
import * as ReportUtils from './ReportUtils';
import type {ExpenseOriginalMessage} from './ReportUtils';
+import * as TransactionUtils from './TransactionUtils';
let allPolicyTags: OnyxCollection = {};
Onyx.connect({
@@ -189,8 +190,8 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr
const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {};
const transactionTag = reportActionOriginalMessage?.tag ?? '';
const oldTransactionTag = reportActionOriginalMessage?.oldTag ?? '';
- const splittedTag = transactionTag.split(CONST.COLON);
- const splittedOldTag = oldTransactionTag.split(CONST.COLON);
+ const splittedTag = TransactionUtils.getTagArrayFromName(transactionTag);
+ const splittedOldTag = TransactionUtils.getTagArrayFromName(oldTransactionTag);
const localizedTagListName = Localize.translateLocal('common.tag');
Object.keys(policyTags).forEach((policyTagKey, index) => {
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
index d9835b01ceff..2be262aa5f0f 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
@@ -1,8 +1,8 @@
import type {ParamListBase} from '@react-navigation/routers';
import type {StackNavigationOptions} from '@react-navigation/stack';
-import {CardStyleInterpolators, createStackNavigator} from '@react-navigation/stack';
import React, {useMemo} from 'react';
import useThemeStyles from '@hooks/useThemeStyles';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import type {
AddPersonalBankAccountNavigatorParamList,
DetailsNavigatorParamList,
@@ -35,6 +35,7 @@ import type {
import type {ThemeStyles} from '@styles/index';
import type {Screen} from '@src/SCREENS';
import SCREENS from '@src/SCREENS';
+import subRouteOptions from './modalStackNavigatorOptions';
type Screens = Partial React.ComponentType>>;
@@ -45,16 +46,15 @@ type Screens = Partial React.ComponentType>>;
* @param getScreenOptions optional function that returns the screen options, override the default options
*/
function createModalStackNavigator(screens: Screens, getScreenOptions?: (styles: ThemeStyles) => StackNavigationOptions): React.ComponentType {
- const ModalStackNavigator = createStackNavigator();
+ const ModalStackNavigator = createPlatformStackNavigator();
function ModalStack() {
const styles = useThemeStyles();
const defaultSubRouteOptions = useMemo(
(): StackNavigationOptions => ({
+ ...subRouteOptions,
cardStyle: styles.navigationScreenCardStyle,
- headerShown: false,
- cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS,
}),
[styles],
);
@@ -100,7 +100,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.HOLD]: () => require('../../../pages/iou/HoldReasonPage').default as React.ComponentType,
- [SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').default as React.ComponentType,
[SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType,
[SCREENS.IOU_SEND.ADD_DEBIT_CARD]: () => require('../../../pages/settings/Wallet/AddDebitCardPage').default as React.ComponentType,
[SCREENS.IOU_SEND.ENABLE_PAYMENTS]: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default as React.ComponentType,
@@ -133,6 +132,7 @@ const ReportSettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Report/RoomNamePage').default as React.ComponentType,
[SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: () => require('../../../pages/settings/Report/NotificationPreferencePage').default as React.ComponentType,
[SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: () => require('../../../pages/settings/Report/WriteCapabilityPage').default as React.ComponentType,
+ [SCREENS.REPORT_SETTINGS.VISIBILITY]: () => require('../../../pages/settings/Report/VisibilityPage').default as React.ComponentType,
});
const TaskModalStackNavigator = createModalStackNavigator({
@@ -191,7 +191,6 @@ const AccountSettingsModalStackNavigator = createModalStackNavigator(
[SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../pages/settings/Preferences/PreferencesPage').default as React.ComponentType,
[SCREENS.SETTINGS.SECURITY]: () => require('../../../pages/settings/Security/SecuritySettingsPage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../pages/settings/Profile/ProfilePage').default as React.ComponentType,
- [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType,
[SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../pages/settings/Wallet/WalletPage').default as React.ComponentType,
[SCREENS.SETTINGS.ABOUT]: () => require('../../../pages/settings/AboutPage/AboutPage').default as React.ComponentType,
},
@@ -203,6 +202,7 @@ const WorkspaceSwitcherModalStackNavigator = createModalStackNavigator({
+ [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require('../../../pages/settings/Profile/PronounsPage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../pages/settings/Profile/DisplayNamePage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.TIMEZONE]: () => require('../../../pages/settings/Profile/TimezoneInitialPage').default as React.ComponentType,
@@ -246,12 +246,16 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType,
[SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType,
[SCREENS.WORKSPACE.DESCRIPTION]: () => require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.SHARE]: () => require('../../../pages/workspace/WorkspaceProfileSharePage').default as React.ComponentType,
[SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceProfileCurrencyPage').default as React.ComponentType,
[SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType,
[SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType,
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType,
[SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: () => require('../../../pages/settings/Wallet/ReportCardLostPage').default as React.ComponentType,
[SCREENS.KEYBOARD_SHORTCUTS]: () => require('../../../pages/KeyboardShortcutsPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyReasonPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyResponsePage').default as React.ComponentType,
+ [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyConfirmPage').default as React.ComponentType,
});
const EnablePaymentsStackNavigator = createModalStackNavigator({
diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx
index 087e963b3892..262a93da9e33 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx
@@ -1,12 +1,12 @@
-import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import useThemeStyles from '@hooks/useThemeStyles';
import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper';
import getCurrentUrl from '@libs/Navigation/currentUrl';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import type {CentralPaneNavigatorParamList} from '@navigation/types';
import SCREENS from '@src/SCREENS';
-const Stack = createStackNavigator();
+const Stack = createPlatformStackNavigator();
const url = getCurrentUrl();
const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined;
@@ -17,11 +17,13 @@ const workspaceSettingsScreens = {
[SCREENS.SETTINGS.WORKSPACES]: () => require('../../../../../pages/workspace/WorkspacesListPage').default as React.ComponentType,
[SCREENS.WORKSPACE.PROFILE]: () => require('../../../../../pages/workspace/WorkspaceProfilePage').default as React.ComponentType,
[SCREENS.WORKSPACE.CARD]: () => require('../../../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType,
[SCREENS.WORKSPACE.BILLS]: () => require('../../../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.INVOICES]: () => require('../../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../../pages/workspace/travel/WorkspaceTravelPage').default as React.ComponentType,
[SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../../pages/workspace/categories/WorkspaceCategoriesPage').default as React.ComponentType,
} satisfies Screens;
function BaseCentralPaneNavigator() {
diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx
new file mode 100644
index 000000000000..30651e32cbd6
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx
@@ -0,0 +1,7 @@
+function Overlay() {
+ return null;
+}
+
+Overlay.displayName = 'Overlay';
+
+export default Overlay;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx
similarity index 100%
rename from src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx
rename to src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
index c421bdc82028..550fb947a4e6 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -1,5 +1,4 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import {createStackNavigator} from '@react-navigation/stack';
import React, {useMemo, useRef} from 'react';
import {View} from 'react-native';
import NoDropZone from '@components/DragAndDrop/NoDropZone';
@@ -7,6 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions';
import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import type {AuthScreensParamList, RightModalNavigatorParamList} from '@navigation/types';
import type NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
@@ -14,7 +14,7 @@ import Overlay from './Overlay';
type RightModalNavigatorProps = StackScreenProps;
-const Stack = createStackNavigator();
+const Stack = createPlatformStackNavigator();
function RightModalNavigator({navigation}: RightModalNavigatorProps) {
const styles = useThemeStyles();
diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.tsx b/src/libs/Navigation/AppNavigator/PublicScreens.tsx
index 6b1557994627..792a538cfd39 100644
--- a/src/libs/Navigation/AppNavigator/PublicScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/PublicScreens.tsx
@@ -1,5 +1,5 @@
-import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import type {PublicScreensParamList} from '@navigation/types';
import LogInWithShortLivedAuthTokenPage from '@pages/LogInWithShortLivedAuthTokenPage';
import AppleSignInDesktopPage from '@pages/signin/AppleSignInDesktopPage';
@@ -12,7 +12,7 @@ import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import defaultScreenOptions from './defaultScreenOptions';
-const RootStack = createStackNavigator();
+const RootStack = createPlatformStackNavigator();
function PublicScreens() {
return (
diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts
new file mode 100644
index 000000000000..17100bc71bda
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts
@@ -0,0 +1,11 @@
+const defaultScreenOptions = {
+ contentStyle: {
+ overflow: 'visible',
+ flex: 1,
+ },
+ headerShown: false,
+ animationTypeForReplace: 'push',
+ animation: 'slide_from_right',
+};
+
+export default defaultScreenOptions;
diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts
new file mode 100644
index 000000000000..4015c43c679e
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts
@@ -0,0 +1,12 @@
+import type {StackNavigationOptions} from '@react-navigation/stack';
+
+const defaultScreenOptions: StackNavigationOptions = {
+ cardStyle: {
+ overflow: 'visible',
+ flex: 1,
+ },
+ headerShown: false,
+ animationTypeForReplace: 'push',
+};
+
+export default defaultScreenOptions;
diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts
new file mode 100644
index 000000000000..2b062fd2f2be
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts
@@ -0,0 +1,8 @@
+import type {NativeStackNavigationOptions} from '@react-navigation/native-stack';
+
+const rightModalNavigatorOptions = (): NativeStackNavigationOptions => ({
+ presentation: 'card',
+ animation: 'slide_from_right',
+});
+
+export default rightModalNavigatorOptions;
diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts
new file mode 100644
index 000000000000..935c0041b794
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts
@@ -0,0 +1,20 @@
+import type {StackNavigationOptions} from '@react-navigation/stack';
+// eslint-disable-next-line no-restricted-imports
+import getNavigationModalCardStyle from '@styles/utils/getNavigationModalCardStyles';
+
+const rightModalNavigatorOptions = (isSmallScreenWidth: boolean): StackNavigationOptions => ({
+ presentation: 'transparentModal',
+
+ // We want pop in RHP since there are some flows that would work weird otherwise
+ animationTypeForReplace: 'pop',
+ cardStyle: {
+ ...getNavigationModalCardStyle(),
+
+ // This is necessary to cover translated sidebar with overlay.
+ width: isSmallScreenWidth ? '100%' : '200%',
+ // Excess space should be on the left so we need to position from right.
+ right: 0,
+ },
+});
+
+export default rightModalNavigatorOptions;
diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
index c3a69bbd7ccf..5685afec5459 100644
--- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
+++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
@@ -4,6 +4,7 @@ import type {StyleUtilsType} from '@styles/utils';
import variables from '@styles/variables';
import CONFIG from '@src/CONFIG';
import createModalCardStyleInterpolator from './createModalCardStyleInterpolator';
+import getRightModalNavigatorOptions from './getRightModalNavigatorOptions';
type ScreenOptions = Record;
@@ -25,23 +26,12 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr
return {
rightModalNavigator: {
...commonScreenOptions,
+ ...getRightModalNavigatorOptions(isSmallScreenWidth),
cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props),
- presentation: 'transparentModal',
-
- // We want pop in RHP since there are some flows that would work weird otherwise
- animationTypeForReplace: 'pop',
- cardStyle: {
- ...StyleUtils.getNavigationModalCardStyle(),
-
- // This is necessary to cover translated sidebar with overlay.
- width: isSmallScreenWidth ? '100%' : '200%',
- // Excess space should be on the left so we need to position from right.
- right: 0,
- },
},
leftModalNavigator: {
...commonScreenOptions,
- cardStyleInterpolator: (props) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER),
+ cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER),
presentation: 'transparentModal',
// We want pop in LHP since there are some flows that would work weird otherwise
@@ -59,8 +49,8 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr
homeScreen: {
title: CONFIG.SITE_TITLE,
...commonScreenOptions,
+ // Note: The card* properties won't be applied on mobile platforms, as they use the native defaults.
cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props),
-
cardStyle: {
...StyleUtils.getNavigationModalCardStyle(),
width: isSmallScreenWidth ? '100%' : variables.sideBarWidth,
@@ -73,6 +63,7 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr
fullScreen: {
...commonScreenOptions,
+
cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props),
cardStyle: {
...StyleUtils.getNavigationModalCardStyle(),
@@ -87,7 +78,9 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr
...commonScreenOptions,
animationEnabled: isSmallScreenWidth,
cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props),
-
+ // temporary solution - better to hide a keyboard than see keyboard flickering
+ // see https://github.com/software-mansion/react-native-screens/issues/2021 for more details
+ keyboardHandlingEnabled: true,
cardStyle: {
...StyleUtils.getNavigationModalCardStyle(),
paddingRight: isSmallScreenWidth ? 0 : variables.sideBarWidth,
diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts
new file mode 100644
index 000000000000..ca9769fa9972
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts
@@ -0,0 +1,8 @@
+import type {NativeStackNavigationOptions} from '@react-navigation/native-stack';
+
+const defaultSubRouteOptions: NativeStackNavigationOptions = {
+ headerShown: false,
+ animation: 'slide_from_right',
+};
+
+export default defaultSubRouteOptions;
diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts
new file mode 100644
index 000000000000..280a38b263b7
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts
@@ -0,0 +1,9 @@
+import type {StackNavigationOptions} from '@react-navigation/stack';
+import {CardStyleInterpolators} from '@react-navigation/stack';
+
+const defaultSubRouteOptions: StackNavigationOptions = {
+ headerShown: false,
+ cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS,
+};
+
+export default defaultSubRouteOptions;
diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts
new file mode 100644
index 000000000000..ef44cefc13c9
--- /dev/null
+++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts
@@ -0,0 +1,7 @@
+import {createNativeStackNavigator} from '@react-navigation/native-stack';
+
+function createPlatformStackNavigator() {
+ return createNativeStackNavigator();
+}
+
+export default createPlatformStackNavigator;
diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts
new file mode 100644
index 000000000000..51228295572f
--- /dev/null
+++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts
@@ -0,0 +1,5 @@
+import {createStackNavigator} from '@react-navigation/stack';
+
+const createPlatformStackNavigator: typeof createStackNavigator = () => createStackNavigator();
+
+export default createPlatformStackNavigator;
diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts
index 3a4abe225120..371ea89df2e2 100644
--- a/src/libs/Navigation/linkTo.ts
+++ b/src/libs/Navigation/linkTo.ts
@@ -215,7 +215,7 @@ export default function linkTo(navigation: NavigationContainerRef> = {
- [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION],
+ [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE],
[SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT],
[SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE],
};
diff --git a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts
index 446fb479ea09..f4316009b70b 100755
--- a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts
@@ -7,11 +7,13 @@ const TAB_TO_CENTRAL_PANE_MAPPING: Record = {
[SCREENS.WORKSPACE.INITIAL]: [
SCREENS.WORKSPACE.PROFILE,
SCREENS.WORKSPACE.CARD,
+ SCREENS.WORKSPACE.WORKFLOWS,
SCREENS.WORKSPACE.REIMBURSE,
SCREENS.WORKSPACE.BILLS,
SCREENS.WORKSPACE.INVOICES,
SCREENS.WORKSPACE.TRAVEL,
SCREENS.WORKSPACE.MEMBERS,
+ SCREENS.WORKSPACE.CATEGORIES,
],
};
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 74a00dec0a1f..48d649cc4dd9 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -46,6 +46,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.CARD]: {
path: ROUTES.WORKSPACE_CARD.route,
},
+ [SCREENS.WORKSPACE.WORKFLOWS]: {
+ path: ROUTES.WORKSPACE_WORKFLOWS.route,
+ },
[SCREENS.WORKSPACE.REIMBURSE]: {
path: ROUTES.WORKSPACE_REIMBURSE.route,
},
@@ -61,6 +64,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.MEMBERS]: {
path: ROUTES.WORKSPACE_MEMBERS.route,
},
+ [SCREENS.WORKSPACE.CATEGORIES]: {
+ path: ROUTES.WORKSPACE_CATEGORIES.route,
+ },
},
},
[SCREENS.NOT_FOUND]: '*',
@@ -235,6 +241,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.DESCRIPTION]: {
path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route,
},
+ [SCREENS.WORKSPACE.SHARE]: {
+ path: ROUTES.WORKSPACE_PROFILE_SHARE.route,
+ },
[SCREENS.WORKSPACE.RATE_AND_UNIT]: {
path: ROUTES.WORKSPACE_RATE_AND_UNIT.route,
},
@@ -261,6 +270,18 @@ const config: LinkingOptions['config'] = {
path: ROUTES.KEYBOARD_SHORTCUTS,
},
[SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_PROFILE_NAME.route,
+ [SCREENS.SETTINGS.SHARE_CODE]: {
+ path: ROUTES.SETTINGS_SHARE_CODE,
+ },
+ [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: {
+ path: ROUTES.SETTINGS_EXIT_SURVEY_REASON,
+ },
+ [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: {
+ path: ROUTES.SETTINGS_EXIT_SURVEY_RESPONSE.route,
+ },
+ [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: {
+ path: ROUTES.SETTINGS_EXIT_SURVEY_CONFIRM.route,
+ },
},
},
[SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: {
@@ -289,6 +310,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: {
path: ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.route,
},
+ [SCREENS.REPORT_SETTINGS.VISIBILITY]: {
+ path: ROUTES.REPORT_SETTINGS_VISIBILITY.route,
+ },
},
},
[SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: {
@@ -422,7 +446,6 @@ const config: LinkingOptions['config'] = {
[SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route,
[SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route,
[SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route,
- [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.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,
@@ -495,10 +518,6 @@ const config: LinkingOptions['config'] = {
},
[SCREENS.SETTINGS_CENTRAL_PANE]: {
screens: {
- [SCREENS.SETTINGS.SHARE_CODE]: {
- path: ROUTES.SETTINGS_SHARE_CODE,
- exact: true,
- },
[SCREENS.SETTINGS.PROFILE.ROOT]: {
path: ROUTES.SETTINGS_PROFILE,
exact: true,
diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
index 8e246d82ff72..e7c5466852cf 100644
--- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
+++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts
@@ -70,14 +70,16 @@ function createCentralPaneNavigator(route: NavigationPartialRoute): NavigationPartialRoute {
+function createFullScreenNavigator(route?: NavigationPartialRoute): NavigationPartialRoute {
const routes = [];
routes.push({name: SCREENS.SETTINGS.ROOT});
- routes.push({
- name: SCREENS.SETTINGS_CENTRAL_PANE,
- state: getRoutesWithIndex([route]),
- });
+ if (route) {
+ routes.push({
+ name: SCREENS.SETTINGS_CENTRAL_PANE,
+ state: getRoutesWithIndex([route]),
+ });
+ }
return {
name: NAVIGATORS.FULL_SCREEN_NAVIGATOR,
@@ -129,6 +131,11 @@ function getMatchingRootRouteForRHPRoute(
return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: route.params});
}
}
+
+ // This screen is opened from the LHN of the FullStackNavigator, so in this case we shouldn't push any CentralPane screen
+ if (route.name === SCREENS.SETTINGS.SHARE_CODE) {
+ return createFullScreenNavigator();
+ }
}
function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType {
diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
index 55ccca73a389..02ad78a4c044 100644
--- a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
+++ b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
@@ -1,9 +1,11 @@
import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
-import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
+import type {CentralPaneName, CentralPaneNavigatorParamList, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING';
+const WORKSPACES_SCREENS = TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.WORKSPACE.INITIAL].concat(TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.ALL_SETTINGS]);
+
/**
* @param state - react-navigation state
*/
@@ -31,8 +33,47 @@ const getTopMostReportIDFromRHP = (state: State): string => {
return '';
};
+// Check if the given route has a policyID equal to the id provided in the function params
+function hasRouteMatchingPolicyID(route: NavigationPartialRoute, policyID?: string) {
+ if (!route.params) {
+ return false;
+ }
+
+ const params = `params` in route?.params ? (route.params.params as Record) : undefined;
+
+ // If params are not defined, then we need to check if the policyID exists
+ if (!params) {
+ return !policyID;
+ }
+
+ return 'policyID' in params && params.policyID === policyID;
+}
+
+// Get already opened settings screen within the policy
+function getAlreadyOpenedSettingsScreen(rootState?: State, policyID?: string): keyof CentralPaneNavigatorParamList | undefined {
+ if (!rootState) {
+ return undefined;
+ }
+
+ // If one of the screen from WORKSPACES_SCREENS is now in the navigation state, we can decide which screen we should display.
+ // A screen from the navigation state can be pushed to the navigation state again only if it has a matching policyID with the currently selected workspace.
+ // Otherwise, when we switch the workspace, we want to display the initial screen in the settings tab.
+ const alreadyOpenedSettingsTab = rootState.routes
+ .filter((item) => item.params && 'screen' in item.params && WORKSPACES_SCREENS.includes(item.params.screen as keyof CentralPaneNavigatorParamList))
+ .at(-1);
+
+ if (!hasRouteMatchingPolicyID(alreadyOpenedSettingsTab as NavigationPartialRoute, policyID)) {
+ return undefined;
+ }
+
+ const settingsScreen =
+ alreadyOpenedSettingsTab?.params && 'screen' in alreadyOpenedSettingsTab?.params ? (alreadyOpenedSettingsTab?.params?.screen as keyof CentralPaneNavigatorParamList) : undefined;
+
+ return settingsScreen;
+}
+
// Get matching central pane route for bottom tab navigator. e.g HOME -> REPORT
-function getMatchingCentralPaneRouteForState(state: State): NavigationPartialRoute | undefined {
+function getMatchingCentralPaneRouteForState(state: State, rootState?: State): NavigationPartialRoute | undefined {
const topmostBottomTabRoute = getTopmostBottomTabRoute(state);
if (!topmostBottomTabRoute) {
@@ -42,7 +83,10 @@ function getMatchingCentralPaneRouteForState(state: State):
const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0];
if (topmostBottomTabRoute.name === SCREENS.WORKSPACE.INITIAL) {
- return {name: centralPaneName, params: topmostBottomTabRoute.params};
+ // When we go back to the settings tab without switching the workspace id, we want to return to the previously opened screen
+ const policyID = topmostBottomTabRoute?.params && 'policyID' in topmostBottomTabRoute?.params ? (topmostBottomTabRoute.params.policyID as string) : undefined;
+ const screen = getAlreadyOpenedSettingsScreen(rootState, policyID) ?? centralPaneName;
+ return {name: screen, params: topmostBottomTabRoute.params};
}
if (topmostBottomTabRoute.name === SCREENS.HOME) {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 04bc25736887..765ab76fd638 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -15,6 +15,7 @@ import type CONST from '@src/CONST';
import type NAVIGATORS from '@src/NAVIGATORS';
import type {HybridAppRoute, Route as Routes} from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
+import type EXIT_SURVEY_REASON_FORM_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm';
type NavigationRef = NavigationContainerRefWithCurrent;
@@ -59,6 +60,9 @@ type CentralPaneNavigatorParamList = {
[SCREENS.WORKSPACE.CARD]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.WORKFLOWS]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.REIMBURSE]: {
policyID: string;
};
@@ -74,6 +78,9 @@ type CentralPaneNavigatorParamList = {
[SCREENS.WORKSPACE.MEMBERS]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.CATEGORIES]: {
+ policyID: string;
+ };
};
type WorkspaceSwitcherNavigatorParamList = {
@@ -92,9 +99,15 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: undefined;
[SCREENS.SETTINGS.PROFILE.ADDRESS]: undefined;
[SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: undefined;
- [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: undefined;
- [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: undefined;
- [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: undefined;
+ [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: {
+ backTo: Routes;
+ };
+ [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: {
+ contactMethod: string;
+ };
+ [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: {
+ backTo: Routes;
+ };
[SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined;
[SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined;
[SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined;
@@ -146,6 +159,7 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.CURRENCY]: undefined;
[SCREENS.WORKSPACE.NAME]: undefined;
[SCREENS.WORKSPACE.DESCRIPTION]: undefined;
+ [SCREENS.WORKSPACE.SHARE]: undefined;
[SCREENS.WORKSPACE.RATE_AND_UNIT]: {
policyID: string;
};
@@ -167,6 +181,14 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: undefined;
[SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: undefined;
[SCREENS.KEYBOARD_SHORTCUTS]: undefined;
+ [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: undefined;
+ [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: {
+ [EXIT_SURVEY_REASON_FORM_INPUT_IDS.REASON]: ValueOf;
+ backTo: Routes;
+ };
+ [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: {
+ backTo: Routes;
+ };
} & ReimbursementAccountNavigatorParamList;
type NewChatNavigatorParamList = {
@@ -203,6 +225,9 @@ type ReportSettingsNavigatorParamList = {
[SCREENS.REPORT_SETTINGS.ROOM_NAME]: undefined;
[SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: undefined;
[SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: undefined;
+ [SCREENS.REPORT_SETTINGS.VISIBILITY]: {
+ reportID: string;
+ };
};
type ReportDescriptionNavigatorParamList = {
@@ -254,9 +279,12 @@ type MoneyRequestNavigatorParamList = {
reportID: string;
backTo: string;
};
- [SCREENS.MONEY_REQUEST.CATEGORY]: {
- iouType: string;
+ [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: {
+ action: ValueOf;
+ iouType: ValueOf;
+ transactionID: string;
reportID: string;
+ backTo: string;
};
[SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: {
iouType: string;
@@ -277,6 +305,13 @@ type MoneyRequestNavigatorParamList = {
reportID: string;
backTo: string;
};
+ [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: {
+ iouType: ValueOf;
+ reportID: string;
+ backTo: Routes | undefined;
+ action: ValueOf;
+ pageIndex: string;
+ };
[SCREENS.MONEY_REQUEST.STEP_MERCHANT]: {
action: ValueOf;
iouType: ValueOf;
@@ -416,6 +451,7 @@ type RightModalNavigatorParamList = {
[SCREENS.RIGHT_MODAL.NEW_CHAT]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.DETAILS]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.PROFILE]: NavigatorScreenParams;
+ [SCREENS.SETTINGS.SHARE_CODE]: undefined;
[SCREENS.RIGHT_MODAL.REPORT_DETAILS]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: NavigatorScreenParams;
@@ -440,7 +476,6 @@ type RightModalNavigatorParamList = {
};
type SettingsCentralPaneNavigatorParamList = {
- [SCREENS.SETTINGS.SHARE_CODE]: undefined;
[SCREENS.SETTINGS.PROFILE.ROOT]: undefined;
[SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined;
[SCREENS.SETTINGS.SECURITY]: undefined;
@@ -467,6 +502,7 @@ type PublicScreensParamList = {
shortLivedAuthToken?: string;
shortLivedToken?: string;
exitTo?: Routes | HybridAppRoute;
+ domain?: Routes;
};
[SCREENS.VALIDATE_LOGIN]: {
accountID: string;
diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts
index 8da35232112d..f03c34b1696e 100644
--- a/src/libs/NextStepUtils.ts
+++ b/src/libs/NextStepUtils.ts
@@ -73,7 +73,7 @@ function buildNextStep(
const {policyID = '', ownerAccountID = -1, managerID = -1} = report;
const policy = ReportUtils.getPolicy(policyID);
- const {submitsTo, harvesting, isPreventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy;
+ const {submitsTo, harvesting, isPreventSelfApprovalEnabled, preventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy;
const isOwner = currentUserAccountID === ownerAccountID;
const isManager = currentUserAccountID === managerID;
const isSelfApproval = currentUserAccountID === submitsTo;
@@ -164,7 +164,7 @@ function buildNextStep(
}
// Prevented self submitting
- if (isPreventSelfApprovalEnabled && isSelfApproval) {
+ if ((isPreventSelfApprovalEnabled ?? preventSelfApprovalEnabled) && isSelfApproval) {
optimisticNextStep.message = [
{
text: "Oops! Looks like you're submitting to ",
diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts
index b9814487bf3f..62d6fa00906a 100644
--- a/src/libs/NumberUtils.ts
+++ b/src/libs/NumberUtils.ts
@@ -69,9 +69,15 @@ function parseFloatAnyLocale(value: string): number {
return parseFloat(value ? value.replace(',', '.') : value);
}
+/**
+ * Given an input number p and another number q, returns the largest number that's less than p and divisible by q.
+ */
+function roundDownToLargestMultiple(p: number, q: number) {
+ return Math.floor(p / q) * q;
+}
+
/**
* Rounds a number to two decimal places.
- * @param value the value to round
* @returns the rounded value
*/
function roundToTwoDecimalPlaces(value: number): number {
@@ -80,14 +86,10 @@ function roundToTwoDecimalPlaces(value: number): number {
/**
* Clamps a value between a minimum and maximum value.
- *
- * @param value the value to clamp
- * @param min the minimum value
- * @param max the maximum value
* @returns the clamped value
*/
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
-export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundToTwoDecimalPlaces, clamp};
+export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple, roundToTwoDecimalPlaces, clamp};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 80081061f340..3d11795f5452 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -22,18 +22,21 @@ import type {
Report,
ReportAction,
ReportActions,
+ TaxRate,
+ TaxRates,
+ TaxRatesWithDefault,
Transaction,
TransactionViolation,
} from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/IOU';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
-import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import times from '@src/utils/times';
import Timing from './actions/Timing';
import * as CollectionUtils from './CollectionUtils';
import * as ErrorUtils from './ErrorUtils';
+import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
import * as Localize from './Localize';
import * as LoginUtils from './LoginUtils';
@@ -111,8 +114,8 @@ type GetOptionsConfig = {
recentlyUsedTags?: string[];
canInviteUser?: boolean;
includeSelectedOptions?: boolean;
- includePolicyTaxRates?: boolean;
- policyTaxRates?: PolicyTaxRateWithDefault;
+ includeTaxRates?: boolean;
+ taxRates?: TaxRatesWithDefault;
transactionViolations?: OnyxCollection;
};
@@ -141,7 +144,7 @@ type GetOptions = {
currentUserOption: ReportUtils.OptionData | null | undefined;
categoryOptions: CategorySection[];
tagOptions: CategorySection[];
- policyTaxRatesOptions: CategorySection[];
+ taxRatesOptions: CategorySection[];
};
type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean};
@@ -871,9 +874,9 @@ function sortTags(tags: Record | Tag[]) {
let sortedTags;
if (Array.isArray(tags)) {
- sortedTags = tags.sort((a, b) => a.name.localeCompare(b.name));
+ sortedTags = tags.sort((a, b) => localeCompare(a.name, b.name));
} else {
- sortedTags = Object.values(tags).sort((a, b) => a.name.localeCompare(b.name));
+ sortedTags = Object.values(tags).sort((a, b) => localeCompare(a.name, b.name));
}
return sortedTags;
@@ -1061,7 +1064,8 @@ function getTagsOptions(tags: Category[]): Option[] {
function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOptions: Category[], searchInputValue: string, maxRecentReportsToShow: number) {
const tagSections = [];
const sortedTags = sortTags(tags);
- const enabledTags = sortedTags.filter((tag) => tag.enabled);
+ const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name);
+ const enabledTags = [...selectedOptions, ...sortedTags.filter((tag) => tag.enabled && !selectedOptionNames.includes(tag.name))];
const numberOfTags = enabledTags.length;
let indexOffset = 0;
@@ -1109,7 +1113,6 @@ function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOpt
return tagSections;
}
- const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name);
const filteredRecentlyUsedTags = recentlyUsedTags
.filter((recentlyUsedTag) => {
const tagObject = tags.find((tag) => tag.name === recentlyUsedTag);
@@ -1119,13 +1122,11 @@ function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOpt
const filteredTags = enabledTags.filter((tag) => !selectedOptionNames.includes(tag.name));
if (selectedOptions.length) {
- const selectedTagOptions = selectedOptions.map((option) => {
- const tagObject = tags.find((tag) => tag.name === option.name);
- return {
- name: option.name,
- enabled: !!tagObject?.enabled,
- };
- });
+ const selectedTagOptions = selectedOptions.map((option) => ({
+ name: option.name,
+ // Should be marked as enabled to be able to unselect even though the selected category is disabled
+ enabled: true,
+ }));
tagSections.push({
// "Selected" section
@@ -1172,31 +1173,23 @@ function hasEnabledTags(policyTagList: Array
return hasEnabledOptions(policyTagValueList);
}
-type PolicyTaxRateWithDefault = {
- name: string;
- defaultExternalID: string;
- defaultValue: string;
- foreignTaxDefault: string;
- taxes: PolicyTaxRates;
-};
-
/**
* Transforms tax rates to a new object format - to add codes and new name with concatenated name and value.
*
- * @param policyTaxRates - The original tax rates object.
+ * @param taxRates - The original tax rates object.
* @returns The transformed tax rates object.g
*/
-function transformedTaxRates(policyTaxRates: PolicyTaxRateWithDefault | undefined): Record {
- const defaultTaxKey = policyTaxRates?.defaultExternalID;
- const getModifiedName = (data: PolicyTaxRate, code: string) => `${data.name} (${data.value})${defaultTaxKey === code ? ` • ${Localize.translateLocal('common.default')}` : ''}`;
- const taxes = Object.fromEntries(Object.entries(policyTaxRates?.taxes ?? {}).map(([code, data]) => [code, {...data, code, modifiedName: getModifiedName(data, code), name: data.name}]));
+function transformedTaxRates(taxRates: TaxRatesWithDefault | undefined): Record {
+ const defaultTaxKey = taxRates?.defaultExternalID;
+ const getModifiedName = (data: TaxRate, code: string) => `${data.name} (${data.value})${defaultTaxKey === code ? ` • ${Localize.translateLocal('common.default')}` : ''}`;
+ const taxes = Object.fromEntries(Object.entries(taxRates?.taxes ?? {}).map(([code, data]) => [code, {...data, code, modifiedName: getModifiedName(data, code), name: data.name}]));
return taxes;
}
/**
* Sorts tax rates alphabetically by name.
*/
-function sortTaxRates(taxRates: PolicyTaxRates): PolicyTaxRate[] {
+function sortTaxRates(taxRates: TaxRates): TaxRate[] {
const sortedtaxRates = lodashSortBy(taxRates, (taxRate) => taxRate.name);
return sortedtaxRates;
}
@@ -1204,7 +1197,7 @@ function sortTaxRates(taxRates: PolicyTaxRates): PolicyTaxRate[] {
/**
* Builds the options for taxRates
*/
-function getTaxRatesOptions(taxRates: Array>): Option[] {
+function getTaxRatesOptions(taxRates: Array>): Option[] {
return taxRates.map((taxRate) => ({
text: taxRate.modifiedName,
keyForList: taxRate.code,
@@ -1218,10 +1211,10 @@ function getTaxRatesOptions(taxRates: Array>): Option[] {
/**
* Builds the section list for tax rates
*/
-function getTaxRatesSection(policyTaxRates: PolicyTaxRateWithDefault | undefined, selectedOptions: Category[], searchInputValue: string): CategorySection[] {
+function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedOptions: Category[], searchInputValue: string): CategorySection[] {
const policyRatesSections = [];
- const taxes = transformedTaxRates(policyTaxRates);
+ const taxes = transformedTaxRates(taxRates);
const sortedTaxRates = sortTaxRates(taxes);
const enabledTaxRates = sortedTaxRates.filter((taxRate) => !taxRate.isDisabled);
@@ -1360,8 +1353,8 @@ function getOptions(
canInviteUser = true,
includeSelectedOptions = false,
transactionViolations = {},
- includePolicyTaxRates,
- policyTaxRates,
+ includeTaxRates,
+ taxRates,
}: GetOptionsConfig,
): GetOptions {
if (includeCategories) {
@@ -1374,7 +1367,7 @@ function getOptions(
currentUserOption: null,
categoryOptions,
tagOptions: [],
- policyTaxRatesOptions: [],
+ taxRatesOptions: [],
};
}
@@ -1388,12 +1381,12 @@ function getOptions(
currentUserOption: null,
categoryOptions: [],
tagOptions,
- policyTaxRatesOptions: [],
+ taxRatesOptions: [],
};
}
- if (includePolicyTaxRates) {
- const policyTaxRatesOptions = getTaxRatesSection(policyTaxRates, selectedOptions as Category[], searchInputValue);
+ if (includeTaxRates) {
+ const taxRatesOptions = getTaxRatesSection(taxRates, selectedOptions as Category[], searchInputValue);
return {
recentReports: [],
@@ -1402,7 +1395,7 @@ function getOptions(
currentUserOption: null,
categoryOptions: [],
tagOptions: [],
- policyTaxRatesOptions,
+ taxRatesOptions,
};
}
@@ -1414,7 +1407,7 @@ function getOptions(
currentUserOption: null,
categoryOptions: [],
tagOptions: [],
- policyTaxRatesOptions: [],
+ taxRatesOptions: [],
};
}
@@ -1494,6 +1487,10 @@ function getOptions(
return;
}
+ if (!accountIDs || accountIDs.length === 0) {
+ return;
+ }
+
// Save the report in the map if this is a single participant so we can associate the reportID with the
// personal detail option later. Individuals should not be associated with single participant
// policyExpenseChats or chatRooms since those are not people.
@@ -1699,7 +1696,7 @@ function getOptions(
currentUserOption,
categoryOptions: [],
tagOptions: [],
- policyTaxRatesOptions: [],
+ taxRatesOptions: [],
};
}
@@ -1796,8 +1793,8 @@ function getFilteredOptions(
recentlyUsedTags: string[] = [],
canInviteUser = true,
includeSelectedOptions = false,
- includePolicyTaxRates = false,
- policyTaxRates: PolicyTaxRateWithDefault = {} as PolicyTaxRateWithDefault,
+ includeTaxRates = false,
+ taxRates: TaxRatesWithDefault = {} as TaxRatesWithDefault,
) {
return getOptions(reports, personalDetails, {
betas,
@@ -1817,8 +1814,8 @@ function getFilteredOptions(
recentlyUsedTags,
canInviteUser,
includeSelectedOptions,
- includePolicyTaxRates,
- policyTaxRates,
+ includeTaxRates,
+ taxRates,
});
}
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index a85e97a4cf05..70f87a8c7373 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -3,9 +3,11 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
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, PolicyMembers, PolicyTagList, PolicyTags} from '@src/types/onyx';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import Navigation from './Navigation/Navigation';
type MemberEmailsToAccountIDs = Record;
type UnitRate = {rate: number};
@@ -93,7 +95,7 @@ function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean): boolea
);
}
-function isExpensifyTeam(email: string): boolean {
+function isExpensifyTeam(email: string | undefined): boolean {
const emailDomain = Str.extractEmailDomain(email ?? '');
return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN;
}
@@ -250,6 +252,13 @@ function getPolicyMembersByIdWithoutCurrentUser(policyMembers: OnyxCollection): boolean {
return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED;
}
-function isDeletedAction(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 === '';
@@ -103,8 +104,8 @@ function isDeletedParentAction(reportAction: OnyxEntry): boolean {
return (reportAction?.message?.[0]?.isDeletedParentAction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
}
-function isReversedTransaction(reportAction: OnyxEntry) {
- return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
+function isReversedTransaction(reportAction: OnyxEntry) {
+ return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && ((reportAction as ReportAction)?.childVisibleActionCount ?? 0) > 0;
}
function isPendingRemove(reportAction: OnyxEntry | EmptyObject): boolean {
@@ -184,9 +185,11 @@ function getParentReportAction(report: OnyxEntry | EmptyObject): ReportA
/**
* Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object.
*/
-function isSentMoneyReportAction(reportAction: OnyxEntry): boolean {
+function isSentMoneyReportAction(reportAction: OnyxEntry): boolean {
return (
- reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction?.originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!reportAction?.originalMessage?.IOUDetails
+ reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
+ (reportAction?.originalMessage as IOUMessage)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY &&
+ !!(reportAction?.originalMessage as IOUMessage)?.IOUDetails
);
}
@@ -517,7 +520,7 @@ 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 | null, shouldMarkTheFirstItemAsNewest = false): ReportAction[] {
+function getSortedReportActionsForDisplay(reportActions: ReportActions | ReportAction[] | null, shouldMarkTheFirstItemAsNewest = false): ReportAction[] {
const filteredReportActions = Object.entries(reportActions ?? {})
.filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key))
.map((entry) => entry[1]);
@@ -700,8 +703,8 @@ function getAllReportActions(reportID: string): ReportActions {
function isReportActionAttachment(reportAction: OnyxEntry): boolean {
const message = reportAction?.message?.[0];
- if (reportAction && 'isAttachment' in reportAction) {
- return reportAction.isAttachment ?? false;
+ if (reportAction && ('isAttachment' in reportAction || 'attachmentInfo' in reportAction)) {
+ return reportAction?.isAttachment ?? !!reportAction?.attachmentInfo ?? false;
}
if (message) {
@@ -800,14 +803,6 @@ function getMemberChangeMessageFragment(reportAction: OnyxEntry):
};
}
-/**
- * MARKEDREIMBURSED reportActions come from marking a report as reimbursed in OldDot. For now, we just
- * concat all of the text elements of the message to create the full message.
- */
-function getMarkedReimbursedMessage(reportAction: OnyxEntry): string {
- return reportAction?.message?.map((element) => element.text).join('') ?? '';
-}
-
function getMemberChangeMessagePlainText(reportAction: OnyxEntry): string {
const messageElements = getMemberChangeMessageElements(reportAction);
return messageElements.map((element) => element.content).join('');
@@ -935,7 +930,6 @@ export {
hasRequestFromCurrentAccount,
getFirstVisibleReportActionID,
isMemberChangeAction,
- getMarkedReimbursedMessage,
getMemberChangeMessageFragment,
getMemberChangeMessagePlainText,
isReimbursementDeQueuedAction,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 05e2db66d629..8813501e2b3f 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -52,6 +52,7 @@ import * as CollectionUtils from './CollectionUtils';
import * as CurrencyUtils from './CurrencyUtils';
import DateUtils from './DateUtils';
import isReportMessageAttachment from './isReportMessageAttachment';
+import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
import * as Localize from './Localize';
import linkingConfig from './Navigation/linkingConfig';
@@ -722,12 +723,10 @@ function hasParticipantInArray(report: Report, policyMemberAccountIDs: number[])
/**
* Whether the Money Request report is settled
*/
-function isSettled(reportOrID: Report | OnyxEntry | string | undefined): boolean {
- if (!allReports || !reportOrID) {
+function isSettled(reportID: string | undefined): boolean {
+ if (!allReports || !reportID) {
return false;
}
- const reportID = typeof reportOrID === 'string' ? reportOrID : reportOrID?.reportID;
-
const report: Report | EmptyObject = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? {};
if (isEmptyObject(report) || report.isWaitingOnBankAccount) {
return false;
@@ -1443,7 +1442,7 @@ function getIconsForParticipants(participants: number[], personalDetails: OnyxCo
const sortedParticipantDetails = participantDetails.sort((first, second) => {
// First sort by displayName/login
- const displayNameLoginOrder = first[1].localeCompare(second[1]);
+ const displayNameLoginOrder = localeCompare(first[1], second[1]);
if (displayNameLoginOrder !== 0) {
return displayNameLoginOrder;
}
@@ -1618,22 +1617,6 @@ function getPersonalDetailsForAccountID(accountID: number): Partial {
// First sort by displayName/login
- const displayNameLoginOrder = first.displayName.localeCompare(second.displayName);
+ const displayNameLoginOrder = localeCompare(first.displayName, second.displayName);
if (displayNameLoginOrder !== 0) {
return displayNameLoginOrder;
}
@@ -2216,7 +2199,7 @@ function canEditReportAction(reportAction: OnyxEntry): boolean {
reportAction?.actorAccountID === currentUserAccountID &&
isCommentOrIOU &&
canEditMoneyRequest(reportAction) && // Returns true for non-IOU actions
- !isReportMessageAttachment(reportAction?.message?.[0] ?? {type: '', text: ''}) &&
+ !ReportActionsUtils.isReportActionAttachment(reportAction) &&
!ReportActionsUtils.isDeletedAction(reportAction) &&
!ReportActionsUtils.isCreatedTaskReportAction(reportAction) &&
reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
@@ -2259,7 +2242,7 @@ function hasMissingSmartscanFields(iouReportID: string): boolean {
/**
* Given a parent IOU report action get report name for the LHN.
*/
-function getTransactionReportName(reportAction: OnyxEntry): string {
+function getTransactionReportName(reportAction: OnyxEntry): string {
if (ReportActionsUtils.isReversedTransaction(reportAction)) {
return Localize.translateLocal('parentReportAction.reversedTransaction');
}
@@ -2370,7 +2353,9 @@ function getReportPreviewMessage(
if (isSettled(report.reportID) || (report.isWaitingOnBankAccount && isPreviewMessageForParentChatReport)) {
// A settled report preview message can come in three formats "paid ... elsewhere" or "paid ... with Expensify"
let translatePhraseKey: TranslationPaths = 'iou.paidElsewhereWithAmount';
- if (
+ if (isPreviewMessageForParentChatReport) {
+ translatePhraseKey = 'iou.payerPaidAmount';
+ } else if (
[CONST.IOU.PAYMENT_TYPE.VBBA, CONST.IOU.PAYMENT_TYPE.EXPENSIFY].some((paymentType) => paymentType === originalMessage?.paymentType) ||
!!reportActionMessage.match(/ (with Expensify|using Expensify)$/) ||
report.isWaitingOnBankAccount
@@ -3761,7 +3746,7 @@ function buildOptimisticTaskReport(
*
* @param moneyRequestReportID - the reportID which the report action belong to
*/
-function buildTransactionThread(reportAction: OnyxEntry, moneyRequestReportID: string): OptimisticChatReport {
+function buildTransactionThread(reportAction: OnyxEntry, moneyRequestReportID: string): OptimisticChatReport {
const participantAccountIDs = [...new Set([currentUserAccountID, Number(reportAction?.actorAccountID)])].filter(Boolean) as number[];
return buildOptimisticChatReport(
participantAccountIDs,
@@ -3965,6 +3950,13 @@ function shouldReportBeInOptionList({
return true;
}
+ const reportIsSettled = report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED;
+
+ // Always show IOU reports with violations unless they are reimbursed
+ if (isExpenseRequest(report) && doesReportHaveViolations && !reportIsSettled) {
+ return true;
+ }
+
// Hide only chat threads that haven't been commented on (other threads are actionable)
if (isChatThread(report) && canHideReport && isEmptyChat) {
return false;
@@ -3976,11 +3968,6 @@ function shouldReportBeInOptionList({
return true;
}
- // Always show IOU reports with violations
- if (isExpenseRequest(report) && doesReportHaveViolations) {
- return true;
- }
-
// All unread chats (even archived ones) in GSD mode will be shown. This is because GSD mode is specifically for focusing the user on the most relevant chats, primarily, the unread ones
if (isInGSDMode) {
return isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE;
@@ -4525,6 +4512,13 @@ function canEditWriteCapability(report: OnyxEntry, policy: OnyxEntry, policy: OnyxEntry): boolean {
+ return PolicyUtils.isPolicyAdmin(policy) && !isArchivedRoom(report);
+}
+
/**
* Returns the onyx data needed for the task assignee chat
*/
@@ -4704,7 +4698,6 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry)
// property. If it does, it indicates that this is a 'Send money' action.
const {amount, currency} = originalMessage.IOUDetails ?? originalMessage;
const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(amount), currency) ?? '';
- const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport?.managerID, true);
switch (originalMessage.paymentType) {
case CONST.IOU.PAYMENT_TYPE.ELSEWHERE:
@@ -4718,7 +4711,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry)
translationKey = 'iou.payerPaidAmount';
break;
}
- return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: payerName ?? ''});
+ return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: ''});
}
const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID ?? '');
@@ -5215,6 +5208,7 @@ export {
getAvailableReportFields,
reportFieldsEnabled,
getAllAncestorReportActionIDs,
+ canEditRoomVisibility,
canEditPolicyDescription,
getPolicyDescriptionText,
};
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 49436576295c..d9298817f6b7 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -242,7 +242,9 @@ function getOptionData({
result.policyID = report.policyID;
result.stateNum = report.stateNum;
result.statusNum = report.statusNum;
- result.isUnread = ReportUtils.isUnread(report);
+ // When the only message of a report is deleted lastVisibileActionCreated is not reset leading to wrongly
+ // setting it Unread so we add additional condition here to avoid empty chat LHN from being bold.
+ result.isUnread = ReportUtils.isUnread(report) && !!report.lastActorAccountID;
result.isUnreadWithMention = ReportUtils.isUnreadWithMention(report);
result.hasDraftComment = report.hasDraft;
result.isPinned = report.isPinned;
diff --git a/src/libs/Sound/playSoundExcludingMobile/index.native.ts b/src/libs/Sound/playSoundExcludingMobile/index.native.ts
new file mode 100644
index 000000000000..c41ad6998483
--- /dev/null
+++ b/src/libs/Sound/playSoundExcludingMobile/index.native.ts
@@ -0,0 +1,2 @@
+// mobile platform plays a sound when notification is delivered (in native code)
+export default function playSoundExcludingMobile() {}
diff --git a/src/libs/Sound/playSoundExcludingMobile/index.ts b/src/libs/Sound/playSoundExcludingMobile/index.ts
new file mode 100644
index 000000000000..03c5cd57a635
--- /dev/null
+++ b/src/libs/Sound/playSoundExcludingMobile/index.ts
@@ -0,0 +1,5 @@
+import playSound from '..';
+
+const playSoundExcludingMobile: typeof playSound = (sound) => playSound(sound);
+
+export default playSoundExcludingMobile;
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index d3eafc6554db..3489053951b6 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -4,13 +4,15 @@ import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {RecentWaypoint, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx';
-import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates';
-import type {Comment, Receipt, TransactionChanges, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction';
+import type {RecentWaypoint, Report, ReportAction, TaxRate, TaxRates, Transaction, TransactionViolation} from '@src/types/onyx';
+import type {IOUMessage} from '@src/types/onyx/OriginalMessage';
+import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
import {isCorporateCard, isExpensifyCard} from './CardUtils';
import DateUtils from './DateUtils';
import * as NumberUtils from './NumberUtils';
+import type {OptimisticIOUReportAction} from './ReportUtils';
let allTransactions: OnyxCollection = {};
@@ -94,6 +96,7 @@ function buildOptimisticTransaction(
category = '',
tag = '',
billable = false,
+ pendingFields: Partial<{[K in TransactionPendingFieldsKey]: ValueOf}> | undefined = undefined,
): Transaction {
// transactionIDs are random, positive, 64-bit numeric strings.
// Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID)
@@ -108,6 +111,7 @@ function buildOptimisticTransaction(
}
return {
+ ...(!isEmptyObject(pendingFields) ? {pendingFields} : {}),
transactionID,
amount,
currency,
@@ -364,18 +368,50 @@ function getBillable(transaction: OnyxEntry): boolean {
return transaction?.billable ?? false;
}
+/**
+ * Return a colon-delimited tag string as an array, considering escaped colons and double backslashes.
+ */
+function getTagArrayFromName(tagName: string): string[] {
+ // WAIT!!!!!!!!!!!!!!!!!!
+ // You need to keep this in sync with TransactionUtils.php
+
+ // We need to be able to preserve double backslashes in the original string
+ // and not have it interfere with splitting on a colon (:).
+ // So, let's replace it with something absurd to begin with, do our split, and
+ // then replace the double backslashes in the end.
+ const tagWithoutDoubleSlashes = tagName.replace(/\\\\/g, '☠');
+ const tagWithoutEscapedColons = tagWithoutDoubleSlashes.replace(/\\:/g, '☢');
+
+ // Do our split
+ const matches = tagWithoutEscapedColons.split(':');
+ const newMatches: string[] = [];
+
+ for (const item of matches) {
+ const tagWithEscapedColons = item.replace(/☢/g, '\\:');
+ const tagWithDoubleSlashes = tagWithEscapedColons.replace(/☠/g, '\\\\');
+ newMatches.push(tagWithDoubleSlashes);
+ }
+
+ return newMatches;
+}
+
/**
* Return the tag from the transaction. When the tagIndex is passed, return the tag based on the index.
* This "tag" field has no "modified" complement.
*/
function getTag(transaction: OnyxEntry, tagIndex?: number): string {
if (tagIndex !== undefined) {
- return transaction?.tag?.split(CONST.COLON)[tagIndex] ?? '';
+ const tagsArray = getTagArrayFromName(transaction?.tag ?? '');
+ return tagsArray[tagIndex] ?? '';
}
return transaction?.tag ?? '';
}
+function getTagForDisplay(transaction: OnyxEntry, tagIndex?: number): string {
+ return getTag(transaction, tagIndex).replace(/[\\\\]:/g, ':');
+}
+
/**
* Return the created field from the transaction, return the modifiedCreated if present.
*/
@@ -460,11 +496,11 @@ function hasRoute(transaction: Transaction): boolean {
*
* @deprecated Use Onyx.connect() or withOnyx() instead
*/
-function getLinkedTransaction(reportAction: OnyxEntry): Transaction | EmptyObject {
+function getLinkedTransaction(reportAction: OnyxEntry): Transaction | EmptyObject {
let transactionID = '';
if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) {
- transactionID = reportAction.originalMessage?.IOUTransactionID ?? '';
+ transactionID = (reportAction?.originalMessage as IOUMessage)?.IOUTransactionID ?? '';
}
return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {};
@@ -550,20 +586,11 @@ function isOnHold(transaction: OnyxEntry): boolean {
/**
* Checks if any violations for the provided transaction are of type 'violation'
*/
-function hasViolation(transactionOrID: Transaction | OnyxEntry | string, transactionViolations: OnyxCollection): boolean {
- if (!transactionOrID) {
- return false;
- }
- const transactionID = typeof transactionOrID === 'string' ? transactionOrID : transactionOrID.transactionID;
+function hasViolation(transactionID: string, transactionViolations: OnyxCollection): boolean {
return Boolean(transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === 'violation'));
}
-function getTransactionViolations(transactionOrID: OnyxEntry | string, transactionViolations: OnyxCollection): TransactionViolation[] | null {
- if (!transactionOrID) {
- return null;
- }
- const transactionID = typeof transactionOrID === 'string' ? transactionOrID : transactionOrID.transactionID;
-
+function getTransactionViolations(transactionID: string, transactionViolations: OnyxCollection): TransactionViolation[] | null {
return transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID] ?? null;
}
@@ -578,8 +605,8 @@ function calculateTaxAmount(percentage: string, amount: number) {
/**
* Calculates count of all tax enabled options
*/
-function getEnabledTaxRateCount(options: PolicyTaxRates) {
- return Object.values(options).filter((option: PolicyTaxRate) => !option.isDisabled).length;
+function getEnabledTaxRateCount(options: TaxRates) {
+ return Object.values(options).filter((option: TaxRate) => !option.isDisabled).length;
}
export {
@@ -605,6 +632,8 @@ export {
getCategory,
getBillable,
getTag,
+ getTagArrayFromName,
+ getTagForDisplay,
getTransactionViolations,
getLinkedTransaction,
getAllReportTransactions,
diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts
index e3a667fd5a44..12b52524f113 100644
--- a/src/libs/UserUtils.ts
+++ b/src/libs/UserUtils.ts
@@ -173,7 +173,7 @@ function getAvatarUrl(avatarSource: AvatarSource | undefined, accountID: number)
* Avatars uploaded by users will have a _128 appended so that the asset server returns a small version.
* This removes that part of the URL so the full version of the image can load.
*/
-function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID: number): AvatarSource {
+function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID?: number): AvatarSource {
const source = getAvatar(avatarSource, accountID);
if (typeof source !== 'string') {
return source;
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 02ae638a41d3..0a46acbea102 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -5,7 +5,7 @@ import isDate from 'lodash/isDate';
import isEmpty from 'lodash/isEmpty';
import isObject from 'lodash/isObject';
import type {OnyxCollection} from 'react-native-onyx';
-import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types';
+import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormValue} from '@components/Form/types';
import CONST from '@src/CONST';
import type {OnyxFormKey} from '@src/ONYXKEYS';
import type {Report} from '@src/types/onyx';
@@ -37,7 +37,11 @@ function validateCardNumber(value: string): boolean {
/**
* Validating that this is a valid address (PO boxes are not allowed)
*/
-function isValidAddress(value: string): boolean {
+function isValidAddress(value: FormValue): boolean {
+ if (typeof value !== 'string') {
+ return false;
+ }
+
if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.EMOJIS)) {
return false;
}
@@ -77,7 +81,7 @@ function isValidPastDate(date: string | Date): boolean {
* Used to validate a value that is "required".
* @param value - field value
*/
-function isRequiredFulfilled(value?: string | boolean | Date): boolean {
+function isRequiredFulfilled(value?: FormValue): boolean {
if (!value) {
return false;
}
@@ -103,7 +107,7 @@ function getFieldRequiredErrors(values: FormOnyxVal
const errors: FormInputErrors = {};
requiredFields.forEach((fieldKey) => {
- if (isRequiredFulfilled(values[fieldKey] as keyof FormOnyxValues)) {
+ if (isRequiredFulfilled(values[fieldKey] as FormValue)) {
return;
}
diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts
index a1cd001badee..6153ea62cd0d 100644
--- a/src/libs/Violations/ViolationsUtils.ts
+++ b/src/libs/Violations/ViolationsUtils.ts
@@ -30,7 +30,7 @@ const ViolationsUtils = {
// Add 'categoryOutOfPolicy' violation if category is not in policy
if (!hasCategoryOutOfPolicyViolation && categoryKey && !isCategoryInPolicy) {
- newTransactionViolations.push({name: 'categoryOutOfPolicy', type: 'violation', userMessage: ''});
+ newTransactionViolations.push({name: 'categoryOutOfPolicy', type: 'violation'});
}
// Remove 'categoryOutOfPolicy' violation if category is in policy
@@ -45,72 +45,40 @@ const ViolationsUtils = {
// Add 'missingCategory' violation if category is required and not set
if (!hasMissingCategoryViolation && policyRequiresCategories && !categoryKey) {
- newTransactionViolations.push({name: 'missingCategory', type: 'violation', userMessage: ''});
+ newTransactionViolations.push({name: 'missingCategory', type: 'violation'});
}
}
if (policyRequiresTags) {
- const selectedTags = updatedTransaction.tag?.split(CONST.COLON) ?? [];
const policyTagKeys = Object.keys(policyTagList);
- if (policyTagKeys.length === 0) {
- newTransactionViolations.push({
- name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY,
- type: 'violation',
- userMessage: '',
- });
- }
-
- policyTagKeys.forEach((key, index) => {
- const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && violation.data?.tagName === key);
- const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG && violation.data?.tagName === key);
- const selectedTag = selectedTags[index];
- const isTagInPolicy = Boolean(policyTagList[key]?.tags[selectedTag]?.enabled);
+ // At the moment, we only return violations for tags for workspaces with single-level tags
+ if (policyTagKeys.length === 1) {
+ const policyTagListName = policyTagKeys[0];
+ const policyTags = policyTagList[policyTagListName]?.tags;
+ const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY);
+ const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG);
+ const isTagInPolicy = policyTags ? !!policyTags[updatedTransaction.tag ?? '']?.enabled : false;
// Add 'tagOutOfPolicy' violation if tag is not in policy
- if (!hasTagOutOfPolicyViolation && selectedTag && !isTagInPolicy) {
- newTransactionViolations.push({
- name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY,
- type: 'violation',
- userMessage: '',
- data: {
- tagName: key,
- },
- });
+ if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy) {
+ newTransactionViolations.push({name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, type: 'violation'});
}
// Remove 'tagOutOfPolicy' violation if tag is in policy
- if (hasTagOutOfPolicyViolation && selectedTag && isTagInPolicy) {
- newTransactionViolations = reject(newTransactionViolations, {
- name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY,
- data: {
- tagName: key,
- },
- });
+ if (hasTagOutOfPolicyViolation && updatedTransaction.tag && isTagInPolicy) {
+ newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY});
}
// Remove 'missingTag' violation if tag is valid according to policy
if (hasMissingTagViolation && isTagInPolicy) {
- newTransactionViolations = reject(newTransactionViolations, {
- name: CONST.VIOLATIONS.MISSING_TAG,
- data: {
- tagName: key,
- },
- });
+ newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_TAG});
}
-
// Add 'missingTag violation' if tag is required and not set
- if (!hasMissingTagViolation && !selectedTag && policyRequiresTags) {
- newTransactionViolations.push({
- name: CONST.VIOLATIONS.MISSING_TAG,
- type: 'violation',
- userMessage: '',
- data: {
- tagName: key,
- },
- });
+ if (!hasMissingTagViolation && !updatedTransaction.tag && policyRequiresTags) {
+ newTransactionViolations.push({name: CONST.VIOLATIONS.MISSING_TAG, type: 'violation'});
}
- });
+ }
}
return {
diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts
index 30dd03b6e780..0f4e1aed36a7 100644
--- a/src/libs/actions/BankAccounts.ts
+++ b/src/libs/actions/BankAccounts.ts
@@ -366,7 +366,7 @@ function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subS
* Updates the bank account in the database with the company step data
* @param params - Business step form data
*/
-function updateCompanyInformationForBankAccount(bankAccountID: number, params: CompanyStepProps, policyID: string) {
+function updateCompanyInformationForBankAccount(bankAccountID: number, params: Partial, policyID: string) {
API.write(
WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT,
{
@@ -383,7 +383,7 @@ function updateCompanyInformationForBankAccount(bankAccountID: number, params: C
* Add beneficial owners for the bank account and verify the accuracy of the information provided
* @param params - Beneficial Owners step form params
*/
-function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: BeneficialOwnersStepProps, policyID: string) {
+function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: Partial, policyID: string) {
API.write(
WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT,
{
diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts
index 2cc32616562d..756ef902d913 100644
--- a/src/libs/actions/Card.ts
+++ b/src/libs/actions/Card.ts
@@ -178,3 +178,4 @@ function revealVirtualCardDetails(cardID: number): Promise {
}
export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud, revealVirtualCardDetails};
+export type {ReplacementReason};
diff --git a/src/libs/actions/ExitSurvey.ts b/src/libs/actions/ExitSurvey.ts
new file mode 100644
index 000000000000..ef3ecd6d3e31
--- /dev/null
+++ b/src/libs/actions/ExitSurvey.ts
@@ -0,0 +1,78 @@
+import type {OnyxUpdate} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
+import * as API from '@libs/API';
+import ONYXKEYS from '@src/ONYXKEYS';
+import REASON_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm';
+import type {ExitReason} from '@src/types/form/ExitSurveyReasonForm';
+import RESPONSE_INPUT_IDS from '@src/types/form/ExitSurveyResponseForm';
+
+let exitReason: ExitReason | undefined;
+let exitSurveyResponse: string | undefined;
+Onyx.connect({
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM,
+ callback: (value) => (exitReason = value?.[REASON_INPUT_IDS.REASON]),
+});
+Onyx.connect({
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM,
+ callback: (value) => (exitSurveyResponse = value?.[RESPONSE_INPUT_IDS.RESPONSE]),
+});
+
+function saveExitReason(reason: ExitReason) {
+ Onyx.set(ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM, {[REASON_INPUT_IDS.REASON]: reason});
+}
+
+function saveResponse(response: string) {
+ Onyx.set(ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM, {[RESPONSE_INPUT_IDS.RESPONSE]: response});
+}
+
+/**
+ * Save the user's response to the mandatory exit survey in the back-end.
+ */
+function switchToOldDot() {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.IS_SWITCHING_TO_OLD_DOT,
+ value: true,
+ },
+ ];
+
+ const finallyData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.IS_SWITCHING_TO_OLD_DOT,
+ value: false,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM_DRAFT,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM_DRAFT,
+ value: null,
+ },
+ ];
+
+ API.write(
+ 'SwitchToOldDot',
+ {
+ reason: exitReason,
+ surveyResponse: exitSurveyResponse,
+ },
+ {optimisticData, finallyData},
+ );
+}
+
+export {saveExitReason, saveResponse, switchToOldDot};
diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts
index 3a0bdb94d5f5..8207b78e8759 100644
--- a/src/libs/actions/FormActions.ts
+++ b/src/libs/actions/FormActions.ts
@@ -1,6 +1,5 @@
import Onyx from 'react-native-onyx';
import type {NullishDeep} from 'react-native-onyx';
-import FormUtils from '@libs/FormUtils';
import type {OnyxFormDraftKey, OnyxFormKey, OnyxValue} from '@src/ONYXKEYS';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
@@ -25,11 +24,11 @@ function clearErrorFields(formID: OnyxFormKey) {
}
function setDraftValues(formID: OnyxFormKey, draftValues: NullishDeep>) {
- Onyx.merge(FormUtils.getDraftKey(formID), draftValues);
+ Onyx.merge(`${formID}Draft`, draftValues);
}
function clearDraftValues(formID: OnyxFormKey) {
- Onyx.set(FormUtils.getDraftKey(formID), null);
+ Onyx.set(`${formID}Draft`, null);
}
export {setDraftValues, setErrorFields, setErrors, clearErrors, clearErrorFields, setIsLoading, clearDraftValues};
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 47d10ddcef4b..37308c73e724 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -60,7 +60,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import * as Policy from './Policy';
import * as Report from './Report';
-type MoneyRequestRoute = StackScreenProps['route'];
+type MoneyRequestRoute = StackScreenProps['route'];
type IOURequestType = ValueOf;
@@ -76,6 +76,8 @@ type MoneyRequestInformation = {
createdChatReportActionID: string;
createdIOUReportActionID: string;
reportPreviewAction: OnyxTypes.ReportAction;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
onyxData: OnyxData;
};
@@ -223,8 +225,7 @@ Onyx.connect({
* @param reportID to attach the transaction to
* @param iouRequestType one of manual/scan/distance
*/
-// eslint-disable-next-line @typescript-eslint/naming-convention
-function startMoneyRequest_temporaryForRefactor(reportID: string, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) {
+function initMoneyRequest(reportID: string, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) {
// Generate a brand new transactionID
const newTransactionID = CONST.IOU.OPTIMISTIC_TRANSACTION_ID;
// Disabling this line since currentDate can be an empty string
@@ -259,6 +260,12 @@ function clearMoneyRequest(transactionID: string) {
Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null);
}
+// eslint-disable-next-line @typescript-eslint/naming-convention
+function startMoneyRequest_temporaryForRefactor(iouType: ValueOf, reportID: string) {
+ clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID));
+}
+
// eslint-disable-next-line @typescript-eslint/naming-convention
function setMoneyRequestAmount_temporaryForRefactor(transactionID: string, amount: number, currency: string, removeOriginalCurrency = false) {
if (removeOriginalCurrency) {
@@ -299,16 +306,10 @@ function setMoneyRequestPendingFields(transactionID: string, pendingFields: Pend
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields});
}
-// eslint-disable-next-line @typescript-eslint/naming-convention
-function setMoneyRequestCategory_temporaryForRefactor(transactionID: string, category: string) {
+function setMoneyRequestCategory(transactionID: string, category: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category});
}
-// eslint-disable-next-line @typescript-eslint/naming-convention
-function resetMoneyRequestCategory_temporaryForRefactor(transactionID: string) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category: null});
-}
-
function setMoneyRequestTag(transactionID: string, tag: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {tag});
}
@@ -391,6 +392,8 @@ function buildOnyxDataForMoneyRequest(
optimisticPolicyRecentlyUsedCategories: string[],
optimisticPolicyRecentlyUsedTags: OnyxTypes.RecentlyUsedTags,
isNewChatReport: boolean,
+ transactionThreadReport: OptimisticChatReport,
+ transactionThreadCreatedReportAction: OptimisticCreatedReportAction,
shouldCreateNewMoneyRequestReport: boolean,
policy?: OnyxEntry,
policyTagList?: OnyxEntry,
@@ -400,6 +403,7 @@ function buildOnyxDataForMoneyRequest(
): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] {
const isScanRequest = TransactionUtils.isScanRequest(transaction);
const outstandingChildRequest = getOutstandingChildRequest(needsToBeManuallySubmitted, policy);
+ const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null]));
const optimisticData: OnyxUpdate[] = [];
if (chatReport) {
@@ -469,6 +473,19 @@ function buildOnyxDataForMoneyRequest(
},
},
+ {
+ 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,
@@ -531,12 +548,20 @@ function buildOnyxDataForMoneyRequest(
errorFields: null,
},
},
+ {
+ 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: null,
+ pendingFields: clearedPendingFields,
},
},
@@ -575,6 +600,16 @@ function buildOnyxDataForMoneyRequest(
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`,
+ value: {
+ [transactionThreadCreatedReportAction.reportActionID]: {
+ pendingAction: null,
+ errors: null,
+ },
+ },
+ },
);
const failureData: OnyxUpdate[] = [
@@ -605,23 +640,24 @@ function buildOnyxDataForMoneyRequest(
},
},
},
+ {
+ 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: null,
+ pendingFields: clearedPendingFields,
},
},
-
- // 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,
- },
-
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
@@ -671,6 +707,15 @@ function buildOnyxDataForMoneyRequest(
}),
},
},
+ {
+ 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
@@ -789,6 +834,8 @@ function getMoneyRequestInformation(
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(
ReportUtils.isExpenseReport(iouReport) ? -amount : amount,
currency,
@@ -804,6 +851,7 @@ function getMoneyRequestInformation(
category,
tag,
billable,
+ isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined,
);
const optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category);
@@ -814,8 +862,7 @@ function getMoneyRequestInformation(
// 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.
- const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`];
- if (existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) {
+ if (isDistanceRequest) {
optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false);
}
@@ -823,7 +870,8 @@ function getMoneyRequestInformation(
// 1. CREATED action for the chatReport
// 2. CREATED action for the iouReport
// 3. IOU action for the iouReport
- // 4. REPORTPREVIEW action for the chatReport
+ // 4. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread
+ // 5. REPORTPREVIEW action for the chatReport
// Note: The CREATED action for the IOU report must be optimistically generated before the IOU action so there's no chance that it appears after the IOU action in the chat
const currentTime = DateUtils.getDBTime();
const optimisticCreatedActionForChat = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail);
@@ -843,6 +891,8 @@ function getMoneyRequestInformation(
false,
currentTime,
);
+ const optimisticTransactionThread = ReportUtils.buildTransactionThread(iouAction, iouReport.reportID);
+ const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail);
let reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID);
if (reportPreviewAction) {
@@ -886,6 +936,8 @@ function getMoneyRequestInformation(
optimisticPolicyRecentlyUsedCategories,
optimisticPolicyRecentlyUsedTags,
isNewChatReport,
+ optimisticTransactionThread,
+ optimisticCreatedActionForTransactionThread,
shouldCreateNewMoneyRequestReport,
policy,
policyTagList,
@@ -904,6 +956,8 @@ function getMoneyRequestInformation(
createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : '0',
createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOU.reportActionID : '0',
reportPreviewAction,
+ transactionThreadReportID: optimisticTransactionThread.reportID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
onyxData: {
optimisticData,
successData,
@@ -939,7 +993,18 @@ function createDistanceRequest(
source: ReceiptGeneric as ReceiptSource,
state: CONST.IOU.RECEIPT_STATE.OPEN,
};
- const {iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = getMoneyRequestInformation(
+ const {
+ iouReport,
+ chatReport,
+ transaction,
+ iouAction,
+ createdChatReportActionID,
+ createdIOUReportActionID,
+ reportPreviewAction,
+ transactionThreadReportID,
+ createdReportActionIDForThread,
+ onyxData,
+ } = getMoneyRequestInformation(
currentChatReport,
participant,
comment,
@@ -974,6 +1039,8 @@ function createDistanceRequest(
category,
tag,
billable,
+ transactionThreadReportID,
+ createdReportActionIDForThread,
};
API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData);
@@ -1003,7 +1070,7 @@ function calculateDiffAmount(iouReport: OnyxEntry, updatedTran
// Subtract the diff from the total if we change the currency from the currency of IOU report to a different currency
return -updatedAmount;
}
- if (updatedCurrency === iouReport?.currency && updatedTransaction?.modifiedAmount) {
+ if (updatedCurrency === iouReport?.currency && updatedAmount !== currentAmount) {
// Calculate the diff between the updated amount and the current amount if we change the amount and the currency of the transaction is the currency of the report
return updatedAmount - currentAmount;
}
@@ -1127,32 +1194,32 @@ function getUpdateMoneyRequestParams(
},
},
});
+ }
- // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct.
- let updatedMoneyRequestReport = {...iouReport};
- const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction);
-
- if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') {
- // For expense report, the amount is negative so we should subtract total from diff
- updatedMoneyRequestReport.total -= diff;
- } else {
- updatedMoneyRequestReport = iouReport
- ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true)
- : {};
- }
- updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency);
+ // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct.
+ let updatedMoneyRequestReport = {...iouReport};
+ const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction);
- optimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
- value: updatedMoneyRequestReport,
- });
- successData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
- value: {pendingAction: null},
- });
+ if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') {
+ // For expense report, the amount is negative so we should subtract total from diff
+ updatedMoneyRequestReport.total -= diff;
+ } else {
+ updatedMoneyRequestReport = iouReport
+ ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true)
+ : {};
}
+ updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency);
+
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
+ value: updatedMoneyRequestReport,
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
+ value: {pendingAction: null},
+ });
// Optimistically modify the transaction and the transaction thread
optimisticData.push({
@@ -1438,27 +1505,39 @@ function requestMoney(
const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report;
const moneyRequestReportID = isMoneyRequestReport ? report.reportID : '';
const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created);
- const {payerAccountID, payerEmail, iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} =
- getMoneyRequestInformation(
- currentChatReport,
- participant,
- comment,
- amount,
- currency,
- currentCreated,
- merchant,
- receipt,
- undefined,
- category,
- tag,
- billable,
- policy,
- policyTagList,
- policyCategories,
- payeeAccountID,
- payeeEmail,
- moneyRequestReportID,
- );
+ const {
+ payerAccountID,
+ payerEmail,
+ iouReport,
+ chatReport,
+ transaction,
+ iouAction,
+ createdChatReportActionID,
+ createdIOUReportActionID,
+ reportPreviewAction,
+ transactionThreadReportID,
+ createdReportActionIDForThread,
+ onyxData,
+ } = getMoneyRequestInformation(
+ currentChatReport,
+ participant,
+ comment,
+ amount,
+ currency,
+ currentCreated,
+ merchant,
+ receipt,
+ undefined,
+ category,
+ tag,
+ billable,
+ policy,
+ policyTagList,
+ policyCategories,
+ payeeAccountID,
+ payeeEmail,
+ moneyRequestReportID,
+ );
const activeReportID = isMoneyRequestReport ? report.reportID : chatReport.reportID;
const parameters: RequestMoneyParams = {
@@ -1483,9 +1562,10 @@ function requestMoney(
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.REQUEST_MONEY, parameters, onyxData);
@@ -1605,6 +1685,11 @@ function createSplitsAndOnyxData(
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`,
value: splitTransaction,
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`,
+ value: null,
+ },
];
const successData: OnyxUpdate[] = [
@@ -1621,11 +1706,6 @@ function createSplitsAndOnyxData(
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`,
value: {pendingAction: null},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`,
- value: null,
- },
];
if (!existingSplitChatReport) {
@@ -1644,11 +1724,6 @@ function createSplitsAndOnyxData(
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`,
- value: null,
- },
];
if (existingSplitChatReport) {
@@ -1813,6 +1888,10 @@ function createSplitsAndOnyxData(
// Add tag to optimistic policy recently used tags when a participant is a workspace
const optimisticPolicyRecentlyUsedTags = isPolicyExpenseChat ? Policy.buildOptimisticPolicyRecentlyUsedTags(participant.policyID, tag) : {};
+ // Create optimistic transactionThread
+ const optimisticTransactionThread = ReportUtils.buildTransactionThread(oneOnOneIOUAction, oneOnOneIOUReport.reportID);
+ const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit);
+
// STEP 5: Build Onyx Data
const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest(
oneOnOneChatReport,
@@ -1826,6 +1905,8 @@ function createSplitsAndOnyxData(
optimisticPolicyRecentlyUsedCategories,
optimisticPolicyRecentlyUsedTags,
isNewOneOnOneChatReport,
+ optimisticTransactionThread,
+ optimisticCreatedActionForTransactionThread,
shouldCreateNewOneOnOneIOUReport,
);
@@ -1840,6 +1921,8 @@ function createSplitsAndOnyxData(
createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID,
createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID,
reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID,
+ transactionThreadReportID: optimisticTransactionThread.reportID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
};
splits.push(individualSplit);
@@ -2410,6 +2493,9 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction);
}
+ const optimisticTransactionThread = ReportUtils.buildTransactionThread(oneOnOneIOUAction, oneOnOneIOUReport.reportID);
+ const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit);
+
const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest(
oneOnOneChatReport,
oneOnOneIOUReport,
@@ -2422,6 +2508,8 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
[],
{},
isNewOneOnOneChatReport,
+ optimisticTransactionThread,
+ optimisticCreatedActionForTransactionThread,
shouldCreateNewOneOnOneIOUReport,
);
@@ -2436,6 +2524,8 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID,
createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID,
reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID,
+ transactionThreadReportID: optimisticTransactionThread.reportID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
});
optimisticData.push(...oneOnOneOptimisticData);
@@ -3122,6 +3212,9 @@ function getSendMoneyParams(
const reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, optimisticIOUReport);
+ const optimisticTransactionThread = ReportUtils.buildTransactionThread(optimisticIOUReportAction, optimisticIOUReport.reportID);
+ const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(recipientEmail);
+
// Change the method to set for new reports because it doesn't exist yet, is faster,
// and we need the data to be available when we navigate to the chat page
const optimisticChatReportData: OnyxUpdate = isNewChat
@@ -3154,6 +3247,11 @@ function getSendMoneyParams(
lastMessageHtml: optimisticIOUReportAction.message?.[0].html,
},
};
+ const optimisticTransactionThreadData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThread.reportID}`,
+ value: optimisticTransactionThread,
+ };
const optimisticIOUReportActionsData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`,
@@ -3171,6 +3269,13 @@ function getSendMoneyParams(
[reportPreviewAction.reportActionID]: reportPreviewAction,
},
};
+ const optimisticTransactionThreadReportActionsData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`,
+ value: {
+ [optimisticCreatedActionForTransactionThread.reportActionID]: optimisticCreatedActionForTransactionThread,
+ },
+ };
const successData: OnyxUpdate[] = [
{
@@ -3196,6 +3301,15 @@ function getSendMoneyParams(
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`,
+ value: {
+ [optimisticCreatedActionForTransactionThread.reportActionID]: {
+ pendingAction: null,
+ },
+ },
+ },
];
const failureData: OnyxUpdate[] = [
@@ -3206,6 +3320,24 @@ function getSendMoneyParams(
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'),
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThread.reportID}`,
+ value: {
+ errorFields: {
+ createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'),
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`,
+ value: {
+ [optimisticCreatedActionForTransactionThread.reportActionID]: {
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ },
+ },
+ },
];
let optimisticPersonalDetailListData: OnyxUpdate | EmptyObject = {};
@@ -3270,7 +3402,16 @@ function getSendMoneyParams(
});
}
- const optimisticData: OnyxUpdate[] = [optimisticChatReportData, optimisticIOUReportData, optimisticChatReportActionsData, optimisticIOUReportActionsData, optimisticTransactionData];
+ const optimisticData: OnyxUpdate[] = [
+ optimisticChatReportData,
+ optimisticIOUReportData,
+ optimisticChatReportActionsData,
+ optimisticIOUReportActionsData,
+ optimisticTransactionData,
+ optimisticTransactionThreadData,
+ optimisticTransactionThreadReportActionsData,
+ ];
+
if (!isEmptyObject(optimisticPersonalDetailListData)) {
optimisticData.push(optimisticPersonalDetailListData);
}
@@ -3285,6 +3426,8 @@ function getSendMoneyParams(
newIOUReportDetails,
createdReportActionID: isNewChat ? optimisticCreatedAction.reportActionID : '0',
reportPreviewReportActionID: reportPreviewAction.reportActionID,
+ transactionThreadReportID: optimisticTransactionThread.reportID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
},
optimisticData,
successData,
@@ -3293,9 +3436,10 @@ function getSendMoneyParams(
}
function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, recipient: Participant, paymentMethodType: PaymentMethodType): PayMoneyRequestData {
+ const total = iouReport.total ?? 0;
const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction(
CONST.IOU.REPORT_ACTION_TYPE.PAY,
- -(iouReport.total ?? 0),
+ ReportUtils.isExpenseReport(iouReport) ? -total : total,
iouReport.currency ?? '',
'',
[recipient],
@@ -3869,14 +4013,6 @@ function setMoneyRequestCurrency(currency: string) {
Onyx.merge(ONYXKEYS.IOU, {currency});
}
-function setMoneyRequestCategory(category: string) {
- Onyx.merge(ONYXKEYS.IOU, {category});
-}
-
-function resetMoneyRequestCategory() {
- Onyx.merge(ONYXKEYS.IOU, {category: ''});
-}
-
function setMoneyRequestTaxRate(transactionID: string, taxRate: TaxRate) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxRate});
}
@@ -3934,7 +4070,6 @@ function navigateToNextPage(iou: OnyxEntry, iouType: string, repo
? [{reportID: chatReport?.reportID, isPolicyExpenseChat: true, selected: true}]
: (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true}));
setMoneyRequestParticipants(participants);
- resetMoneyRequestCategory();
}
Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID));
return;
@@ -4095,14 +4230,11 @@ export {
payMoneyRequest,
sendMoneyWithWallet,
startMoneyRequest,
+ initMoneyRequest,
startMoneyRequest_temporaryForRefactor,
- resetMoneyRequestCategory,
- resetMoneyRequestCategory_temporaryForRefactor,
resetMoneyRequestInfo,
- clearMoneyRequest,
setMoneyRequestAmount_temporaryForRefactor,
setMoneyRequestBillable_temporaryForRefactor,
- setMoneyRequestCategory_temporaryForRefactor,
setMoneyRequestCreated,
setMoneyRequestCurrency_temporaryForRefactor,
setMoneyRequestDescription,
diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts
index ea675ff6b8f6..13e0a42e839f 100644
--- a/src/libs/actions/PaymentMethods.ts
+++ b/src/libs/actions/PaymentMethods.ts
@@ -12,6 +12,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Route} from '@src/ROUTES';
+import INPUT_IDS from '@src/types/form/AddDebitCardForm';
import type {BankAccountList, FundList} from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type PaymentMethod from '@src/types/onyx/PaymentMethod';
@@ -205,7 +206,15 @@ function clearDebitCardFormErrorAndSubmit() {
Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, {
isLoading: false,
errors: undefined,
- setupComplete: false,
+ [INPUT_IDS.SETUP_COMPLETE]: false,
+ [INPUT_IDS.NAME_ON_CARD]: '',
+ [INPUT_IDS.CARD_NUMBER]: '',
+ [INPUT_IDS.EXPIRATION_DATE]: '',
+ [INPUT_IDS.SECURITY_CODE]: '',
+ [INPUT_IDS.ADDRESS_STREET]: '',
+ [INPUT_IDS.ADDRESS_ZIP_CODE]: '',
+ [INPUT_IDS.ADDRESS_STATE]: '',
+ [INPUT_IDS.ACCEPT_TERMS]: '',
});
}
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index bc695911b910..57cd4a6fc071 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -6,6 +6,7 @@ import lodashClone from 'lodash/clone';
import lodashUnion from 'lodash/union';
import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import * as API from '@libs/API';
import type {
AddMembersToWorkspaceParams,
@@ -19,6 +20,8 @@ import type {
OpenWorkspaceMembersPageParams,
OpenWorkspaceParams,
OpenWorkspaceReimburseViewParams,
+ SetWorkspaceApprovalModeParams,
+ SetWorkspaceAutoReportingParams,
UpdateWorkspaceAvatarParams,
UpdateWorkspaceCustomUnitAndRateParams,
UpdateWorkspaceDescriptionParams,
@@ -381,6 +384,87 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]
return announceRoomMembers;
}
+function setWorkspaceAutoReporting(policyID: string, enabled: boolean) {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ autoReporting: enabled,
+ pendingFields: {isAutoApprovalEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ autoReporting: !enabled,
+ pendingFields: {isAutoApprovalEnabled: null},
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ pendingFields: {isAutoApprovalEnabled: null},
+ },
+ },
+ ];
+
+ const params: SetWorkspaceAutoReportingParams = {policyID, enabled};
+ API.write(WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING, params, {optimisticData, failureData, successData});
+}
+
+function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMode: ValueOf) {
+ const isAutoApprovalEnabled = approvalMode === CONST.POLICY.APPROVAL_MODE.BASIC;
+
+ const value = {
+ approver,
+ approvalMode,
+ isAutoApprovalEnabled,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ ...value,
+ pendingFields: {approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ pendingFields: {approvalMode: null},
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ pendingFields: {approvalMode: null},
+ },
+ },
+ ];
+
+ const params: SetWorkspaceApprovalModeParams = {policyID, value: JSON.stringify(value)};
+ API.write(WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE, params, {optimisticData, failureData, successData});
+}
+
/**
* Build optimistic data for removing users from the announcement room
*/
@@ -1635,8 +1719,8 @@ function buildOptimisticPolicyRecentlyUsedCategories(policyID?: string, category
return lodashUnion([category], policyRecentlyUsedCategories);
}
-function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, reportTags?: string): RecentlyUsedTags {
- if (!policyID || !reportTags) {
+function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, transactionTags?: string): RecentlyUsedTags {
+ if (!policyID || !transactionTags) {
return {};
}
@@ -1645,7 +1729,7 @@ function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, reportTags?: s
const policyRecentlyUsedTags = allRecentlyUsedTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`] ?? {};
const newOptimisticPolicyRecentlyUsedTags: RecentlyUsedTags = {};
- reportTags.split(CONST.COLON).forEach((tag, index) => {
+ TransactionUtils.getTagArrayFromName(transactionTags).forEach((tag, index) => {
if (!tag) {
return;
}
@@ -2134,5 +2218,7 @@ export {
buildOptimisticPolicyRecentlyUsedTags,
createDraftInitialWorkspace,
setWorkspaceInviteMessageDraft,
+ setWorkspaceAutoReporting,
+ setWorkspaceApprovalMode,
updateWorkspaceDescription,
};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index e236002ee704..f29f8a4fbaab 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -40,6 +40,7 @@ import type {
UpdateReportWriteCapabilityParams,
UpdateRoomDescriptionParams,
} from '@libs/API/parameters';
+import type UpdateRoomVisibilityParams from '@libs/API/parameters/UpdateRoomVisibilityParams';
import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import * as CollectionUtils from '@libs/CollectionUtils';
import DateUtils from '@libs/DateUtils';
@@ -66,9 +67,10 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
+import INPUT_IDS from '@src/types/form/NewRoomForm';
import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx';
import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage';
-import type {NotificationPreference, WriteCapability} from '@src/types/onyx/Report';
+import type {NotificationPreference, RoomVisibility, WriteCapability} from '@src/types/onyx/Report';
import type Report from '@src/types/onyx/Report';
import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction';
import type ReportAction from '@src/types/onyx/ReportAction';
@@ -85,12 +87,14 @@ type ActionSubscriber = {
callback: SubscriberCallback;
};
+let conciergeChatReportID: string | undefined;
let currentUserAccountID = -1;
Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (value) => {
// When signed out, val is undefined
if (!value?.accountID) {
+ conciergeChatReportID = undefined;
return;
}
@@ -167,7 +171,6 @@ Onyx.connect({
});
const allReports: OnyxCollection = {};
-let conciergeChatReportID: string | undefined;
const typingWatchTimers: Record = {};
let reportIDDeeplinkedFromOldDot: string | undefined;
@@ -1442,6 +1445,38 @@ function updateNotificationPreference(
}
}
+function updateRoomVisibility(reportID: string, previousValue: RoomVisibility | undefined, newValue: RoomVisibility, navigate: boolean, report: OnyxEntry | EmptyObject = {}) {
+ if (previousValue === newValue) {
+ if (navigate && !isEmptyObject(report) && report.reportID) {
+ ReportUtils.goBackToDetailsPage(report);
+ }
+ return;
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {visibility: newValue},
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {visibility: previousValue},
+ },
+ ];
+
+ const parameters: UpdateRoomVisibilityParams = {reportID, visibility: newValue};
+
+ API.write(WRITE_COMMANDS.UPDATE_ROOM_VISIBILITY, parameters, {optimisticData, failureData});
+ if (navigate && !isEmptyObject(report)) {
+ ReportUtils.goBackToDetailsPage(report);
+ }
+}
+
/**
* This will subscribe to an existing thread, or create a new one and then subsribe to it if necessary
*
@@ -1683,24 +1718,29 @@ function updateWriteCapabilityAndNavigate(report: Report, newValue: WriteCapabil
/**
* Navigates to the 1:1 report with Concierge
- *
- * @param ignoreConciergeReportID - Flag to ignore conciergeChatReportID during navigation. The default behavior is to not ignore.
*/
-function navigateToConciergeChat(ignoreConciergeReportID = false, shouldDismissModal = false) {
+function navigateToConciergeChat(shouldDismissModal = false, shouldPopCurrentScreen = false, checkIfCurrentPageActive = () => true) {
// If conciergeChatReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID.
// Otherwise, we would find the concierge chat and navigate to it.
- // Now, when user performs sign-out and a sign-in again, conciergeChatReportID may contain a stale value.
- // In order to prevent navigation to a stale value, we use ignoreConciergeReportID to forcefully find and navigate to concierge chat.
- if (!conciergeChatReportID || ignoreConciergeReportID) {
+ if (!conciergeChatReportID) {
// In order to avoid creating concierge repeatedly,
// we need to ensure that the server data has been successfully pulled
Welcome.serverDataIsReadyPromise().then(() => {
// If we don't have a chat with Concierge then create it
+ if (!checkIfCurrentPageActive()) {
+ return;
+ }
+ if (shouldPopCurrentScreen && !shouldDismissModal) {
+ Navigation.goBack();
+ }
navigateToAndOpenReport([CONST.EMAIL.CONCIERGE], shouldDismissModal);
});
} else if (shouldDismissModal) {
Navigation.dismissModal(conciergeChatReportID);
} else {
+ if (shouldPopCurrentScreen) {
+ Navigation.goBack();
+ }
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeChatReportID));
}
}
@@ -2180,10 +2220,7 @@ function openReportFromDeepLink(url: string, isAuthenticated: boolean) {
Session.waitForUserSignIn().then(() => {
Navigation.waitForProtectedRoutes().then(() => {
const route = ReportUtils.getRouteFromLink(url);
- if (route === ROUTES.CONCIERGE) {
- navigateToConciergeChat(true);
- return;
- }
+
if (route && Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) {
Session.signOutAndRedirectToSignIn(true);
return;
@@ -2805,6 +2842,11 @@ function clearNewRoomFormError() {
isLoading: false,
errorFields: null,
errors: null,
+ [INPUT_IDS.ROOM_NAME]: '',
+ [INPUT_IDS.REPORT_DESCRIPTION]: '',
+ [INPUT_IDS.POLICY_ID]: '',
+ [INPUT_IDS.WRITE_CAPABILITY]: '',
+ [INPUT_IDS.VISIBILITY]: '',
});
}
@@ -2926,4 +2968,5 @@ export {
updateReportField,
updateReportName,
resolveActionableMentionWhisper,
+ updateRoomVisibility,
};
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index 7416b4f07e5e..013d86049150 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -856,7 +856,7 @@ function handleExitToNavigation(exitTo: Routes | HybridAppRoute) {
waitForUserSignIn().then(() => {
Navigation.waitForProtectedRoutes().then(() => {
const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo;
- Navigation.navigate(url, CONST.NAVIGATION.TYPE.FORCED_UP);
+ Navigation.navigate(url);
});
});
});
@@ -890,7 +890,7 @@ const canAccessRouteByAnonymousUser = (route: string) => {
if (route.startsWith('/')) {
routeRemovedReportId = routeRemovedReportId.slice(1);
}
- const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route];
+ const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route, ROUTES.CONCIERGE];
if ((routesCanAccessByAnonymousUser as string[]).includes(routeRemovedReportId)) {
return true;
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index 1d9af01f2fa0..5b178104d7c7 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -1,6 +1,7 @@
import {isEqual} from 'lodash';
import lodashClone from 'lodash/clone';
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';
@@ -106,7 +107,7 @@ function saveWaypoint(transactionID: string, index: string, waypoint: RecentWayp
}
}
-function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: boolean) {
+function removeWaypoint(transaction: OnyxEntry, currentIndex: string, isDraft?: boolean) {
// Index comes from the route params and is a string
const index = Number(currentIndex);
const existingWaypoints = transaction?.comment?.waypoints ?? {};
@@ -134,9 +135,10 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft:
// to remove nested keys while also preserving other object keys
// Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set
let newTransaction: Transaction = {
- ...transaction,
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ ...(transaction as Transaction),
comment: {
- ...transaction.comment,
+ ...transaction?.comment,
waypoints: reIndexedWaypoints,
},
// We want to reset the amount only for draft transactions (when creating the request).
@@ -164,10 +166,10 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft:
};
}
if (isDraft) {
- Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction);
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`, newTransaction);
return;
}
- Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, newTransaction);
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, newTransaction);
}
function getOnyxDataForRouteRequest(transactionID: string, isDraft = false): OnyxData {
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index 54efe4ba4d8e..d7cef2aca546 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -29,6 +29,7 @@ import * as Pusher from '@libs/Pusher/pusher';
import PusherUtils from '@libs/PusherUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import playSound, {SOUNDS} from '@libs/Sound';
+import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -526,13 +527,13 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) {
}
// mention user
- if ('html' in message && typeof message.html === 'string' && message.html.includes('')) {
- return playSound(SOUNDS.ATTENTION);
+ 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 playSound(SOUNDS.ATTENTION);
+ return playSoundExcludingMobile(SOUNDS.ATTENTION);
}
// assign a task
@@ -552,7 +553,7 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) {
// plain message
if ('html' in message) {
- return playSound(SOUNDS.RECEIVE);
+ return playSoundExcludingMobile(SOUNDS.RECEIVE);
}
}
} catch (e) {
@@ -779,6 +780,13 @@ function generateStatementPDF(period: string) {
function setContactMethodAsDefault(newDefaultContactMethod: string) {
const oldDefaultContactMethod = currentEmail;
const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ primaryLogin: newDefaultContactMethod,
+ },
+ },
{
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.SESSION,
@@ -825,6 +833,13 @@ function setContactMethodAsDefault(newDefaultContactMethod: string) {
},
];
const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ primaryLogin: oldDefaultContactMethod,
+ },
+ },
{
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.SESSION,
diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts
index df8a589f7bdc..fd03adcffd93 100644
--- a/src/libs/isReportMessageAttachment.ts
+++ b/src/libs/isReportMessageAttachment.ts
@@ -1,3 +1,4 @@
+import Str from 'expensify-common/lib/str';
import CONST from '@src/CONST';
import type {Message} from '@src/types/onyx/ReportAction';
@@ -17,5 +18,5 @@ export default function isReportMessageAttachment({text, html, translationKey}:
}
const regex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)"`, 'i');
- return text === CONST.ATTACHMENT_MESSAGE_TEXT && (!!html.match(regex) || html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML);
+ return (text === CONST.ATTACHMENT_MESSAGE_TEXT || !!Str.isVideo(text)) && (!!html.match(regex) || html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML);
}
diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts
index cd50938c70b9..68c750b05a5f 100644
--- a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts
+++ b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts
@@ -1,5 +1,3 @@
import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder';
-// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event
-// Because of that - on iOS we can use `keyboardWillHide` that is not available on android
-export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardWillHide');
+export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardDidHide');
diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts
index 8d5ef578b66c..72df7a730e02 100644
--- a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts
+++ b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts
@@ -5,8 +5,6 @@ import * as Composer from '@userActions/Composer';
import type SetShouldShowComposeInputKeyboardAware from './types';
let keyboardEventListener: EmitterSubscription | null = null;
-// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event
-// Because of that - on iOS we can use `keyboardWillHide` that is not available on android
const setShouldShowComposeInputKeyboardAwareBuilder: (keyboardEvent: KeyboardEventName) => SetShouldShowComposeInputKeyboardAware =
(keyboardEvent: KeyboardEventName) => (shouldShow: boolean) => {
diff --git a/src/libs/shouldAllowDownloadQRCode/index.native.ts b/src/libs/shouldAllowDownloadQRCode/index.native.ts
new file mode 100644
index 000000000000..ea9b2b9c8aa1
--- /dev/null
+++ b/src/libs/shouldAllowDownloadQRCode/index.native.ts
@@ -0,0 +1,5 @@
+import type ShouldAllowDownloadQRCode from './types';
+
+const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = true;
+
+export default shouldAllowDownloadQRCode;
diff --git a/src/libs/shouldAllowDownloadQRCode/index.ts b/src/libs/shouldAllowDownloadQRCode/index.ts
new file mode 100644
index 000000000000..8331f7d4821f
--- /dev/null
+++ b/src/libs/shouldAllowDownloadQRCode/index.ts
@@ -0,0 +1,5 @@
+import type ShouldAllowDownloadQRCode from './types';
+
+const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = false;
+
+export default shouldAllowDownloadQRCode;
diff --git a/src/libs/shouldAllowDownloadQRCode/types.ts b/src/libs/shouldAllowDownloadQRCode/types.ts
new file mode 100644
index 000000000000..3bd6c5dc4dd7
--- /dev/null
+++ b/src/libs/shouldAllowDownloadQRCode/types.ts
@@ -0,0 +1,3 @@
+type ShouldAllowDownloadQRCode = boolean;
+
+export default ShouldAllowDownloadQRCode;
diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx
index 251728866a54..4abf8f0d2033 100644
--- a/src/pages/ConciergePage.tsx
+++ b/src/pages/ConciergePage.tsx
@@ -1,11 +1,16 @@
import {useFocusEffect} from '@react-navigation/native';
import type {StackScreenProps} from '@react-navigation/stack';
-import React from 'react';
+import React, {useEffect, useRef} from 'react';
+import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';
+import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {AuthScreensParamList} from '@libs/Navigation/types';
+import * as App from '@userActions/App';
import * as Report from '@userActions/Report';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
@@ -24,19 +29,39 @@ type ConciergePageProps = ConciergePageOnyxProps & StackScreenProps {
if (session && 'authToken' in session) {
+ App.confirmReadyToOpenApp();
// Pop the concierge loading page before opening the concierge report.
Navigation.isNavigationReady().then(() => {
- Navigation.goBack();
- Report.navigateToConciergeChat();
+ if (isUnmounted.current) {
+ return;
+ }
+ Report.navigateToConciergeChat(undefined, true, () => !isUnmounted.current);
});
} else {
Navigation.navigate();
}
});
- return ;
+ useEffect(
+ () => () => {
+ isUnmounted.current = true;
+ },
+ [],
+ );
+
+ return (
+
+
+
+
+
+
+ );
}
ConciergePage.displayName = 'ConciergePage';
diff --git a/src/pages/DetailsPage.tsx b/src/pages/DetailsPage.tsx
index d4438d3141bf..a9adb5310e58 100755
--- a/src/pages/DetailsPage.tsx
+++ b/src/pages/DetailsPage.tsx
@@ -64,22 +64,13 @@ function DetailsPage({personalDetails, route, session}: DetailsPageProps) {
let details = Object.values(personalDetails ?? {}).find((personalDetail) => personalDetail?.login === login.toLowerCase());
if (!details) {
- if (login === CONST.EMAIL.CONCIERGE) {
- details = {
- accountID: CONST.ACCOUNT_ID.CONCIERGE,
- login,
- displayName: 'Concierge',
- avatar: UserUtils.getDefaultAvatar(CONST.ACCOUNT_ID.CONCIERGE),
- };
- } else {
- const optimisticAccountID = UserUtils.generateAccountID(login);
- details = {
- accountID: optimisticAccountID,
- login,
- displayName: login,
- avatar: UserUtils.getDefaultAvatar(optimisticAccountID),
- };
- }
+ const optimisticAccountID = UserUtils.generateAccountID(login);
+ details = {
+ accountID: optimisticAccountID,
+ login,
+ displayName: login,
+ avatar: UserUtils.getDefaultAvatar(optimisticAccountID),
+ };
}
const isSMSLogin = details.login ? Str.isSMSLogin(details.login) : false;
diff --git a/src/pages/EditRequestCategoryPage.js b/src/pages/EditRequestCategoryPage.js
deleted file mode 100644
index 205b4bf66dfa..000000000000
--- a/src/pages/EditRequestCategoryPage.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import CategoryPicker from '@components/CategoryPicker';
-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 Navigation from '@libs/Navigation/Navigation';
-
-const propTypes = {
- /** Transaction default category value */
- defaultCategory: PropTypes.string.isRequired,
-
- /** The policyID we are getting categories for */
- policyID: PropTypes.string.isRequired,
-
- /** Callback to fire when the Save button is pressed */
- onSubmit: PropTypes.func.isRequired,
-};
-
-function EditRequestCategoryPage({defaultCategory, policyID, onSubmit}) {
- const styles = useThemeStyles();
- const {translate} = useLocalize();
-
- const selectCategory = (category) => {
- onSubmit({
- category: category.searchText,
- });
- };
-
- return (
-
-
- {translate('iou.categorySelection')}
-
-
- );
-}
-
-EditRequestCategoryPage.propTypes = propTypes;
-EditRequestCategoryPage.displayName = 'EditRequestCategoryPage';
-
-export default EditRequestCategoryPage;
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index 29917154a527..de17d16a7c38 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -1,5 +1,4 @@
import lodashGet from 'lodash/get';
-import lodashValues from 'lodash/values';
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo} from 'react';
import {withOnyx} from 'react-native-onyx';
@@ -21,7 +20,6 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import EditRequestAmountPage from './EditRequestAmountPage';
-import EditRequestCategoryPage from './EditRequestCategoryPage';
import EditRequestDistancePage from './EditRequestDistancePage';
import EditRequestReceiptPage from './EditRequestReceiptPage';
import EditRequestTagPage from './EditRequestTagPage';
@@ -77,7 +75,7 @@ const defaultProps = {
function EditRequestPage({report, route, policy, policyCategories, policyTags, parentReportActions, transaction}) {
const parentReportActionID = lodashGet(report, 'parentReportActionID', '0');
const parentReportAction = lodashGet(parentReportActions, parentReportActionID, {});
- const {amount: transactionAmount, currency: transactionCurrency, category: transactionCategory, tag: transactionTag} = ReportUtils.getTransactionDetails(transaction);
+ const {amount: transactionAmount, currency: transactionCurrency, tag: transactionTag} = ReportUtils.getTransactionDetails(transaction);
const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency;
const fieldToEdit = lodashGet(route, ['params', 'field'], '');
@@ -90,9 +88,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p
// A flag for verifying that the current report is a sub-report of a workspace chat
const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report);
- // A flag for showing the categories page
- const shouldShowCategories = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories)));
-
// A flag for showing the tags page
const shouldShowTags = useMemo(() => isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists)), [isPolicyExpenseChat, policyTagLists, transactionTag]);
@@ -135,7 +130,7 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p
IOU.updateMoneyRequestTag(
transaction.transactionID,
report.reportID,
- IOUUtils.insertTagIntoReportTagsString(transactionTag, updatedTag, tagIndex),
+ IOUUtils.insertTagIntoTransactionTagsString(transactionTag, updatedTag, tagIndex),
policy,
policyTags,
policyCategories,
@@ -145,16 +140,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p
[tag, transaction.transactionID, report.reportID, transactionTag, tagIndex, policy, policyTags, policyCategories],
);
- const saveCategory = useCallback(
- ({category: newCategory}) => {
- // In case the same category has been selected, reset the category.
- const updatedCategory = newCategory === transactionCategory ? '' : newCategory;
- IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, policy, policyTags, policyCategories);
- Navigation.dismissModal();
- },
- [transactionCategory, transaction.transactionID, report.reportID, policy, policyTags, policyCategories],
- );
-
if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) {
return (
- );
- }
-
if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG && shouldShowTags) {
return (
{
- setDraftSplitTransaction({category: transactionChanges.category.trim()});
- }}
- />
- );
- }
-
if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG) {
return (
Navigation.dismissModal()}
+ onBackButtonPress={navigation.goBack}
/>
(
+ tabBar={({state, navigation: tabNavigation, position}) => (
)}
diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.tsx b/src/pages/PrivateNotes/PrivateNotesListPage.tsx
index 97ebc7dee2fb..0a6a2659ffb6 100644
--- a/src/pages/PrivateNotes/PrivateNotesListPage.tsx
+++ b/src/pages/PrivateNotes/PrivateNotesListPage.tsx
@@ -88,7 +88,12 @@ function PrivateNotesListPage({report, personalDetailsList, session}: PrivateNot
onCloseButtonPress={() => Navigation.dismissModal()}
/>
{translate('privateNotes.personalNoteMessage')}
- {privateNotes.map((item) => getMenuItem(item))}
+
+ {privateNotes.map((item) => getMenuItem(item))}
+
);
}
diff --git a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
index bb352acd4732..ed00fbcff422 100644
--- a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
+++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
@@ -136,7 +136,6 @@ function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkTok
testID={BankInfo.displayName}
includeSafeAreaPaddingBottom={false}
shouldEnablePickerAvoiding={false}
- shouldEnableMaxHeight
>
)}
{!requiresTwoFactorAuth && (
diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx
index 9eae888bdd74..b4272f094071 100644
--- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx
+++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx
@@ -8,6 +8,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import type {SubStepProps} from '@hooks/useSubStep/types';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
@@ -34,6 +35,7 @@ const PERSONAL_INFO_STEP_INDEXES = CONST.REIMBURSEMENT_ACCOUNT_SUBSTEP_INDEX.PER
function Confirmation({reimbursementAccount, reimbursementAccountDraft, onNext, onMove}: ConfirmationProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const {isOffline} = useNetwork();
const isLoading = reimbursementAccount?.isLoading ?? false;
const values = useMemo(() => getSubstepValues(PERSONAL_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]);
@@ -112,6 +114,7 @@ function Confirmation({reimbursementAccount, reimbursementAccountDraft, onNext,
/>
)}
diff --git a/src/pages/SearchPage/index.js b/src/pages/SearchPage/index.js
index 7c472296dfe1..0c17e58837c1 100644
--- a/src/pages/SearchPage/index.js
+++ b/src/pages/SearchPage/index.js
@@ -6,6 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {usePersonalDetails} from '@components/OnyxProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -143,6 +144,7 @@ function SearchPage({betas, reports, isSearchingForReports}) {
includeSafeAreaPaddingBottom={false}
testID={SearchPage.displayName}
onEntryTransitionEnd={handleScreenTransitionEnd}
+ shouldEnableMaxHeight
>
{({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => (
<>
@@ -153,6 +155,7 @@ function SearchPage({betas, reports, isSearchingForReports}) {
(null);
- const {isSmallScreenWidth} = useWindowDimensions();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const isReport = !!report?.reportID;
@@ -71,72 +67,52 @@ function ShareCodePage({report}: ShareCodePageProps) {
const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID;
return (
-
+
Navigation.goBack(isReport ? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID) : undefined)}
- shouldShowBackButton={isReport || isSmallScreenWidth}
- icon={Illustrations.QRCode}
+ shouldShowBackButton
/>
-
-
-
-
-
+
+
+
-
- Clipboard.setString(url)}
- shouldLimitWidth={false}
- wrapperStyle={themeStyles.sectionMenuItemTopDescription}
- />
+
+ Clipboard.setString(url)}
+ shouldLimitWidth={false}
+ />
- {isNative && (
-
-
+ Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE, Navigation.getActiveRouteWithoutParams()))}
+ shouldShowRightIcon
+ />
diff --git a/src/pages/ValidateLoginPage/index.tsx b/src/pages/ValidateLoginPage/index.tsx
index edc8b61c82b0..2289547afe56 100644
--- a/src/pages/ValidateLoginPage/index.tsx
+++ b/src/pages/ValidateLoginPage/index.tsx
@@ -19,6 +19,10 @@ function ValidateLoginPage({
if (session?.authToken) {
// If already signed in, do not show the validate code if not on web,
// because we don't want to block the user with the interstitial page.
+ if (exitTo) {
+ Session.handleExitToNavigation(exitTo);
+ return;
+ }
Navigation.goBack();
} else {
Session.signInWithValidateCodeAndNavigate(Number(accountID), validateCode, '', exitTo);
diff --git a/src/pages/ValidateLoginPage/index.website.tsx b/src/pages/ValidateLoginPage/index.website.tsx
index 7ce46ee11b14..866b061d964f 100644
--- a/src/pages/ValidateLoginPage/index.website.tsx
+++ b/src/pages/ValidateLoginPage/index.website.tsx
@@ -34,7 +34,7 @@ function ValidateLoginPage({
}
Session.initAutoAuthState(autoAuthState);
- if (isSignedIn || !login) {
+ if (isSignedIn || (!login && !exitTo)) {
if (exitTo) {
Session.handleExitToNavigation(exitTo);
}
@@ -64,8 +64,8 @@ function ValidateLoginPage({
<>
{autoAuthState === CONST.AUTO_AUTH_STATE.FAILED && }
{autoAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && is2FARequired && !isSignedIn && }
- {autoAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && isSignedIn && }
- {autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED && (
+ {autoAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && isSignedIn && !exitTo && }
+ {autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED && !exitTo && (
{
+ Navigation.goBack();
App.createWorkspaceWithPolicyDraftAndNavigateToIt();
}}
>
diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js
index e024f1c3f7eb..faa70bb0633a 100644
--- a/src/pages/home/HeaderView.js
+++ b/src/pages/home/HeaderView.js
@@ -329,7 +329,7 @@ function HeaderView(props) {
{
if (ReportUtils.canEditPolicyDescription(props.policy)) {
- Navigation.navigate(ROUTES.WORKSPACE_DESCRIPTION.getRoute(props.report.policyID));
+ Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(props.report.policyID));
return;
}
Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.reportID));
diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx
index 213d94f51f81..52b62c2d15b3 100755
--- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx
+++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx
@@ -208,6 +208,7 @@ function BaseReportActionContextMenu({
undefined,
undefined,
filteredContextMenuActions,
+ true,
);
};
diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
index 9c8c6a8b37e7..0b4154a15e80 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
@@ -65,7 +65,7 @@ function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef([]);
const contentRef = useRef(null);
- const anchorRef = useRef(null);
+ const anchorRef = useRef(null);
const dimensionsEventListener = useRef(null);
const contextMenuAnchorRef = useRef(null);
const contextMenuTargetNode = useRef(null);
@@ -163,11 +163,16 @@ function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef {
const {pageX = 0, pageY = 0} = extractPointerEvent(event);
contextMenuAnchorRef.current = contextMenuAnchor;
contextMenuTargetNode.current = event.target as HTMLElement;
-
+ if (shouldCloseOnTarget) {
+ anchorRef.current = event.target as HTMLDivElement;
+ } else {
+ anchorRef.current = null;
+ }
setInstanceID(Math.random().toString(36).substr(2, 5));
onPopoverShow.current = onShow;
diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts
index d8570bd14510..6664a38d2e19 100644
--- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts
+++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts
@@ -34,6 +34,7 @@ type ShowContextMenu = (
isPinnedChat?: boolean,
isUnreadChat?: boolean,
disabledOptions?: ContextMenuAction[],
+ shouldCloseOnTarget?: boolean,
) => void;
type ReportActionContextMenu = {
@@ -113,6 +114,7 @@ function showContextMenu(
isPinnedChat = false,
isUnreadChat = false,
disabledActions: ContextMenuAction[] = [],
+ shouldCloseOnTarget = false,
) {
if (!contextMenuRef.current) {
return;
@@ -140,6 +142,7 @@ function showContextMenu(
isPinnedChat,
isUnreadChat,
disabledActions,
+ shouldCloseOnTarget,
);
}
diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
index 62abfcf8545a..72727168cad6 100644
--- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
+++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
@@ -18,14 +18,12 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
import compose from '@libs/compose';
-import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import * as IOU from '@userActions/IOU';
import * as Report from '@userActions/Report';
import * as Task from '@userActions/Task';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
const propTypes = {
/** The report currently being looked at */
@@ -145,12 +143,12 @@ function AttachmentPickerWithMenuItems({
[CONST.IOU.TYPE.SPLIT]: {
icon: Expensicons.Receipt,
text: translate('iou.splitBill'),
- onSelected: () => Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.SPLIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, report.reportID)),
+ onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report.reportID),
},
[CONST.IOU.TYPE.REQUEST]: {
icon: Expensicons.MoneyCircle,
text: translate('iou.requestMoney'),
- onSelected: () => Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.REQUEST, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, report.reportID)),
+ onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report.reportID),
},
[CONST.IOU.TYPE.SEND]: {
icon: Expensicons.Send,
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
index 8ec0bce9d1a7..4bbf3d393213 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
@@ -446,7 +446,12 @@ function ReportActionCompose({
onBlur={onBlur}
measureParentContainer={measureContainer}
listHeight={listHeight}
- onValueChange={validateCommentMaxLength}
+ onValueChange={(value) => {
+ if (value.length === 0 && isComposerFullSize) {
+ Report.setIsComposerFullSize(reportID, false);
+ }
+ validateCommentMaxLength(value);
+ }}
/>
{
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index 66394190fde6..7b8de0566735 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -464,8 +464,6 @@ function ReportActionItem(props) {
children = ;
} else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) {
children = ;
- } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED) {
- children = ;
} else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) {
children = ;
} else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) {
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx
index 427c6ccdbfc4..2c9a4cbd21e8 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.tsx
+++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx
@@ -31,6 +31,7 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import setShouldShowComposeInputKeyboardAware from '@libs/setShouldShowComposeInputKeyboardAware';
+import * as ComposerActions from '@userActions/Composer';
import * as EmojiPickerAction from '@userActions/EmojiPickerAction';
import * as InputFocus from '@userActions/InputFocus';
import * as Report from '@userActions/Report';
@@ -211,6 +212,9 @@ function ReportActionItemMessageEdit(
// eslint-disable-next-line react-hooks/exhaustive-deps -- this cleanup needs to be called only on unmount
}, [action.reportActionID]);
+ // show the composer after editing is complete for devices that hide the composer during editing.
+ useEffect(() => () => ComposerActions.setShouldShowComposeInput(true), []);
+
/**
* Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft
* allows one to navigate somewhere else and come back to the comment and still have it in edit mode.
diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js
index 828077097580..c4cc0713c596 100644
--- a/src/pages/home/sidebar/SidebarLinksData.js
+++ b/src/pages/home/sidebar/SidebarLinksData.js
@@ -9,7 +9,7 @@ import _ from 'underscore';
import networkPropTypes from '@components/networkPropTypes';
import {withNetwork} from '@components/OnyxProvider';
import withCurrentReportID from '@components/withCurrentReportID';
-import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
+import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
import withNavigationFocus from '@components/withNavigationFocus';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useLocalize from '@hooks/useLocalize';
@@ -68,11 +68,6 @@ const propTypes = {
// eslint-disable-next-line react/forbid-prop-types
policyMembers: PropTypes.object,
- /** Session info for the currently logged in user. */
- session: PropTypes.shape({
- /** Currently logged in user accountID */
- accountID: PropTypes.number,
- }),
/** All of the transaction violations */
transactionViolations: PropTypes.shape({
violations: PropTypes.arrayOf(
@@ -97,6 +92,8 @@ const propTypes = {
}),
),
}),
+
+ ...withCurrentUserPersonalDetailsPropTypes,
};
const defaultProps = {
@@ -106,11 +103,9 @@ const defaultProps = {
betas: [],
policies: {},
policyMembers: {},
- session: {
- accountID: '',
- },
transactionViolations: {},
allReportActions: {},
+ ...withCurrentUserPersonalDetailsDefaultProps,
};
function SidebarLinksData({
@@ -126,15 +121,15 @@ function SidebarLinksData({
priorityMode,
network,
policyMembers,
- session: {accountID},
transactionViolations,
+ currentUserPersonalDetails,
}) {
const styles = useThemeStyles();
const {activeWorkspaceID} = useActiveWorkspace();
const {translate} = useLocalize();
const prevPriorityMode = usePrevious(priorityMode);
- const policyMemberAccountIDs = getPolicyMembersByIdWithoutCurrentUser(policyMembers, activeWorkspaceID, accountID);
+ const policyMemberAccountIDs = getPolicyMembersByIdWithoutCurrentUser(policyMembers, activeWorkspaceID, currentUserPersonalDetails.accountID);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => Policy.openWorkspace(activeWorkspaceID, policyMemberAccountIDs), [activeWorkspaceID]);
@@ -271,7 +266,7 @@ const chatReportSelector = (report) =>
const reportActionsSelector = (reportActions) =>
reportActions &&
lodashMap(reportActions, (reportAction) => {
- const {reportActionID, parentReportActionID, actionName, errors = []} = reportAction;
+ const {reportActionID, parentReportActionID, actionName, errors = [], originalMessage} = reportAction;
const decision = lodashGet(reportAction, 'message[0].moderationDecision.decision');
return {
@@ -284,6 +279,7 @@ const reportActionsSelector = (reportActions) =>
moderationDecision: {decision},
},
],
+ originalMessage,
};
});
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
index 0df490fa4466..573cbe370aa7 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
@@ -166,10 +166,11 @@ function FloatingActionButtonAndPopover(props) {
text: translate('iou.requestMoney'),
onSelected: () =>
interceptAnonymousUser(() =>
- Navigation.navigate(
+ IOU.startMoneyRequest_temporaryForRefactor(
+ CONST.IOU.TYPE.REQUEST,
// When starting to create a money request from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used
// for all of the routes in the creation flow.
- ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.REQUEST, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()),
+ ReportUtils.generateReportID(),
),
),
},
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index 2a48897bfc85..7495efb43171 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -9,6 +9,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {withNetwork} from '@components/OnyxProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import compose from '@libs/compose';
import * as CurrencyUtils from '@libs/CurrencyUtils';
@@ -165,6 +166,7 @@ function IOUCurrencySelection(props) {
/>
{
- Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID));
- };
-
- const updateCategory = (category) => {
- if (category.searchText === iou.category) {
- IOU.resetMoneyRequestCategory();
- } else {
- IOU.setMoneyRequestCategory(category.searchText);
- }
-
- Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID));
- };
-
- return (
-
-
- {translate('iou.categorySelection')}
-
-
- );
-}
-
-MoneyRequestCategoryPage.displayName = 'MoneyRequestCategoryPage';
-MoneyRequestCategoryPage.propTypes = propTypes;
-MoneyRequestCategoryPage.defaultProps = defaultProps;
-
-export default compose(
- withOnyx({
- iou: {
- key: ONYXKEYS.IOU,
- },
- }),
- // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
- withOnyx({
- report: {
- key: ({route, iou}) => {
- const reportID = IOU.getIOUReportID(iou, route);
-
- return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
- },
- },
- }),
-)(MoneyRequestCategoryPage);
diff --git a/src/pages/iou/MoneyRequestWaypointPage.js b/src/pages/iou/MoneyRequestWaypointPage.tsx
similarity index 52%
rename from src/pages/iou/MoneyRequestWaypointPage.js
rename to src/pages/iou/MoneyRequestWaypointPage.tsx
index 2f8b8b9cc729..c21aae7cf063 100644
--- a/src/pages/iou/MoneyRequestWaypointPage.js
+++ b/src/pages/iou/MoneyRequestWaypointPage.tsx
@@ -1,39 +1,21 @@
-import PropTypes from 'prop-types';
+import type {StackScreenProps} from '@react-navigation/stack';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
+import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types';
import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
import IOURequestStepWaypoint from './request/step/IOURequestStepWaypoint';
-const propTypes = {
- /** The transactionID of this request */
- transactionID: PropTypes.string,
-
- /** Route params */
- route: PropTypes.shape({
- params: PropTypes.shape({
- /** IOU type */
- iouType: PropTypes.string,
-
- /** Index of the waypoint being edited */
- waypointIndex: PropTypes.string,
- }),
- }),
-};
-
-const defaultProps = {
- transactionID: '',
- route: {
- params: {
- iouType: '',
- waypointIndex: '',
- },
- },
+type MoneyRequestWaypointPageOnyxProps = {
+ transactionID: string | undefined;
};
+type MoneyRequestWaypointPageProps = StackScreenProps & MoneyRequestWaypointPageOnyxProps;
// This component is responsible for grabbing the transactionID from the IOU key
// 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 WaypointEditor can subscribe to the transaction.
-function MoneyRequestWaypointPage({transactionID, route}) {
+function MoneyRequestWaypointPage({transactionID = '', route}: MoneyRequestWaypointPageProps) {
return (
+ // @ts-expect-error TODO: Remove this once withFullTransactionOrNotFound(https://github.com/Expensify/App/issues/36123) is migrated to TypeScript.
iou && iou.transactionID},
+
+export default withOnyx({
+ transactionID: {key: ONYXKEYS.IOU, selector: (iou) => iou?.transactionID},
})(MoneyRequestWaypointPage);
diff --git a/src/pages/iou/request/IOURequestStartPage.js b/src/pages/iou/request/IOURequestStartPage.js
index 3d80ab89347d..05e3d7c96311 100644
--- a/src/pages/iou/request/IOURequestStartPage.js
+++ b/src/pages/iou/request/IOURequestStartPage.js
@@ -80,14 +80,6 @@ function IOURequestStartPage({
const previousIOURequestType = usePrevious(transactionRequestType.current);
const isFromGlobalCreate = _.isEmpty(report.reportID);
- // Clear out the temporary money request when this component is unmounted
- useEffect(
- () => () => {
- IOU.clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
- },
- [reportID],
- );
-
useEffect(() => {
const handler = (event) => {
if (event.code !== CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) {
@@ -106,7 +98,7 @@ function IOURequestStartPage({
if (transaction.reportID === reportID) {
return;
}
- IOU.startMoneyRequest_temporaryForRefactor(reportID, isFromGlobalCreate, transactionRequestType.current);
+ IOU.initMoneyRequest(reportID, isFromGlobalCreate, transactionRequestType.current);
}, [transaction, reportID, iouType, isFromGlobalCreate]);
const isExpenseChat = ReportUtils.isPolicyExpenseChat(report);
@@ -125,7 +117,7 @@ function IOURequestStartPage({
if (newIouType === previousIOURequestType) {
return;
}
- IOU.startMoneyRequest_temporaryForRefactor(reportID, isFromGlobalCreate, newIouType);
+ IOU.initMoneyRequest(reportID, isFromGlobalCreate, newIouType);
transactionRequestType.current = newIouType;
},
[previousIOURequestType, reportID, isFromGlobalCreate],
diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js
index d6c088c23e95..238b66c0e727 100644
--- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js
+++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js
@@ -11,6 +11,7 @@ import {PressableWithFeedback} from '@components/Pressable';
import ReferralProgramCTA from '@components/ReferralProgramCTA';
import SelectCircle from '@components/SelectCircle';
import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch';
@@ -337,6 +338,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({
`${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${report ? report.policyID : '0'}`,
- },
policy: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`,
},
diff --git a/src/pages/iou/request/step/IOURequestStepCategory.js b/src/pages/iou/request/step/IOURequestStepCategory.js
index 30f0b9c7a338..3e0feec02854 100644
--- a/src/pages/iou/request/step/IOURequestStepCategory.js
+++ b/src/pages/iou/request/step/IOURequestStepCategory.js
@@ -1,13 +1,24 @@
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
import React from 'react';
+import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
import CategoryPicker from '@components/CategoryPicker';
+import categoryPropTypes from '@components/categoryPropTypes';
+import tagPropTypes from '@components/tagPropTypes';
import Text from '@components/Text';
import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as ReportUtils from '@libs/ReportUtils';
import reportPropTypes from '@pages/reportPropTypes';
import * as IOU from '@userActions/IOU';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import {policyPropTypes} from '@src/pages/workspace/withPolicy';
import IOURequestStepRoutePropTypes from './IOURequestStepRoutePropTypes';
import StepScreenWrapper from './StepScreenWrapper';
import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
@@ -21,24 +32,51 @@ const propTypes = {
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
transaction: transactionPropTypes,
+ /** The draft transaction that holds data to be persisted on the current transaction */
+ splitDraftTransaction: transactionPropTypes,
+
/** The report attached to the transaction */
report: reportPropTypes,
+
+ /** The policy of the report */
+ policy: policyPropTypes.policy,
+
+ /** Collection of categories attached to a policy */
+ policyCategories: PropTypes.objectOf(categoryPropTypes),
+
+ /** Collection of tags attached to a policy */
+ policyTags: tagPropTypes,
};
const defaultProps = {
report: {},
transaction: {},
+ splitDraftTransaction: {},
+ policy: null,
+ policyTags: null,
+ policyCategories: null,
};
function IOURequestStepCategory({
report,
route: {
- params: {transactionID, backTo},
+ params: {transactionID, backTo, action, iouType},
},
transaction,
+ splitDraftTransaction,
+ policy,
+ policyTags,
+ policyCategories,
}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const isEditing = action === CONST.IOU.ACTION.EDIT;
+ const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT;
+ const {category: transactionCategory} = ReportUtils.getTransactionDetails(isEditingSplitBill ? splitDraftTransaction : transaction);
+
+ const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report);
+ // eslint-disable-next-line rulesdir/no-negated-variables
+ const shouldShowNotFoundPage = !isPolicyExpenseChat || (!transactionCategory && !OptionsListUtils.hasEnabledOptions(_.values(policyCategories)));
const navigateBack = () => {
Navigation.goBack(backTo);
@@ -49,11 +87,23 @@ function IOURequestStepCategory({
* @param {String} category.searchText
*/
const updateCategory = (category) => {
- if (category.searchText === transaction.category) {
- IOU.resetMoneyRequestCategory_temporaryForRefactor(transactionID);
- } else {
- IOU.setMoneyRequestCategory_temporaryForRefactor(transactionID, category.searchText);
+ const isSelectedCategory = category.searchText === transactionCategory;
+ const updatedCategory = isSelectedCategory ? '' : category.searchText;
+
+ // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value
+ if (isEditingSplitBill) {
+ IOU.setDraftSplitTransaction(transaction.transactionID, {category: category.searchText});
+ navigateBack();
+ return;
+ }
+
+ if (isEditing) {
+ IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, policy, policyTags, policyCategories);
+ navigateBack();
+ return;
}
+
+ IOU.setMoneyRequestCategory(transactionID, updatedCategory);
navigateBack();
};
@@ -62,11 +112,12 @@ function IOURequestStepCategory({
headerTitle={translate('common.category')}
onBackButtonPress={navigateBack}
shouldShowWrapper
+ shouldShowNotFoundPage={shouldShowNotFoundPage}
testID={IOURequestStepCategory.displayName}
>
{translate('iou.categorySelection')}
@@ -78,4 +129,24 @@ IOURequestStepCategory.displayName = 'IOURequestStepCategory';
IOURequestStepCategory.propTypes = propTypes;
IOURequestStepCategory.defaultProps = defaultProps;
-export default compose(withWritableReportOrNotFound, withFullTransactionOrNotFound)(IOURequestStepCategory);
+export default compose(
+ withWritableReportOrNotFound,
+ withFullTransactionOrNotFound,
+ withOnyx({
+ splitDraftTransaction: {
+ key: ({route}) => {
+ const transactionID = lodashGet(route, 'params.transactionID', 0);
+ return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`;
+ },
+ },
+ policy: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`,
+ },
+ policyCategories: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`,
+ },
+ policyTags: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`,
+ },
+ }),
+)(IOURequestStepCategory);
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js
index 98bb6851d0de..0744fbd600a7 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.js
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js
@@ -133,7 +133,7 @@ function IOURequestStepConfirmation({
return;
}
if (policyCategories && policyCategories[transaction.category] && !policyCategories[transaction.category].enabled) {
- IOU.resetMoneyRequestCategory_temporaryForRefactor(transactionID);
+ IOU.setMoneyRequestCategory(transactionID, '');
}
}, [policyCategories, transaction.category, transactionID]);
const defaultCategory = lodashGet(
@@ -145,7 +145,7 @@ function IOURequestStepConfirmation({
if (requestType !== CONST.IOU.REQUEST_TYPE.DISTANCE || !_.isEmpty(transaction.category)) {
return;
}
- IOU.setMoneyRequestCategory_temporaryForRefactor(transactionID, defaultCategory);
+ IOU.setMoneyRequestCategory(transactionID, defaultCategory);
// Prevent resetting to default when unselect category
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactionID, requestType, defaultCategory]);
diff --git a/src/pages/iou/request/step/IOURequestStepCurrency.js b/src/pages/iou/request/step/IOURequestStepCurrency.js
index 42e059d7d276..43e4e9bf0eaa 100644
--- a/src/pages/iou/request/step/IOURequestStepCurrency.js
+++ b/src/pages/iou/request/step/IOURequestStepCurrency.js
@@ -5,6 +5,7 @@ import {Keyboard} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
import compose from '@libs/compose';
@@ -126,6 +127,7 @@ function IOURequestStepCurrency({
{({didScreenTransitionEnd}) => (
{
const nextStepIOUType = numberOfParticipants.current === 1 ? iouType : CONST.IOU.TYPE.SPLIT;
IOU.setMoneyRequestTag(transactionID, '');
- IOU.resetMoneyRequestCategory_temporaryForRefactor(transactionID);
+ IOU.setMoneyRequestCategory(transactionID, '');
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(nextStepIOUType, transactionID, selectedReportID.current || reportID));
}, [iouType, transactionID, reportID]);
diff --git a/src/pages/iou/request/step/IOURequestStepTag.js b/src/pages/iou/request/step/IOURequestStepTag.js
index 20ab92d3d446..572663160090 100644
--- a/src/pages/iou/request/step/IOURequestStepTag.js
+++ b/src/pages/iou/request/step/IOURequestStepTag.js
@@ -82,7 +82,7 @@ function IOURequestStepTag({
*/
const updateTag = (selectedTag) => {
const isSelectedTag = selectedTag.searchText === tag;
- const updatedTag = IOUUtils.insertTagIntoReportTagsString(transactionTag, isSelectedTag ? '' : selectedTag.searchText, tagIndex);
+ const updatedTag = IOUUtils.insertTagIntoTransactionTagsString(transactionTag, isSelectedTag ? '' : selectedTag.searchText, tagIndex);
if (isSplitBill && isEditing) {
IOU.setDraftSplitTransaction(transactionID, {tag: updatedTag});
navigateBack();
diff --git a/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js
index 02b0c3c38dd5..29263d92078f 100644
--- a/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js
+++ b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js
@@ -1,4 +1,6 @@
import {useFocusEffect} from '@react-navigation/native';
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useRef} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
@@ -36,14 +38,17 @@ const propTypes = {
transaction: transactionPropTypes,
/* Onyx Props */
- /** Collection of tax rates attached to a policy */
- policyTaxRates: taxPropTypes,
+ /** The policy of the report */
+ policy: PropTypes.shape({
+ /** Collection of tax rates attached to a policy */
+ taxRates: taxPropTypes,
+ }),
};
const defaultProps = {
report: {},
transaction: {},
- policyTaxRates: {},
+ policy: {},
};
const getTaxAmount = (transaction, defaultTaxValue) => {
@@ -58,7 +63,7 @@ function IOURequestStepTaxAmountPage({
transaction,
transaction: {currency},
report,
- policyTaxRates,
+ policy,
}) {
const {translate} = useLocalize();
const styles = useThemeStyles();
@@ -69,6 +74,7 @@ function IOURequestStepTaxAmountPage({
const isSaveButtonPressed = useRef(false);
const originalCurrency = useRef(null);
+ const taxRates = lodashGet(policy, 'taxRates', {});
useEffect(() => {
if (transaction.originalCurrency) {
@@ -140,7 +146,7 @@ function IOURequestStepTaxAmountPage({
isEditing={isEditing}
currency={currency}
amount={transaction.taxAmount}
- taxAmount={getTaxAmount(transaction, policyTaxRates.defaultValue)}
+ taxAmount={getTaxAmount(transaction, taxRates.defaultValue)}
transaction={transaction}
ref={(e) => (textInput.current = e)}
onCurrencyButtonPress={navigateToCurrencySelectionPage}
@@ -177,8 +183,8 @@ export default compose(
withWritableReportOrNotFound,
withFullTransactionOrNotFound,
withOnyx({
- policyTaxRates: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${report ? report.policyID : '0'}`,
+ policy: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`,
},
}),
)(IOURequestStepTaxAmountPage);
diff --git a/src/pages/iou/request/step/IOURequestStepTaxRatePage.js b/src/pages/iou/request/step/IOURequestStepTaxRatePage.js
index edcc8c8fa592..0cc2375b5510 100644
--- a/src/pages/iou/request/step/IOURequestStepTaxRatePage.js
+++ b/src/pages/iou/request/step/IOURequestStepTaxRatePage.js
@@ -1,3 +1,5 @@
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
@@ -22,16 +24,19 @@ const propTypes = {
/** Navigation route context info provided by react navigation */
route: IOURequestStepRoutePropTypes.isRequired,
- /* Onyx Props */
- /** Collection of tax rates attached to a policy */
- policyTaxRates: taxPropTypes,
-
/** The transaction object being modified in Onyx */
transaction: transactionPropTypes,
+
+ /* Onyx Props */
+ /** The policy of the report */
+ policy: PropTypes.shape({
+ /** Collection of tax rates attached to a policy */
+ taxRates: taxPropTypes,
+ }),
};
const defaultProps = {
- policyTaxRates: {},
+ policy: {},
transaction: {},
};
@@ -44,7 +49,7 @@ function IOURequestStepTaxRatePage({
route: {
params: {backTo},
},
- policyTaxRates,
+ policy,
transaction,
}) {
const {translate} = useLocalize();
@@ -52,13 +57,13 @@ function IOURequestStepTaxRatePage({
const navigateBack = () => {
Navigation.goBack(backTo);
};
-
- const defaultTaxKey = policyTaxRates.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${policyTaxRates.taxes[defaultTaxKey].name} (${policyTaxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
+ const taxRates = lodashGet(policy, 'taxRates', {});
+ const defaultTaxKey = taxRates.defaultExternalID;
+ const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
const selectedTaxRate = (transaction.taxRate && transaction.taxRate.text) || defaultTaxName;
const updateTaxRates = (taxes) => {
- const taxAmount = getTaxAmount(policyTaxRates, taxes.text, transaction.amount);
+ const taxAmount = getTaxAmount(taxRates, taxes.text, transaction.amount);
const amountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(taxAmount));
IOU.setMoneyRequestTaxRate(transaction.transactionID, taxes);
IOU.setMoneyRequestTaxAmount(transaction.transactionID, amountInSmallestCurrencyUnits);
@@ -80,7 +85,7 @@ function IOURequestStepTaxRatePage({
/>
@@ -98,8 +103,8 @@ export default compose(
withWritableReportOrNotFound,
withFullTransactionOrNotFound,
withOnyx({
- policyTaxRates: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${report ? report.policyID : '0'}`,
+ policy: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`,
},
}),
)(IOURequestStepTaxRatePage);
diff --git a/src/pages/iou/request/step/IOURequestStepWaypoint.js b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
similarity index 64%
rename from src/pages/iou/request/step/IOURequestStepWaypoint.js
rename to src/pages/iou/request/step/IOURequestStepWaypoint.tsx
index 93a87baa0481..eee6da9e87ef 100644
--- a/src/pages/iou/request/step/IOURequestStepWaypoint.js
+++ b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
@@ -1,91 +1,69 @@
import {useNavigation} from '@react-navigation/native';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
+import type {TextInput} from 'react-native';
+import type {Place} from 'react-native-google-places-autocomplete';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {OnyxEntry} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import AddressSearch from '@components/AddressSearch';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import ConfirmModal from '@components/ConfirmModal';
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 * as Expensicons from '@components/Icon/Expensicons';
import ScreenWrapper from '@components/ScreenWrapper';
-import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
import useLocationBias from '@hooks/useLocationBias';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as ValidationUtils from '@libs/ValidationUtils';
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 IOURequestStepRoutePropTypes from './IOURequestStepRoutePropTypes';
+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 withWritableReportOrNotFound from './withWritableReportOrNotFound';
-const propTypes = {
- /** Navigation route context info provided by react navigation */
- route: IOURequestStepRoutePropTypes.isRequired,
+type IOURequestStepWaypointOnyxProps = {
+ /** List of recent waypoints */
+ recentWaypoints: OnyxEntry;
- /* Onyx props */
- /** The optimistic transaction for this request */
- transaction: transactionPropTypes,
-
- /* Current location coordinates of the user */
- userLocation: PropTypes.shape({
- /** Latitude of the location */
- latitude: PropTypes.number,
-
- /** Longitude of the location */
- longitude: PropTypes.number,
- }),
-
- /** Recent waypoints that the user has selected */
- recentWaypoints: PropTypes.arrayOf(
- PropTypes.shape({
- /** The name of the location */
- name: PropTypes.string,
-
- /** A description of the location (usually the address) */
- description: PropTypes.string,
-
- /** Data required by the google auto complete plugin to know where to put the markers on the map */
- geometry: PropTypes.shape({
- /** Data about the location */
- location: PropTypes.shape({
- /** Latitude of the location */
- lat: PropTypes.number,
-
- /** Longitude of the location */
- lng: PropTypes.number,
- }),
- }),
- }),
- ),
+ userLocation: OnyxEntry;
};
-const defaultProps = {
- recentWaypoints: [],
- transaction: {},
- userLocation: undefined,
-};
+type IOURequestStepWaypointProps = {
+ route: {
+ params: {
+ iouType: ValueOf;
+ transactionID: string;
+ reportID: string;
+ backTo: Routes | undefined;
+ action: ValueOf;
+ pageIndex: string;
+ };
+ };
+ transaction: OnyxEntry;
+} & IOURequestStepWaypointOnyxProps;
function IOURequestStepWaypoint({
- recentWaypoints,
route: {
params: {action, backTo, iouType, pageIndex, reportID, transactionID},
},
transaction,
+ recentWaypoints = [],
userLocation,
-}) {
+}: IOURequestStepWaypointProps) {
const styles = useThemeStyles();
const {windowWidth} = useWindowDimensions();
const [isDeleteStopModalOpen, setIsDeleteStopModalOpen] = useState(false);
@@ -93,12 +71,12 @@ function IOURequestStepWaypoint({
const isFocused = navigation.isFocused();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
- const textInput = useRef(null);
+ const textInput = useRef(null);
const parsedWaypointIndex = parseInt(pageIndex, 10);
- const allWaypoints = lodashGet(transaction, 'comment.waypoints', {});
- const currentWaypoint = lodashGet(allWaypoints, `waypoint${pageIndex}`, {});
- const waypointCount = _.size(allWaypoints);
- const filledWaypointCount = _.size(_.filter(allWaypoints, (waypoint) => !_.isEmpty(waypoint)));
+ const allWaypoints = transaction?.comment.waypoints ?? {};
+ const currentWaypoint = allWaypoints[`waypoint${pageIndex}`] ?? {};
+ const waypointCount = Object.keys(allWaypoints).length;
+ const filledWaypointCount = Object.values(allWaypoints).filter((waypoint) => !isEmptyObject(waypoint)).length;
const waypointDescriptionKey = useMemo(() => {
switch (parsedWaypointIndex) {
@@ -112,16 +90,16 @@ function IOURequestStepWaypoint({
}, [parsedWaypointIndex, waypointCount]);
const locationBias = useLocationBias(allWaypoints, userLocation);
- const waypointAddress = lodashGet(currentWaypoint, 'address', '');
- // Hide the menu when there is only start and finish waypoint or the current waypoint is empty
- const shouldShowThreeDotsButton = waypointCount > 2 && waypointAddress;
+ const waypointAddress = currentWaypoint.address ?? '';
+ // Hide the menu when there is only start and finish waypoint
+ const shouldShowThreeDotsButton = waypointCount > 2 && !!waypointAddress;
const shouldDisableEditor =
isFocused &&
(Number.isNaN(parsedWaypointIndex) || parsedWaypointIndex < 0 || parsedWaypointIndex > waypointCount || (filledWaypointCount < 2 && parsedWaypointIndex >= waypointCount));
- const validate = (values) => {
+ const validate = (values: FormOnyxValues<'waypointForm'>): Partial> => {
const errors = {};
- const waypointValue = values[`waypoint${pageIndex}`] || '';
+ const waypointValue = values[`waypoint${pageIndex}`] ?? '';
if (isOffline && waypointValue !== '' && !ValidationUtils.isValidAddress(waypointValue)) {
ErrorUtils.addErrorMessage(errors, `waypoint${pageIndex}`, 'bankAccount.error.address');
}
@@ -135,11 +113,10 @@ function IOURequestStepWaypoint({
return errors;
};
- const saveWaypoint = (waypoint) => Transaction.saveWaypoint(transactionID, pageIndex, waypoint, action === CONST.IOU.ACTION.CREATE);
-
- const submit = (values) => {
- const waypointValue = values[`waypoint${pageIndex}`] || '';
+ const saveWaypoint = (waypoint: FormOnyxValues<'waypointForm'>) => Transaction.saveWaypoint(transactionID, pageIndex, waypoint, action === CONST.IOU.ACTION.CREATE);
+ const submit = (values: FormOnyxValues<'waypointForm'>) => {
+ const waypointValue = values[`waypoint${pageIndex}`] ?? '';
// Allows letting you set a waypoint to an empty value
if (waypointValue === '') {
Transaction.removeWaypoint(transaction, pageIndex, true);
@@ -149,10 +126,8 @@ function IOURequestStepWaypoint({
// Therefore, we're going to save the waypoint as just the address, and the lat/long will be filled in on the backend
if (isOffline && waypointValue) {
const waypoint = {
- lat: null,
- lng: null,
address: waypointValue,
- name: values.name || null,
+ name: values.name,
};
saveWaypoint(waypoint);
}
@@ -167,19 +142,14 @@ function IOURequestStepWaypoint({
Navigation.goBack(ROUTES.MONEY_REQUEST_DISTANCE_TAB.getRoute(iouType));
};
- /**
- * @param {Object} values
- * @param {String} values.lat
- * @param {String} values.lng
- * @param {String} values.address
- */
- const selectWaypoint = (values) => {
+ const selectWaypoint = (values: Waypoint) => {
const waypoint = {
lat: values.lat,
lng: values.lng,
address: values.address,
- name: values.name || null,
+ name: values.name,
};
+
Transaction.saveWaypoint(transactionID, pageIndex, waypoint, action === CONST.IOU.ACTION.CREATE);
if (backTo) {
Navigation.goBack(backTo);
@@ -191,7 +161,7 @@ function IOURequestStepWaypoint({
return (
textInput.current && textInput.current.focus()}
+ onEntryTransitionEnd={() => textInput.current?.focus()}
shouldEnableMaxHeight
testID={IOURequestStepWaypoint.displayName}
>
@@ -240,7 +210,9 @@ function IOURequestStepWaypoint({
locationBias={locationBias}
canUseCurrentLocation
inputID={`waypoint${pageIndex}`}
- ref={(e) => (textInput.current = e)}
+ ref={(e: HTMLElement | null) => {
+ textInput.current = e as unknown as TextInput;
+ }}
hint={!isOffline ? 'distance.errors.selectSuggestedAddress' : ''}
containerStyles={[styles.mt4]}
label={translate('distance.address')}
@@ -249,14 +221,14 @@ function IOURequestStepWaypoint({
maxInputLength={CONST.FORM_CHARACTER_LIMIT}
renamedInputKeys={{
address: `waypoint${pageIndex}`,
- city: null,
- country: null,
- street: null,
- street2: null,
- zipCode: null,
- lat: null,
- lng: null,
- state: null,
+ city: '',
+ country: '',
+ street: '',
+ street2: '',
+ zipCode: '',
+ lat: '',
+ lng: '',
+ state: '',
}}
predefinedPlaces={recentWaypoints}
resultTypes=""
@@ -269,32 +241,32 @@ function IOURequestStepWaypoint({
}
IOURequestStepWaypoint.displayName = 'IOURequestStepWaypoint';
-IOURequestStepWaypoint.propTypes = propTypes;
-IOURequestStepWaypoint.defaultProps = defaultProps;
-export default compose(
- withWritableReportOrNotFound,
- withFullTransactionOrNotFound,
- withOnyx({
- userLocation: {
- key: ONYXKEYS.USER_LOCATION,
- },
- recentWaypoints: {
- key: ONYXKEYS.NVP_RECENT_WAYPOINTS,
+// eslint-disable-next-line rulesdir/no-negated-variables
+const IOURequestStepWaypointWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepWaypoint);
+// eslint-disable-next-line rulesdir/no-negated-variables
+const IOURequestStepWaypointWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepWaypointWithWritableReportOrNotFound);
- // Only grab the most recent 5 waypoints because that's all that is shown in the UI. This also puts them into the format of data
- // that the google autocomplete component expects for it's "predefined places" feature.
- selector: (waypoints) =>
- _.map(waypoints ? waypoints.slice(0, 5) : [], (waypoint) => ({
- name: waypoint.name,
- description: waypoint.address,
- geometry: {
- location: {
- lat: waypoint.lat,
- lng: waypoint.lng,
- },
+export default withOnyx({
+ userLocation: {
+ key: ONYXKEYS.USER_LOCATION,
+ },
+ recentWaypoints: {
+ key: ONYXKEYS.NVP_RECENT_WAYPOINTS,
+
+ // Only grab the most recent 5 waypoints because that's all that is shown in the UI. This also puts them into the format of data
+ // that the google autocomplete component expects for it's "predefined places" feature.
+ selector: (waypoints) =>
+ (waypoints ? waypoints.slice(0, 5) : []).map((waypoint) => ({
+ name: waypoint.name,
+ description: waypoint.address ?? '',
+ geometry: {
+ location: {
+ lat: waypoint.lat ?? 0,
+ lng: waypoint.lng ?? 0,
},
- })),
- },
- }),
-)(IOURequestStepWaypoint);
+ },
+ })),
+ },
+ // @ts-expect-error TODO: Remove this once withFullTransactionOrNotFound (https://github.com/Expensify/App/issues/36123) is migrated to TypeScript.
+})(IOURequestStepWaypointWithFullTransactionOrNotFound);
diff --git a/src/pages/iou/request/step/StepScreenWrapper.js b/src/pages/iou/request/step/StepScreenWrapper.js
index eae542f0f6f9..1d9129861db0 100644
--- a/src/pages/iou/request/step/StepScreenWrapper.js
+++ b/src/pages/iou/request/step/StepScreenWrapper.js
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
import _ from 'underscore';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -23,6 +24,9 @@ const propTypes = {
/** Whether or not the wrapper should be shown (sometimes screens can be embedded inside another screen that already is using a wrapper) */
shouldShowWrapper: PropTypes.bool.isRequired,
+ /** Whether or not to display not found page */
+ shouldShowNotFoundPage: PropTypes.bool,
+
/** An ID used for unit testing */
testID: PropTypes.string.isRequired,
@@ -33,11 +37,16 @@ const propTypes = {
const defaultProps = {
onEntryTransitionEnd: () => {},
includeSafeAreaPaddingBottom: false,
+ shouldShowNotFoundPage: false,
};
-function StepScreenWrapper({testID, headerTitle, onBackButtonPress, onEntryTransitionEnd, children, shouldShowWrapper, includeSafeAreaPaddingBottom}) {
+function StepScreenWrapper({testID, headerTitle, onBackButtonPress, onEntryTransitionEnd, children, shouldShowWrapper, shouldShowNotFoundPage, includeSafeAreaPaddingBottom}) {
const styles = useThemeStyles();
+ if (shouldShowNotFoundPage) {
+ return ;
+ }
+
if (!shouldShowWrapper) {
return children;
}
diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.tsx
similarity index 79%
rename from src/pages/iou/steps/MoneyRequestAmountForm.js
rename to src/pages/iou/steps/MoneyRequestAmountForm.tsx
index 9106ff28589e..cb1f73ae2207 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.js
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.tsx
@@ -1,12 +1,11 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useRef, useState} from 'react';
+import type {ForwardedRef} from 'react';
import {ScrollView, View} from 'react-native';
-import _ from 'underscore';
+import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
+import type {ValueOf} from 'type-fest';
import BigNumberPad from '@components/BigNumberPad';
import Button from '@components/Button';
import FormHelpMessage from '@components/FormHelpMessage';
-import refPropTypes from '@components/refPropTypes';
import TextInputWithCurrencySymbol from '@components/TextInputWithCurrencySymbol';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -15,78 +14,80 @@ import * as Browser from '@libs/Browser';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import getOperatingSystem from '@libs/getOperatingSystem';
+import type {MaybePhraseKey} from '@libs/Localize';
import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
import Navigation from '@libs/Navigation/Navigation';
+import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types';
import CONST from '@src/CONST';
-const propTypes = {
+type MoneyRequestAmountFormProps = {
/** IOU amount saved in Onyx */
- amount: PropTypes.number,
+ amount?: number;
/** Calculated tax amount based on selected tax rate */
- taxAmount: PropTypes.number,
+ taxAmount?: number;
/** Currency chosen by user or saved in Onyx */
- currency: PropTypes.string,
+ currency?: string;
/** Whether the amount is being edited or not */
- isEditing: PropTypes.bool,
-
- /** Refs forwarded to the TextInputWithCurrencySymbol */
- forwardedRef: refPropTypes,
+ isEditing?: boolean;
/** Fired when back button pressed, navigates to currency selection page */
- onCurrencyButtonPress: PropTypes.func.isRequired,
+ onCurrencyButtonPress: () => void;
/** Fired when submit button pressed, saves the given amount and navigates to the next page */
- onSubmitButtonPress: PropTypes.func.isRequired,
+ onSubmitButtonPress: ({amount, currency}: {amount: string; currency: string}) => void;
/** The current tab we have navigated to in the request modal. String that corresponds to the request type. */
- selectedTab: PropTypes.oneOf([CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN]),
+ selectedTab?: ValueOf;
};
-const defaultProps = {
- amount: 0,
- taxAmount: 0,
- currency: CONST.CURRENCY.USD,
- forwardedRef: null,
- isEditing: false,
- selectedTab: CONST.TAB_REQUEST.MANUAL,
+type Selection = {
+ start: number;
+ end: number;
};
/**
* Returns the new selection object based on the updated amount's length
- *
- * @param {Object} oldSelection
- * @param {Number} prevLength
- * @param {Number} newLength
- * @returns {Object}
*/
-const getNewSelection = (oldSelection, prevLength, newLength) => {
+const getNewSelection = (oldSelection: Selection, prevLength: number, newLength: number): Selection => {
const cursorPosition = oldSelection.end + (newLength - prevLength);
return {start: cursorPosition, end: cursorPosition};
};
-const isAmountInvalid = (amount) => !amount.length || parseFloat(amount) < 0.01;
-const isTaxAmountInvalid = (currentAmount, taxAmount, isTaxAmountForm) => isTaxAmountForm && currentAmount > CurrencyUtils.convertToFrontendAmount(taxAmount);
+const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01;
+const isTaxAmountInvalid = (currentAmount: string, taxAmount: number, isTaxAmountForm: boolean) =>
+ isTaxAmountForm && Number.parseFloat(currentAmount) > CurrencyUtils.convertToFrontendAmount(taxAmount);
const AMOUNT_VIEW_ID = 'amountView';
const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView';
const NUM_PAD_VIEW_ID = 'numPadView';
-function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forwardedRef, onCurrencyButtonPress, onSubmitButtonPress, selectedTab}) {
+function MoneyRequestAmountForm(
+ {
+ amount = 0,
+ taxAmount = 0,
+ currency = CONST.CURRENCY.USD,
+ isEditing = false,
+ onCurrencyButtonPress,
+ onSubmitButtonPress,
+ selectedTab = CONST.TAB_REQUEST.MANUAL,
+ }: MoneyRequestAmountFormProps,
+ forwardedRef: ForwardedRef,
+) {
const styles = useThemeStyles();
const {isExtraSmallScreenHeight} = useWindowDimensions();
const {translate, toLocaleDigit, numberFormat} = useLocalize();
- const textInput = useRef(null);
+ const textInput = useRef(null);
const isTaxAmountForm = Navigation.getActiveRoute().includes('taxAmount');
const decimals = CurrencyUtils.getCurrencyDecimals(currency);
const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : '';
const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString);
- const [formError, setFormError] = useState('');
+ const [formError, setFormError] = useState('');
const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true);
const [selection, setSelection] = useState({
@@ -100,15 +101,13 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
/**
* Event occurs when a user presses a mouse button over an DOM element.
- *
- * @param {Event} event
- * @param {Array} ids
*/
- const onMouseDown = (event, ids) => {
- const relatedTargetId = lodashGet(event, 'nativeEvent.target.id');
- if (!_.contains(ids, relatedTargetId)) {
+ const onMouseDown = (event: React.MouseEvent, ids: string[]) => {
+ const relatedTargetId = (event.nativeEvent?.target as HTMLElement)?.id;
+ if (!ids.includes(relatedTargetId)) {
return;
}
+
event.preventDefault();
if (!textInput.current) {
return;
@@ -118,7 +117,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
}
};
- const initializeAmount = useCallback((newAmount) => {
+ const initializeAmount = useCallback((newAmount: number) => {
const frontendAmount = newAmount ? CurrencyUtils.convertToFrontendAmount(newAmount).toString() : '';
setCurrentAmount(frontendAmount);
setSelection({
@@ -128,7 +127,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
}, []);
useEffect(() => {
- if (!currency || !_.isNumber(amount)) {
+ if (!currency || typeof amount !== 'number') {
return;
}
initializeAmount(amount);
@@ -141,7 +140,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
* @param {String} newAmount - Changed amount from user input
*/
const setNewAmount = useCallback(
- (newAmount) => {
+ (newAmount: string) => {
// Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value
// More info: https://github.com/Expensify/App/issues/16974
const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount);
@@ -151,7 +150,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
setSelection((prevSelection) => ({...prevSelection}));
return;
}
- if (!_.isEmpty(formError)) {
+ if (formError) {
setFormError('');
}
@@ -188,13 +187,11 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
/**
* Update amount with number or Backspace pressed for BigNumberPad.
* Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button
- *
- * @param {String} key
*/
const updateAmountNumberPad = useCallback(
- (key) => {
- if (shouldUpdateSelection && !textInput.current.isFocused()) {
- textInput.current.focus();
+ (key: string) => {
+ if (shouldUpdateSelection && !textInput.current?.isFocused()) {
+ textInput.current?.focus();
}
// Backspace button is pressed
if (key === '<' || key === 'Backspace') {
@@ -214,12 +211,12 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
/**
* Update long press value, to remove items pressing on <
*
- * @param {Boolean} value - Changed text from user input
+ * @param value - Changed text from user input
*/
- const updateLongPressHandlerState = useCallback((value) => {
+ const updateLongPressHandlerState = useCallback((value: boolean) => {
setShouldUpdateSelection(!value);
- if (!value && !textInput.current.isFocused()) {
- textInput.current.focus();
+ if (!value && !textInput.current?.isFocused()) {
+ textInput.current?.focus();
}
}, []);
@@ -248,8 +245,8 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
/**
* Input handler to check for a forward-delete key (or keyboard shortcut) press.
*/
- const textInputKeyPress = ({nativeEvent}) => {
- const key = nativeEvent.key.toLowerCase();
+ const textInputKeyPress = ({nativeEvent}: NativeSyntheticEvent) => {
+ const key = nativeEvent?.key.toLowerCase();
if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) {
// Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being
// used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press.
@@ -258,7 +255,8 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
}
// Control-D on Mac is a keyboard shortcut for forward-delete. See https://support.apple.com/en-us/HT201236 for Mac keyboard shortcuts.
// Also check for the keyboard shortcut on iOS in cases where a hardware keyboard may be connected to the device.
- forwardDeletePressedRef.current = key === 'delete' || (_.contains([CONST.OS.MAC_OS, CONST.OS.IOS], getOperatingSystem()) && nativeEvent.ctrlKey && key === 'd');
+ const operatingSystem = getOperatingSystem();
+ forwardDeletePressedRef.current = key === 'delete' || ((operatingSystem === CONST.OS.MAC_OS || operatingSystem === CONST.OS.IOS) && nativeEvent?.ctrlKey && key === 'd');
};
const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit);
@@ -284,7 +282,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
ref={(ref) => {
if (typeof forwardedRef === 'function') {
forwardedRef(ref);
- } else if (forwardedRef && _.has(forwardedRef, 'current')) {
+ } else if (forwardedRef?.current) {
// eslint-disable-next-line no-param-reassign
forwardedRef.current = ref;
}
@@ -292,7 +290,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
}}
selectedCurrencyCode={currency}
selection={selection}
- onSelectionChange={(e) => {
+ onSelectionChange={(e: NativeSyntheticEvent) => {
if (!shouldUpdateSelection) {
return;
}
@@ -302,8 +300,9 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward
setSelection({start, end});
}}
onKeyPress={textInputKeyPress}
+ isCurrencyPressable
/>
- {!_.isEmpty(formError) && (
+ {!!formError && (
(
-
-));
-
-MoneyRequestAmountFormWithRef.displayName = 'MoneyRequestAmountFormWithRef';
-
-export default MoneyRequestAmountFormWithRef;
+export default React.forwardRef(MoneyRequestAmountForm);
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
index ea57d88579ae..fc522816b4ce 100644
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js
@@ -87,7 +87,6 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) {
const navigateToConfirmationStep = (moneyRequestType) => {
IOU.setMoneyRequestId(moneyRequestType);
- IOU.resetMoneyRequestCategory();
Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(moneyRequestType, reportID));
};
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
index 7006c2703b13..3fde970327d7 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
@@ -11,6 +11,7 @@ import {PressableWithFeedback} from '@components/Pressable';
import ReferralProgramCTA from '@components/ReferralProgramCTA';
import SelectCircle from '@components/SelectCircle';
import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch';
@@ -348,6 +349,7 @@ function MoneyRequestParticipantsSelector({
;
+};
+
+type ExitSurveyConfirmPageProps = ExitSurveyConfirmPageOnyxProps & StackScreenProps;
+
+function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitSurveyConfirmPageProps) {
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+ const styles = useThemeStyles();
+
+ const getBackToParam = useCallback(() => {
+ if (isOffline) {
+ return ROUTES.SETTINGS;
+ }
+ if (exitReason) {
+ return ROUTES.SETTINGS_EXIT_SURVEY_RESPONSE.getRoute(exitReason, ROUTES.SETTINGS_EXIT_SURVEY_REASON);
+ }
+ return ROUTES.SETTINGS;
+ }, [exitReason, isOffline]);
+ const {backTo} = route.params;
+ useEffect(() => {
+ const newBackTo = getBackToParam();
+ if (backTo === newBackTo) {
+ return;
+ }
+ navigation.setParams({
+ backTo: newBackTo,
+ });
+ }, [backTo, getBackToParam, navigation]);
+
+ return (
+
+ Navigation.goBack(backTo)}
+ />
+
+ {isOffline && }
+ {!isOffline && (
+ <>
+
+ {translate('exitSurvey.thankYou')}
+ {translate('exitSurvey.thankYouSubtitle')}
+ >
+ )}
+
+
+
+
+ );
+}
+
+ExitSurveyConfirmPage.displayName = 'ExitSurveyConfirmPage';
+
+export default withOnyx({
+ exitReason: {
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM,
+ selector: (value: OnyxEntry) => value?.[EXIT_SURVEY_REASON_INPUT_IDS.REASON],
+ },
+ isLoading: {
+ key: ONYXKEYS.IS_SWITCHING_TO_OLD_DOT,
+ },
+})(ExitSurveyConfirmPage);
diff --git a/src/pages/settings/ExitSurvey/ExitSurveyOffline.tsx b/src/pages/settings/ExitSurvey/ExitSurveyOffline.tsx
new file mode 100644
index 000000000000..3363867ad4bb
--- /dev/null
+++ b/src/pages/settings/ExitSurvey/ExitSurveyOffline.tsx
@@ -0,0 +1,28 @@
+import React, {memo} from 'react';
+import {View} from 'react-native';
+import Icon from '@components/Icon';
+import {ToddBehindCloud} from '@components/Icon/Illustrations';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+
+function ExitSurveyOffline() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ return (
+
+
+ {translate('exitSurvey.offlineTitle')}
+ {translate('exitSurvey.offline')}
+
+ );
+}
+
+ExitSurveyOffline.displayName = 'ExitSurveyOffline';
+
+export default memo(ExitSurveyOffline);
diff --git a/src/pages/settings/ExitSurvey/ExitSurveyReasonPage.tsx b/src/pages/settings/ExitSurvey/ExitSurveyReasonPage.tsx
new file mode 100644
index 000000000000..dbaf330803c1
--- /dev/null
+++ b/src/pages/settings/ExitSurvey/ExitSurveyReasonPage.tsx
@@ -0,0 +1,105 @@
+import React, {useEffect, useMemo, useState} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import type {Choice} from '@components/RadioButtons';
+import RadioButtons from '@components/RadioButtons';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@navigation/Navigation';
+import * as ExitSurvey from '@userActions/ExitSurvey';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type {ExitReason} from '@src/types/form/ExitSurveyReasonForm';
+import INPUT_IDS from '@src/types/form/ExitSurveyReasonForm';
+import type {Errors} from '@src/types/onyx/OnyxCommon';
+import ExitSurveyOffline from './ExitSurveyOffline';
+
+type ExitSurveyReasonPageOnyxProps = {
+ draftReason: ExitReason | null;
+};
+
+function ExitSurveyReasonPage({draftReason}: ExitSurveyReasonPageOnyxProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {isOffline} = useNetwork();
+
+ const [reason, setReason] = useState(draftReason);
+ useEffect(() => {
+ // disabling lint because || is fine to use as a logical operator (as opposed to being used to define a default value)
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (reason || !draftReason) {
+ return;
+ }
+ setReason(draftReason);
+ }, [reason, draftReason]);
+ const reasons: Choice[] = useMemo(
+ () =>
+ Object.values(CONST.EXIT_SURVEY.REASONS).map((value) => ({
+ value,
+ label: translate(`exitSurvey.reasons.${value}`),
+ style: styles.mt6,
+ })),
+ [styles, translate],
+ );
+
+ return (
+
+ Navigation.goBack()}
+ />
+ {
+ const errors: Errors = {};
+ if (!reason) {
+ errors[INPUT_IDS.REASON] = 'common.error.fieldRequired';
+ }
+ return errors;
+ }}
+ onSubmit={() => {
+ if (!reason) {
+ return;
+ }
+ ExitSurvey.saveExitReason(reason);
+ Navigation.navigate(ROUTES.SETTINGS_EXIT_SURVEY_RESPONSE.getRoute(reason, ROUTES.SETTINGS_EXIT_SURVEY_REASON));
+ }}
+ submitButtonText={translate('common.next')}
+ shouldValidateOnBlur
+ shouldValidateOnChange
+ >
+ {isOffline && }
+ {!isOffline && (
+ <>
+ {translate('exitSurvey.reasonPage.title')}
+ {translate('exitSurvey.reasonPage.subtitle')}
+ void}
+ shouldSaveDraft
+ />
+ >
+ )}
+
+
+ );
+}
+
+ExitSurveyReasonPage.displayName = 'ExitSurveyReasonPage';
+
+export default withOnyx({
+ draftReason: {
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM_DRAFT,
+ selector: (value) => value?.[INPUT_IDS.REASON] ?? null,
+ },
+})(ExitSurveyReasonPage);
diff --git a/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx b/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx
new file mode 100644
index 000000000000..c43ef8dd9320
--- /dev/null
+++ b/src/pages/settings/ExitSurvey/ExitSurveyResponsePage.tsx
@@ -0,0 +1,153 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback, useEffect} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import type {AnimatedTextInputRef} from '@components/RNTextInput';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
+import useKeyboardState from '@hooks/useKeyboardState';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as NumberUtils from '@libs/NumberUtils';
+import updateMultilineInputRange from '@libs/updateMultilineInputRange';
+import Navigation from '@navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import variables from '@styles/variables';
+import * as ExitSurvey from '@userActions/ExitSurvey';
+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/ExitSurveyResponseForm';
+import type {Errors} from '@src/types/onyx/OnyxCommon';
+import ExitSurveyOffline from './ExitSurveyOffline';
+
+type ExitSurveyResponsePageOnyxProps = {
+ draftResponse: string;
+};
+
+type ExitSurveyResponsePageProps = ExitSurveyResponsePageOnyxProps & StackScreenProps;
+
+function ExitSurveyResponsePage({draftResponse, route, navigation}: ExitSurveyResponsePageProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const {keyboardHeight} = useKeyboardState();
+ const {windowHeight} = useWindowDimensions();
+ const {top: safeAreaInsetsTop} = useSafeAreaInsets();
+
+ const {reason, backTo} = route.params;
+ const {isOffline} = useNetwork({
+ onReconnect: () => {
+ navigation.setParams({
+ backTo: ROUTES.SETTINGS_EXIT_SURVEY_REASON,
+ });
+ },
+ });
+ useEffect(() => {
+ if (!isOffline || backTo === ROUTES.SETTINGS) {
+ return;
+ }
+ navigation.setParams({backTo: ROUTES.SETTINGS});
+ }, [backTo, isOffline, navigation]);
+
+ const submitForm = useCallback(() => {
+ ExitSurvey.saveResponse(draftResponse);
+ Navigation.navigate(ROUTES.SETTINGS_EXIT_SURVEY_CONFIRM.getRoute(ROUTES.SETTINGS_EXIT_SURVEY_RESPONSE.route));
+ }, [draftResponse]);
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, submitForm);
+
+ const formTopMarginsStyle = styles.mt3;
+ const textStyle = styles.headerAnonymousFooter;
+ const baseResponseInputContainerStyle = styles.mt7;
+ const formMaxHeight = Math.floor(
+ windowHeight -
+ keyboardHeight -
+ safeAreaInsetsTop -
+ // Minus the height of HeaderWithBackButton
+ variables.contentHeaderHeight -
+ // Minus the top margins on the form
+ formTopMarginsStyle.marginTop,
+ );
+ const responseInputMaxHeight = NumberUtils.roundDownToLargestMultiple(
+ formMaxHeight -
+ // Minus the height of the text component
+ textStyle.lineHeight -
+ // Minus the response input margins (multiplied by 2 to create the effect of margins on top and bottom).
+ // marginBottom does not work in this case because the TextInput is in a ScrollView and will push the button beneath it out of view,
+ // so it's maxHeight is what dictates space between it and the button.
+ baseResponseInputContainerStyle.marginTop * 2 -
+ // Minus the approximate size of a default button
+ variables.componentSizeLarge -
+ // Minus the vertical margins around the form button
+ 40,
+
+ // Round down to the largest number of full lines
+ styles.baseTextInput.lineHeight,
+ );
+
+ return (
+
+ Navigation.goBack(backTo)}
+ />
+ {
+ const errors: Errors = {};
+ if (!draftResponse?.trim()) {
+ errors[INPUT_IDS.RESPONSE] = 'common.error.fieldRequired';
+ }
+ return errors;
+ }}
+ shouldValidateOnBlur
+ shouldValidateOnChange
+ >
+ {isOffline && }
+ {!isOffline && (
+ <>
+ {translate(`exitSurvey.prompts.${reason}`)}
+ {
+ if (!el) {
+ return;
+ }
+ updateMultilineInputRange(el);
+ }}
+ containerStyles={[baseResponseInputContainerStyle, StyleUtils.getMaximumHeight(responseInputMaxHeight)]}
+ shouldSaveDraft
+ />
+ >
+ )}
+
+
+ );
+}
+
+ExitSurveyResponsePage.displayName = 'ExitSurveyResponsePage';
+
+export default withOnyx({
+ draftResponse: {
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM_DRAFT,
+ selector: (value) => value?.[INPUT_IDS.RESPONSE] ?? '',
+ },
+})(ExitSurveyResponsePage);
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index 38b64734f6ea..f19df710b41a 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -11,11 +11,14 @@ import cardPropTypes from '@components/cardPropTypes';
import ConfirmModal from '@components/ConfirmModal';
import CurrentUserPersonalDetailsSkeletonView from '@components/CurrentUserPersonalDetailsSkeletonView';
import HeaderPageLayout from '@components/HeaderPageLayout';
+import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import {withNetwork} from '@components/OnyxProvider';
+import {PressableWithFeedback} from '@components/Pressable';
import Text from '@components/Text';
+import Tooltip from '@components/Tooltip';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import useLocalize from '@hooks/useLocalize';
@@ -31,6 +34,7 @@ import Navigation from '@libs/Navigation/Navigation';
import * as UserUtils from '@libs/UserUtils';
import walletTermsPropTypes from '@pages/EnablePayments/walletTermsPropTypes';
import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu';
+import variables from '@styles/variables';
import * as Link from '@userActions/Link';
import * as PaymentMethods from '@userActions/PaymentMethods';
import * as PersonalDetails from '@userActions/PersonalDetails';
@@ -100,6 +104,7 @@ function InitialSettingsPage(props) {
const popoverAnchor = useRef(null);
const {translate} = useLocalize();
const activeRoute = useNavigationState(getTopmostSettingsCentralPaneName);
+ const emojiCode = lodashGet(props, 'currentUserPersonalDetails.status.emojiCode', '');
const [shouldShowSignoutConfirmModal, setShouldShowSignoutConfirmModal] = useState(false);
@@ -136,6 +141,11 @@ function InitialSettingsPage(props) {
sectionStyle: styles.accountSettingsSectionContainer,
sectionTranslationKey: 'initialSettingsPage.account',
items: [
+ {
+ translationKey: 'exitSurvey.goToExpensifyClassic',
+ icon: Expensicons.ExpensifyLogoNew,
+ routeName: ROUTES.SETTINGS_EXIT_SURVEY_REASON,
+ },
{
translationKey: 'common.profile',
icon: Expensicons.Profile,
@@ -151,11 +161,6 @@ function InitialSettingsPage(props) {
? 'error'
: null,
},
- {
- translationKey: 'common.shareCode',
- icon: Expensicons.QrCode,
- routeName: ROUTES.SETTINGS_SHARE_CODE,
- },
{
translationKey: 'common.preferences',
icon: Expensicons.Gear,
@@ -166,16 +171,6 @@ function InitialSettingsPage(props) {
icon: Expensicons.Lock,
routeName: ROUTES.SETTINGS_SECURITY,
},
- {
- translationKey: 'initialSettingsPage.goToExpensifyClassic',
- icon: Expensicons.ExpensifyLogoNew,
- action: () => {
- Link.openOldDotLink(CONST.OLDDOT_URLS.INBOX);
- },
- link: () => Link.buildOldDotURL(CONST.OLDDOT_URLS.INBOX),
- iconRight: Expensicons.NewWindow,
- shouldShowRightIcon: true,
- },
{
translationKey: 'initialSettingsPage.signOut',
icon: Expensicons.Exit,
@@ -211,7 +206,7 @@ function InitialSettingsPage(props) {
* Retuns a list of menu items data for general section
* @returns {Object} object with translationKey, style and items for the general section
*/
- const generaltMenuItemsData = useMemo(
+ const generalMenuItemsData = useMemo(
() => ({
sectionStyle: {
...styles.pt4,
@@ -320,7 +315,7 @@ function InitialSettingsPage(props) {
);
const accountMenuItems = useMemo(() => getMenuItemsSection(accountMenuItemsData), [accountMenuItemsData, getMenuItemsSection]);
- const generalMenuItems = useMemo(() => getMenuItemsSection(generaltMenuItemsData), [generaltMenuItemsData, getMenuItemsSection]);
+ const generalMenuItems = useMemo(() => getMenuItemsSection(generalMenuItemsData), [generalMenuItemsData, getMenuItemsSection]);
const currentUserDetails = props.currentUserPersonalDetails || {};
const avatarURL = lodashGet(currentUserDetails, 'avatar', '');
@@ -332,6 +327,42 @@ function InitialSettingsPage(props) {
) : (
<>
+
+
+ Navigation.navigate(ROUTES.SETTINGS_SHARE_CODE)}
+ >
+
+
+
+
+
+
+ Navigation.navigate(ROUTES.SETTINGS_STATUS)}
+ >
+
+ {emojiCode ? (
+ {emojiCode}
+ ) : (
+
+ )}
+
+
+
+
App.setLocaleAndNavigate(language.value)}
initiallyFocusedOptionKey={_.find(localesToLanguages, (locale) => locale.isSelected).keyForList}
/>
diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js
index 983e3cb26746..05c0546c2e41 100644
--- a/src/pages/settings/Preferences/PriorityModePage.js
+++ b/src/pages/settings/Preferences/PriorityModePage.js
@@ -5,6 +5,7 @@ import _, {compose} from 'underscore';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import Text from '@components/Text';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -57,6 +58,7 @@ function PriorityModePage(props) {
{props.translate('priorityModePage.explainerText')}
mode.isSelected).keyForList}
/>
diff --git a/src/pages/settings/Preferences/ThemePage.js b/src/pages/settings/Preferences/ThemePage.js
index 4907056be761..0724eb286620 100644
--- a/src/pages/settings/Preferences/ThemePage.js
+++ b/src/pages/settings/Preferences/ThemePage.js
@@ -5,6 +5,7 @@ import _ from 'underscore';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -48,6 +49,7 @@ function ThemePage(props) {
User.updateTheme(theme.value)}
initiallyFocusedOptionKey={_.find(localesToThemes, (theme) => theme.isSelected).keyForList}
/>
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
deleted file mode 100644
index a9acf37ae556..000000000000
--- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
+++ /dev/null
@@ -1,393 +0,0 @@
-import Str from 'expensify-common/lib/str';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {Component} from 'react';
-import {InteractionManager, Keyboard, ScrollView, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
-import ConfirmModal from '@components/ConfirmModal';
-import DotIndicatorMessage from '@components/DotIndicatorMessage';
-import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import * as Expensicons from '@components/Icon/Expensicons';
-import MenuItem from '@components/MenuItem';
-import OfflineWithFeedback from '@components/OfflineWithFeedback';
-import ScreenWrapper from '@components/ScreenWrapper';
-import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import withTheme, {withThemePropTypes} from '@components/withTheme';
-import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles';
-import compose from '@libs/compose';
-import {canUseTouchScreen} from '@libs/DeviceCapabilities';
-import * as ErrorUtils from '@libs/ErrorUtils';
-import {translatableTextPropTypes} from '@libs/Localize';
-import Navigation from '@libs/Navigation/Navigation';
-import * as Session from '@userActions/Session';
-import * as User from '@userActions/User';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import ValidateCodeForm from './ValidateCodeForm';
-
-const propTypes = {
- /* Onyx Props */
-
- /** Login list for the user that is signed in */
- loginList: PropTypes.shape({
- /** Value of partner name */
- partnerName: PropTypes.string,
-
- /** Phone/Email associated with user */
- partnerUserID: PropTypes.string,
-
- /** Date when login was validated */
- validatedDate: PropTypes.string,
-
- /** Field-specific server side errors keyed by microtime */
- errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)),
-
- /** Field-specific pending states for offline UI status */
- pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
- }),
-
- /** Current user session */
- session: PropTypes.shape({
- email: PropTypes.string.isRequired,
- }),
-
- /** User's security group IDs by domain */
- myDomainSecurityGroups: PropTypes.objectOf(PropTypes.string),
-
- /** All of the user's security groups and their settings */
- securityGroups: PropTypes.shape({
- hasRestrictedPrimaryLogin: PropTypes.bool,
- }),
-
- /** Route params */
- route: PropTypes.shape({
- params: PropTypes.shape({
- /** Passed via route /settings/profile/contact-methods/:contactMethod/details */
- contactMethod: PropTypes.string,
- }),
- }),
-
- /** Indicated whether the report data is loading */
- isLoadingReportData: PropTypes.bool,
-
- ...withLocalizePropTypes,
- ...withThemeStylesPropTypes,
- ...withThemePropTypes,
-};
-
-const defaultProps = {
- loginList: {},
- session: {
- email: null,
- },
- myDomainSecurityGroups: {},
- securityGroups: {},
- route: {
- params: {
- contactMethod: '',
- },
- },
- isLoadingReportData: true,
-};
-
-class ContactMethodDetailsPage extends Component {
- constructor(props) {
- super(props);
-
- this.deleteContactMethod = this.deleteContactMethod.bind(this);
- this.toggleDeleteModal = this.toggleDeleteModal.bind(this);
- this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this);
- this.getContactMethod = this.getContactMethod.bind(this);
- this.setAsDefault = this.setAsDefault.bind(this);
-
- this.state = {
- isDeleteModalOpen: false,
- };
-
- this.validateCodeFormRef = React.createRef();
- }
-
- componentDidMount() {
- const contactMethod = this.getContactMethod();
- const loginData = lodashGet(this.props.loginList, contactMethod, {});
- if (_.isEmpty(loginData)) {
- return;
- }
- User.resetContactMethodValidateCodeSentState(this.getContactMethod());
- }
-
- componentDidUpdate(prevProps) {
- const contactMethod = this.getContactMethod();
- const validatedDate = lodashGet(this.props.loginList, [contactMethod, 'validatedDate']);
- const prevValidatedDate = lodashGet(prevProps.loginList, [contactMethod, 'validatedDate']);
-
- const loginData = lodashGet(this.props.loginList, contactMethod, {});
- const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID;
- // Navigate to methods page on successful magic code verification
- // validatedDate property is responsible to decide the status of the magic code verification
- if (!prevValidatedDate && validatedDate) {
- // If the selected contactMethod is the current session['login'] and the account is unvalidated,
- // the current authToken is invalid after the successful magic code verification.
- // So we need to sign out the user and redirect to the sign in page.
- if (isDefaultContactMethod) {
- Session.signOutAndRedirectToSignIn();
- return;
- }
- Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route);
- }
- }
-
- /**
- * Gets the current contact method from the route params
- * @returns {string}
- */
- getContactMethod() {
- const contactMethod = lodashGet(this.props.route, 'params.contactMethod');
-
- // We find the number of times the url is encoded based on the last % sign and remove them.
- const lastPercentIndex = contactMethod.lastIndexOf('%');
- const encodePercents = contactMethod.substring(lastPercentIndex).match(new RegExp('25', 'g'));
- let numberEncodePercents = encodePercents ? encodePercents.length : 0;
- const beforeAtSign = contactMethod.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => {
- if (numberEncodePercents > 0) {
- numberEncodePercents--;
- return '%';
- }
- return match;
- });
- const afterAtSign = contactMethod.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%');
-
- return decodeURIComponent(beforeAtSign + afterAtSign);
- }
-
- /**
- * Attempt to set this contact method as user's "Default contact method"
- */
- setAsDefault() {
- User.setContactMethodAsDefault(this.getContactMethod());
- }
-
- /**
- * Checks if the user is allowed to change their default contact method. This should only be allowed if:
- * 1. The viewed contact method is not already their default contact method
- * 2. The viewed contact method is validated
- * 3. If the user is on a private domain, their security group must allow primary login switching
- *
- * @returns {Boolean}
- */
- canChangeDefaultContactMethod() {
- const contactMethod = this.getContactMethod();
- const loginData = lodashGet(this.props.loginList, contactMethod, {});
- const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID;
-
- // Cannot set this contact method as default if:
- // 1. This contact method is already their default
- // 2. This contact method is not validated
- if (isDefaultContactMethod || !loginData.validatedDate) {
- return false;
- }
-
- const domainName = Str.extractEmailDomain(this.props.session.email);
- const primaryDomainSecurityGroupID = lodashGet(this.props.myDomainSecurityGroups, domainName);
-
- // If there's no security group associated with the user for the primary domain,
- // default to allowing the user to change their default contact method.
- if (!primaryDomainSecurityGroupID) {
- return true;
- }
-
- // Allow user to change their default contact method if they don't have a security group OR if their security group
- // does NOT restrict primary login switching.
- return !lodashGet(this.props.securityGroups, [`${ONYXKEYS.COLLECTION.SECURITY_GROUP}${primaryDomainSecurityGroupID}`, 'hasRestrictedPrimaryLogin'], false);
- }
-
- /**
- * Deletes the contact method if it has errors. Otherwise, it shows the confirmation alert and deletes it only if the user confirms.
- */
- deleteContactMethod() {
- if (!_.isEmpty(lodashGet(this.props.loginList, [this.getContactMethod(), 'errorFields'], {}))) {
- User.deleteContactMethod(this.getContactMethod(), this.props.loginList);
- return;
- }
- this.toggleDeleteModal(true);
- }
-
- /**
- * Toggle delete confirm modal visibility
- * @param {Boolean} isOpen
- */
- toggleDeleteModal(isOpen) {
- if (canUseTouchScreen() && isOpen) {
- InteractionManager.runAfterInteractions(() => {
- this.setState({isDeleteModalOpen: isOpen});
- });
- Keyboard.dismiss();
- } else {
- this.setState({isDeleteModalOpen: isOpen});
- }
- }
-
- /**
- * Delete the contact method and hide the modal
- */
- confirmDeleteAndHideModal() {
- this.toggleDeleteModal(false);
- User.deleteContactMethod(this.getContactMethod(), this.props.loginList);
- }
-
- render() {
- const contactMethod = this.getContactMethod();
-
- // Replacing spaces with "hard spaces" to prevent breaking the number
- const formattedContactMethod = Str.isSMSLogin(contactMethod) ? this.props.formatPhoneNumber(contactMethod).replace(/ /g, '\u00A0') : contactMethod;
-
- if (this.props.isLoadingReportData && _.isEmpty(this.props.loginList)) {
- return ;
- }
-
- const loginData = this.props.loginList[contactMethod];
- if (!contactMethod || !loginData) {
- return (
-
- Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)}
- onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)}
- />
-
- );
- }
-
- const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID;
- const hasMagicCodeBeenSent = lodashGet(this.props.loginList, [contactMethod, 'validateCodeSent'], false);
- const isFailedAddContactMethod = Boolean(lodashGet(loginData, 'errorFields.addedLogin'));
- const isFailedRemovedContactMethod = Boolean(lodashGet(loginData, 'errorFields.deletedLogin'));
-
- return (
- this.validateCodeFormRef.current && this.validateCodeFormRef.current.focus()}
- testID={ContactMethodDetailsPage.displayName}
- >
- Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)}
- />
-
- this.toggleDeleteModal(false)}
- onModalHide={() => {
- InteractionManager.runAfterInteractions(() => {
- if (!this.validateCodeFormRef.current) {
- return;
- }
- this.validateCodeFormRef.current.focusLastSelected();
- });
- }}
- prompt={this.props.translate('contacts.removeAreYouSure')}
- confirmText={this.props.translate('common.yesContinue')}
- cancelText={this.props.translate('common.cancel')}
- isVisible={this.state.isDeleteModalOpen && !isDefaultContactMethod}
- danger
- />
-
- {isFailedAddContactMethod && (
-
- )}
-
- {!loginData.validatedDate && !isFailedAddContactMethod && (
-
-
-
-
-
- )}
- {this.canChangeDefaultContactMethod() ? (
- User.clearContactMethodErrors(contactMethod, 'defaultLogin')}
- >
-
-
- ) : null}
- {isDefaultContactMethod ? (
- User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')}
- >
- {this.props.translate('contacts.yourDefaultContactMethod')}
-
- ) : (
- User.clearContactMethodErrors(contactMethod, 'deletedLogin')}
- >
- this.toggleDeleteModal(true)}
- />
-
- )}
-
-
- );
- }
-}
-
-ContactMethodDetailsPage.propTypes = propTypes;
-ContactMethodDetailsPage.defaultProps = defaultProps;
-ContactMethodDetailsPage.displayName = 'ContactMethodDetailsPage';
-
-export default compose(
- withLocalize,
- withOnyx({
- loginList: {
- key: ONYXKEYS.LOGIN_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- myDomainSecurityGroups: {
- key: ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS,
- },
- securityGroups: {
- key: `${ONYXKEYS.COLLECTION.SECURITY_GROUP}`,
- },
- isLoadingReportData: {
- key: `${ONYXKEYS.IS_LOADING_REPORT_DATA}`,
- },
- }),
- withThemeStyles,
- withTheme,
-)(ContactMethodDetailsPage);
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
new file mode 100644
index 000000000000..7de22da728dd
--- /dev/null
+++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
@@ -0,0 +1,305 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import Str from 'expensify-common/lib/str';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {InteractionManager, Keyboard, ScrollView, View} from 'react-native';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import ConfirmModal from '@components/ConfirmModal';
+import DotIndicatorMessage from '@components/DotIndicatorMessage';
+import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItem from '@components/MenuItem';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import usePrevious from '@hooks/usePrevious';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {canUseTouchScreen} from '@libs/DeviceCapabilities';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as Session from '@userActions/Session';
+import * as User from '@userActions/User';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {LoginList, SecurityGroup, Session as TSession} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import ValidateCodeForm from './ValidateCodeForm';
+import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm';
+
+type ContactMethodDetailsPageOnyxProps = {
+ /** Login list for the user that is signed in */
+ loginList: OnyxEntry;
+
+ /** Current user session */
+ session: OnyxEntry;
+
+ /** User's security group IDs by domain */
+ myDomainSecurityGroups: OnyxEntry>;
+
+ /** All of the user's security groups and their settings */
+ securityGroups: OnyxCollection;
+
+ /** Indicated whether the report data is loading */
+ isLoadingReportData: OnyxEntry;
+};
+
+type ContactMethodDetailsPageProps = ContactMethodDetailsPageOnyxProps & StackScreenProps;
+
+function ContactMethodDetailsPage({loginList, session, myDomainSecurityGroups, securityGroups, isLoadingReportData = true, route}: ContactMethodDetailsPageProps) {
+ const {formatPhoneNumber, translate} = useLocalize();
+ const theme = useTheme();
+ const themeStyles = useThemeStyles();
+
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const validateCodeFormRef = useRef(null);
+
+ /**
+ * Gets the current contact method from the route params
+ */
+ const contactMethod: string = useMemo(() => {
+ const contactMethodParam = route.params.contactMethod;
+
+ // We find the number of times the url is encoded based on the last % sign and remove them.
+ const lastPercentIndex = contactMethodParam.lastIndexOf('%');
+ const encodePercents = contactMethodParam.substring(lastPercentIndex).match(new RegExp('25', 'g'));
+ let numberEncodePercents = encodePercents?.length ?? 0;
+ const beforeAtSign = contactMethodParam.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => {
+ if (numberEncodePercents > 0) {
+ numberEncodePercents--;
+ return '%';
+ }
+ return match;
+ });
+ const afterAtSign = contactMethodParam.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%');
+
+ return decodeURIComponent(beforeAtSign + afterAtSign);
+ }, [route.params.contactMethod]);
+ const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]);
+ const isDefaultContactMethod = useMemo(() => session?.email === loginData?.partnerUserID, [session?.email, loginData?.partnerUserID]);
+
+ /**
+ * Attempt to set this contact method as user's "Default contact method"
+ */
+ const setAsDefault = useCallback(() => {
+ User.setContactMethodAsDefault(contactMethod);
+ }, [contactMethod]);
+
+ /**
+ * Checks if the user is allowed to change their default contact method. This should only be allowed if:
+ * 1. The viewed contact method is not already their default contact method
+ * 2. The viewed contact method is validated
+ * 3. If the user is on a private domain, their security group must allow primary login switching
+ */
+ const canChangeDefaultContactMethod = useMemo(() => {
+ // Cannot set this contact method as default if:
+ // 1. This contact method is already their default
+ // 2. This contact method is not validated
+ if (isDefaultContactMethod || !loginData?.validatedDate) {
+ return false;
+ }
+
+ const domainName = Str.extractEmailDomain(session?.email ?? '');
+ const primaryDomainSecurityGroupID = myDomainSecurityGroups?.[domainName];
+
+ // If there's no security group associated with the user for the primary domain,
+ // default to allowing the user to change their default contact method.
+ if (!primaryDomainSecurityGroupID) {
+ return true;
+ }
+
+ // Allow user to change their default contact method if they don't have a security group OR if their security group
+ // does NOT restrict primary login switching.
+ return !securityGroups?.[`${ONYXKEYS.COLLECTION.SECURITY_GROUP}${primaryDomainSecurityGroupID}`]?.hasRestrictedPrimaryLogin;
+ }, [isDefaultContactMethod, loginData?.validatedDate, session?.email, myDomainSecurityGroups, securityGroups]);
+
+ /**
+ * Toggle delete confirm modal visibility
+ */
+ const toggleDeleteModal = useCallback((isOpen: boolean) => {
+ if (canUseTouchScreen() && isOpen) {
+ InteractionManager.runAfterInteractions(() => {
+ setIsDeleteModalOpen(isOpen);
+ });
+ Keyboard.dismiss();
+ } else {
+ setIsDeleteModalOpen(isOpen);
+ }
+ }, []);
+
+ /**
+ * Delete the contact method and hide the modal
+ */
+ const confirmDeleteAndHideModal = useCallback(() => {
+ toggleDeleteModal(false);
+ User.deleteContactMethod(contactMethod, loginList ?? {});
+ }, [contactMethod, loginList, toggleDeleteModal]);
+
+ useEffect(() => {
+ if (isEmptyObject(loginData)) {
+ return;
+ }
+ User.resetContactMethodValidateCodeSentState(contactMethod);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const prevValidatedDate = usePrevious(loginData?.validatedDate);
+ useEffect(() => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (prevValidatedDate || !loginData?.validatedDate) {
+ return;
+ }
+
+ // If the selected contactMethod is the current session['login'] and the account is unvalidated,
+ // the current authToken is invalid after the successful magic code verification.
+ // So we need to sign out the user and redirect to the sign in page.
+ if (isDefaultContactMethod) {
+ Session.signOutAndRedirectToSignIn();
+ return;
+ }
+ // Navigate to methods page on successful magic code verification
+ // validatedDate property is responsible to decide the status of the magic code verification
+ Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route);
+ }, [prevValidatedDate, loginData?.validatedDate, isDefaultContactMethod]);
+
+ if (isLoadingReportData && isEmptyObject(loginList)) {
+ return ;
+ }
+
+ if (!contactMethod || !loginData) {
+ return (
+
+ Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)}
+ onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)}
+ />
+
+ );
+ }
+
+ // Replacing spaces with "hard spaces" to prevent breaking the number
+ const formattedContactMethod = Str.isSMSLogin(contactMethod) ? formatPhoneNumber(contactMethod).replace(/ /g, '\u00A0') : contactMethod;
+ const hasMagicCodeBeenSent = !!loginData.validateCodeSent;
+ const isFailedAddContactMethod = !!loginData.errorFields?.addedLogin;
+ const isFailedRemovedContactMethod = !!loginData.errorFields?.deletedLogin;
+
+ return (
+ validateCodeFormRef.current?.focus?.()}
+ testID={ContactMethodDetailsPage.displayName}
+ >
+ Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)}
+ />
+
+ toggleDeleteModal(false)}
+ onModalHide={() => {
+ InteractionManager.runAfterInteractions(() => {
+ validateCodeFormRef.current?.focusLastSelected?.();
+ });
+ }}
+ prompt={translate('contacts.removeAreYouSure')}
+ confirmText={translate('common.yesContinue')}
+ cancelText={translate('common.cancel')}
+ isVisible={isDeleteModalOpen && !isDefaultContactMethod}
+ danger
+ />
+
+ {isFailedAddContactMethod && (
+
+ )}
+
+ {!loginData.validatedDate && !isFailedAddContactMethod && (
+
+
+
+
+
+ )}
+ {canChangeDefaultContactMethod ? (
+ User.clearContactMethodErrors(contactMethod, 'defaultLogin')}
+ >
+
+
+ ) : null}
+ {isDefaultContactMethod ? (
+ User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')}
+ >
+ {translate('contacts.yourDefaultContactMethod')}
+
+ ) : (
+ User.clearContactMethodErrors(contactMethod, 'deletedLogin')}
+ >
+ toggleDeleteModal(true)}
+ />
+
+ )}
+
+
+ );
+}
+
+ContactMethodDetailsPage.displayName = 'ContactMethodDetailsPage';
+
+export default withOnyx({
+ loginList: {
+ key: ONYXKEYS.LOGIN_LIST,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ myDomainSecurityGroups: {
+ key: ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS,
+ },
+ securityGroups: {
+ key: `${ONYXKEYS.COLLECTION.SECURITY_GROUP}`,
+ },
+ isLoadingReportData: {
+ key: `${ONYXKEYS.IS_LOADING_REPORT_DATA}`,
+ },
+})(ContactMethodDetailsPage);
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
similarity index 53%
rename from src/pages/settings/Profile/Contacts/ContactMethodsPage.js
rename to src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
index c85d123ad3fd..5d150e782c44 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js
+++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
@@ -1,10 +1,9 @@
+import type {StackScreenProps} from '@react-navigation/stack';
import Str from 'expensify-common/lib/str';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
import {ScrollView, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import Button from '@components/Button';
import CopyTextToClipboard from '@components/CopyTextToClipboard';
import FixedFooter from '@components/FixedFooter';
@@ -13,86 +12,64 @@ import MenuItem from '@components/MenuItem';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import {translatableTextPropTypes} from '@libs/Localize';
import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {LoginList, Session} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
-const propTypes = {
- /* Onyx Props */
-
+type ContactMethodsPageOnyxProps = {
/** Login list for the user that is signed in */
- loginList: PropTypes.shape({
- /** The partner creating the account. It depends on the source: website, mobile, integrations, ... */
- partnerName: PropTypes.string,
-
- /** Phone/Email associated with user */
- partnerUserID: PropTypes.string,
-
- /** The date when the login was validated, used to show the brickroad status */
- validatedDate: PropTypes.string,
-
- /** Field-specific server side errors keyed by microtime */
- errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)),
-
- /** Field-specific pending states for offline UI status */
- pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
- }),
+ loginList: OnyxEntry;
/** Current user session */
- session: PropTypes.shape({
- email: PropTypes.string.isRequired,
- }),
-
- ...withLocalizePropTypes,
+ session: OnyxEntry;
};
-const defaultProps = {
- loginList: {},
- session: {
- email: null,
- },
-};
+type ContactMethodsPageProps = ContactMethodsPageOnyxProps & StackScreenProps;
-function ContactMethodsPage(props) {
+function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps) {
const styles = useThemeStyles();
- const loginNames = _.keys(props.loginList);
- const navigateBackTo = lodashGet(props.route, 'params.backTo', '');
+ const {formatPhoneNumber, translate} = useLocalize();
+ const loginNames = Object.keys(loginList ?? {});
+ const navigateBackTo = route?.params?.backTo || ROUTES.SETTINGS_PROFILE;
// Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods.
// The default contact method is determined by checking against the session email (the current login).
- const sortedLoginNames = _.sortBy(loginNames, (loginName) => (props.loginList[loginName].partnerUserID === props.session.email ? 0 : 1));
+ const sortedLoginNames = loginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1));
- const loginMenuItems = _.map(sortedLoginNames, (loginName) => {
- const login = props.loginList[loginName];
- const pendingAction = lodashGet(login, 'pendingFields.deletedLogin') || lodashGet(login, 'pendingFields.addedLogin');
- if (!login.partnerUserID && _.isEmpty(pendingAction)) {
+ const loginMenuItems = sortedLoginNames.map((loginName) => {
+ const login = loginList?.[loginName];
+ const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined;
+ if (!login?.partnerUserID && !pendingAction) {
return null;
}
let description = '';
- if (props.session.email === login.partnerUserID) {
- description = props.translate('contacts.getInTouch');
- } else if (lodashGet(login, 'errorFields.addedLogin')) {
- description = props.translate('contacts.failedNewContact');
- } else if (!login.validatedDate) {
- description = props.translate('contacts.pleaseVerify');
+ if (session?.email === login?.partnerUserID) {
+ description = translate('contacts.getInTouch');
+ } else if (login?.errorFields?.addedLogin) {
+ description = translate('contacts.failedNewContact');
+ } else if (!login?.validatedDate) {
+ description = translate('contacts.pleaseVerify');
}
- let indicator = null;
- if (_.some(lodashGet(login, 'errorFields', {}), (errorField) => !_.isEmpty(errorField))) {
+ let indicator;
+ if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
- } else if (!login.validatedDate) {
+ } else if (!login?.validatedDate) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
}
// Default to using login key if we deleted login.partnerUserID optimistically
// but still need to show the pending login being deleted while offline.
- const partnerUserID = login.partnerUserID || loginName;
- const menuItemTitle = Str.isSMSLogin(partnerUserID) ? props.formatPhoneNumber(partnerUserID) : partnerUserID;
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const partnerUserID = login?.partnerUserID || loginName;
+ const menuItemTitle = Str.isSMSLogin(partnerUserID) ? formatPhoneNumber(partnerUserID) : partnerUserID;
return (
);
@@ -126,25 +103,25 @@ function ContactMethodsPage(props) {
testID={ContactMethodsPage.displayName}
>
Navigation.goBack(navigateBackTo)}
/>
- {props.translate('contacts.helpTextBeforeEmail')}
+ {translate('contacts.helpTextBeforeEmail')}
- {props.translate('contacts.helpTextAfterEmail')}
+ {translate('contacts.helpTextAfterEmail')}
{loginMenuItems}
@@ -154,18 +131,13 @@ function ContactMethodsPage(props) {
);
}
-ContactMethodsPage.propTypes = propTypes;
-ContactMethodsPage.defaultProps = defaultProps;
ContactMethodsPage.displayName = 'ContactMethodsPage';
-export default compose(
- withLocalize,
- withOnyx({
- loginList: {
- key: ONYXKEYS.LOGIN_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- }),
-)(ContactMethodsPage);
+export default withOnyx({
+ loginList: {
+ key: ONYXKEYS.LOGIN_LIST,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+})(ContactMethodsPage);
diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
similarity index 56%
rename from src/pages/settings/Profile/Contacts/NewContactMethodPage.js
rename to src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
index b9d5dee8f4be..20e12f71664e 100644
--- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js
+++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
@@ -1,57 +1,40 @@
+import type {StackScreenProps} from '@react-navigation/stack';
import Str from 'expensify-common/lib/str';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback, useRef} from 'react';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
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 type {AnimatedTextInputRef} from '@components/RNTextInput';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
-import {translatableTextPropTypes} from '@libs/Localize';
import * as LoginUtils from '@libs/LoginUtils';
import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import * as User from '@userActions/User';
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/NewContactMethodForm';
+import type {LoginList} from '@src/types/onyx';
+import type {Errors} from '@src/types/onyx/OnyxCommon';
-const propTypes = {
- /* Onyx Props */
-
+type NewContactMethodPageOnyxProps = {
/** Login list for the user that is signed in */
- loginList: PropTypes.shape({
- /** The partner creating the account. It depends on the source: website, mobile, integrations, ... */
- partnerName: PropTypes.string,
-
- /** Phone/Email associated with user */
- partnerUserID: PropTypes.string,
-
- /** The date when the login was validated, used to show the brickroad status */
- validatedDate: PropTypes.string,
-
- /** Field-specific server side errors keyed by microtime */
- errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)),
-
- /** Field-specific pending states for offline UI status */
- pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
- }),
-
- ...withLocalizePropTypes,
-};
-const defaultProps = {
- loginList: {},
+ loginList: OnyxEntry;
};
-const addNewContactMethod = (values) => {
+type NewContactMethodPageProps = NewContactMethodPageOnyxProps & StackScreenProps;
+
+const addNewContactMethod = (values: FormOnyxValues) => {
const phoneLogin = LoginUtils.getPhoneLogin(values.phoneOrEmail);
const validateIfnumber = LoginUtils.validateNumber(phoneLogin);
const submitDetail = (validateIfnumber || values.phoneOrEmail).trim().toLowerCase();
@@ -59,35 +42,36 @@ const addNewContactMethod = (values) => {
User.addNewContactMethodAndNavigate(submitDetail);
};
-function NewContactMethodPage(props) {
+function NewContactMethodPage({loginList, route}: NewContactMethodPageProps) {
const styles = useThemeStyles();
- const loginInputRef = useRef(null);
+ const {translate} = useLocalize();
+ const loginInputRef = useRef(null);
- const navigateBackTo = lodashGet(props.route, 'params.backTo', ROUTES.SETTINGS_PROFILE);
+ const navigateBackTo = route?.params?.backTo ?? ROUTES.SETTINGS_PROFILE;
const validate = React.useCallback(
- (values) => {
+ (values: FormOnyxValues): Errors => {
const phoneLogin = LoginUtils.getPhoneLogin(values.phoneOrEmail);
const validateIfnumber = LoginUtils.validateNumber(phoneLogin);
const errors = {};
- if (_.isEmpty(values.phoneOrEmail)) {
+ if (!values.phoneOrEmail) {
ErrorUtils.addErrorMessage(errors, 'phoneOrEmail', 'contacts.genericFailureMessages.contactMethodRequired');
}
- if (!_.isEmpty(values.phoneOrEmail) && !(validateIfnumber || Str.isValidEmail(values.phoneOrEmail))) {
+ if (!!values.phoneOrEmail && !(validateIfnumber || Str.isValidEmail(values.phoneOrEmail))) {
ErrorUtils.addErrorMessage(errors, 'phoneOrEmail', 'contacts.genericFailureMessages.invalidContactMethod');
}
- if (!_.isEmpty(values.phoneOrEmail) && lodashGet(props.loginList, validateIfnumber || values.phoneOrEmail.toLowerCase())) {
+ if (!!values.phoneOrEmail && loginList?.[validateIfnumber || values.phoneOrEmail.toLowerCase()]) {
ErrorUtils.addErrorMessage(errors, 'phoneOrEmail', 'contacts.genericFailureMessages.enteredMethodIsAlreadySubmited');
}
return errors;
},
- // We don't need `props.loginList` because when submitting this form
- // the props.loginList gets updated, causing this function to run again.
+ // We don't need `loginList` because when submitting this form
+ // the loginList gets updated, causing this function to run again.
// https://github.com/Expensify/App/issues/20610
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
@@ -103,38 +87,32 @@ function NewContactMethodPage(props) {
return (
{
- if (!loginInputRef.current) {
- return;
- }
-
- loginInputRef.current.focus();
- }}
+ onEntryTransitionEnd={() => loginInputRef.current?.focus()}
includeSafeAreaPaddingBottom={false}
shouldEnableMaxHeight
testID={NewContactMethodPage.displayName}
>
- {props.translate('common.pleaseEnterEmailOrPhoneNumber')}
-
+ {translate('common.pleaseEnterEmailOrPhoneNumber')}
+
(loginInputRef.current = el)}
+ ref={loginInputRef}
inputID={INPUT_IDS.PHONE_OR_EMAIL}
autoCapitalize="none"
enterKeyHint="done"
@@ -146,13 +124,8 @@ function NewContactMethodPage(props) {
);
}
-NewContactMethodPage.propTypes = propTypes;
-NewContactMethodPage.defaultProps = defaultProps;
NewContactMethodPage.displayName = 'NewContactMethodPage';
-export default compose(
- withLocalize,
- withOnyx({
- loginList: {key: ONYXKEYS.LOGIN_LIST},
- }),
-)(NewContactMethodPage);
+export default withOnyx({
+ loginList: {key: ONYXKEYS.LOGIN_LIST},
+})(NewContactMethodPage);
diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.tsx
similarity index 56%
rename from src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
rename to src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.tsx
index 5c1fa30a88f1..adf2680549c7 100644
--- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -1,95 +1,82 @@
import {useFocusEffect} from '@react-navigation/native';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import type {ForwardedRef} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import Button from '@components/Button';
import DotIndicatorMessage from '@components/DotIndicatorMessage';
import MagicCodeInput from '@components/MagicCodeInput';
+import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
-import {withNetwork} from '@components/OnyxProvider';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
-import {translatableTextPropTypes} from '@libs/Localize';
import * as ValidationUtils from '@libs/ValidationUtils';
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 type {Account, LoginList} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
-const propTypes = {
- ...withLocalizePropTypes,
+type ValidateCodeFormHandle = {
+ focus: () => void;
+ focusLastSelected: () => void;
+};
+
+type ValidateCodeFormError = {
+ validateCode?: TranslationPaths;
+};
+
+type BaseValidateCodeFormOnyxProps = {
+ /** The details about the account that the user is signing in with */
+ account: OnyxEntry;
+};
+type ValidateCodeFormProps = {
/** The contact method being valdiated */
- contactMethod: PropTypes.string.isRequired,
+ contactMethod: string;
/** If the magic code has been resent previously */
- hasMagicCodeBeenSent: PropTypes.bool.isRequired,
+ hasMagicCodeBeenSent: boolean;
/** Login list for the user that is signed in */
- loginList: PropTypes.shape({
- /** Value of partner name */
- partnerName: PropTypes.string,
-
- /** Phone/Email associated with user */
- partnerUserID: PropTypes.string,
-
- /** Date when login was validated */
- validatedDate: PropTypes.string,
+ loginList: LoginList;
- /** Field-specific server side errors keyed by microtime */
- errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)),
-
- /** Field-specific pending states for offline UI status */
- pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
- }).isRequired,
+ /** Specifies autocomplete hints for the system, so it can provide autofill */
+ autoComplete?: AutoCompleteVariant;
/** Forwarded inner ref */
- innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
-
- /* Onyx Props */
-
- /** The details about the account that the user is signing in with */
- account: PropTypes.shape({
- /** Whether or not a sign on form is loading (being submitted) */
- isLoading: PropTypes.bool,
- }),
-
- /** Specifies autocomplete hints for the system, so it can provide autofill */
- autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired,
+ innerRef?: ForwardedRef;
};
-const defaultProps = {
- account: {},
- innerRef: () => {},
-};
+type BaseValidateCodeFormProps = BaseValidateCodeFormOnyxProps & ValidateCodeFormProps;
-function BaseValidateCodeForm(props) {
+function BaseValidateCodeForm({account = {}, contactMethod, hasMagicCodeBeenSent, loginList, autoComplete = 'one-time-code', innerRef = () => {}}: BaseValidateCodeFormProps) {
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const [formError, setFormError] = useState({});
+ const [formError, setFormError] = useState({});
const [validateCode, setValidateCode] = useState('');
- const loginData = props.loginList[props.contactMethod];
- const inputValidateCodeRef = useRef();
+ const loginData = loginList[contactMethod];
+ const inputValidateCodeRef = useRef(null);
const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin');
- const shouldDisableResendValidateCode = props.network.isOffline || props.account.isLoading;
- const focusTimeoutRef = useRef(null);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case
+ const shouldDisableResendValidateCode = !!isOffline || account?.isLoading;
+ const focusTimeoutRef = useRef(null);
- useImperativeHandle(props.innerRef, () => ({
+ useImperativeHandle(innerRef, () => ({
focus() {
- if (!inputValidateCodeRef.current) {
- return;
- }
- inputValidateCodeRef.current.focus();
+ inputValidateCodeRef.current?.focus();
},
focusLastSelected() {
if (!inputValidateCodeRef.current) {
@@ -98,7 +85,9 @@ function BaseValidateCodeForm(props) {
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
- focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focusLastSelected, CONST.ANIMATED_TRANSITION);
+ focusTimeoutRef.current = setTimeout(() => {
+ inputValidateCodeRef.current?.focusLastSelected();
+ }, CONST.ANIMATED_TRANSITION);
},
}));
@@ -110,7 +99,9 @@ function BaseValidateCodeForm(props) {
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
- focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focusLastSelected, CONST.ANIMATED_TRANSITION);
+ focusTimeoutRef.current = setTimeout(() => {
+ inputValidateCodeRef.current?.focusLastSelected();
+ }, CONST.ANIMATED_TRANSITION);
return () => {
if (!focusTimeoutRef.current) {
return;
@@ -125,41 +116,39 @@ function BaseValidateCodeForm(props) {
if (!validateLoginError) {
return;
}
- User.clearContactMethodErrors(props.contactMethod, 'validateLogin');
+ User.clearContactMethodErrors(contactMethod, 'validateLogin');
// contactMethod is not added as a dependency since it does not change between renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
- if (!props.hasMagicCodeBeenSent) {
+ if (!hasMagicCodeBeenSent) {
return;
}
- inputValidateCodeRef.current.clear();
- }, [props.hasMagicCodeBeenSent]);
+ inputValidateCodeRef.current?.clear();
+ }, [hasMagicCodeBeenSent]);
/**
* Request a validate code / magic code be sent to verify this contact method
*/
const resendValidateCode = () => {
- User.requestContactMethodValidateCode(props.contactMethod);
- inputValidateCodeRef.current.clear();
+ User.requestContactMethodValidateCode(contactMethod);
+ inputValidateCodeRef.current?.clear();
};
/**
* Handle text input and clear formError upon text change
- *
- * @param {String} text
*/
const onTextInput = useCallback(
- (text) => {
+ (text: string) => {
setValidateCode(text);
setFormError({});
if (validateLoginError) {
- User.clearContactMethodErrors(props.contactMethod, 'validateLogin');
+ User.clearContactMethodErrors(contactMethod, 'validateLogin');
}
},
- [validateLoginError, props.contactMethod],
+ [validateLoginError, contactMethod],
);
/**
@@ -177,28 +166,27 @@ function BaseValidateCodeForm(props) {
}
setFormError({});
- User.validateSecondaryLogin(props.contactMethod, validateCode);
- }, [validateCode, props.contactMethod]);
+ User.validateSecondaryLogin(contactMethod, validateCode);
+ }, [validateCode, contactMethod]);
return (
<>
User.clearContactMethodErrors(props.contactMethod, 'validateCodeSent')}
+ onClose={() => User.clearContactMethodErrors(contactMethod, 'validateCodeSent')}
>
- {props.translate('validateCodeForm.magicCodeNotReceived')}
+ {translate('validateCodeForm.magicCodeNotReceived')}
- {props.hasMagicCodeBeenSent && (
+ {hasMagicCodeBeenSent && (
)}
User.clearContactMethodErrors(props.contactMethod, 'validateLogin')}
+ onClose={() => User.clearContactMethodErrors(contactMethod, 'validateLogin')}
>
>
);
}
-BaseValidateCodeForm.propTypes = propTypes;
-BaseValidateCodeForm.defaultProps = defaultProps;
BaseValidateCodeForm.displayName = 'BaseValidateCodeForm';
-export default compose(
- withLocalize,
- withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
- }),
- withNetwork(),
-)(BaseValidateCodeForm);
+export type {ValidateCodeFormProps, ValidateCodeFormHandle};
+
+export default withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+})(BaseValidateCodeForm);
diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.tsx
similarity index 61%
rename from src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.js
rename to src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.tsx
index a193dc8d2eae..704405f93a2c 100644
--- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.js
+++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.tsx
@@ -1,7 +1,8 @@
import React, {forwardRef} from 'react';
import BaseValidateCodeForm from './BaseValidateCodeForm';
+import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm';
-const ValidateCodeForm = forwardRef((props, ref) => (
+const ValidateCodeForm = forwardRef((props, ref) => (
(
/>
));
-ValidateCodeForm.displayName = 'ValidateCodeForm';
-
export default ValidateCodeForm;
diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.tsx
similarity index 62%
rename from src/pages/settings/Profile/Contacts/ValidateCodeForm/index.js
rename to src/pages/settings/Profile/Contacts/ValidateCodeForm/index.tsx
index bb4e5ed36b47..453fc9c3f373 100644
--- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.js
+++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.tsx
@@ -1,7 +1,8 @@
import React, {forwardRef} from 'react';
import BaseValidateCodeForm from './BaseValidateCodeForm';
+import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm';
-const ValidateCodeForm = forwardRef((props, ref) => (
+const ValidateCodeForm = forwardRef((props, ref) => (
(
/>
));
-ValidateCodeForm.displayName = 'ValidateCodeForm';
-
export default ValidateCodeForm;
diff --git a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js
index 61208447495d..290d6431492d 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js
+++ b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js
@@ -8,7 +8,7 @@ import FormProvider from '@components/Form/FormProvider';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import ScreenWrapper from '@components/ScreenWrapper';
-import BaseListItem from '@components/SelectionList/BaseListItem';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import Text from '@components/Text';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps} from '@components/withCurrentUserPersonalDetails';
import withLocalize from '@components/withLocalize';
@@ -156,10 +156,9 @@ function StatusClearAfterPage({currentUserPersonalDetails, customStatus}) {
const timePeriodOptions = useCallback(
() =>
- _.map(statusType, (item, index) => (
- (
+ updateMode(item)}
showTooltip={false}
isFocused={item.isSelected}
diff --git a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js
index 38c4d1eac449..d8327041538d 100644
--- a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js
@@ -5,6 +5,7 @@ import _ from 'underscore';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
import searchCountryOptions from '@libs/searchCountryOptions';
@@ -93,6 +94,7 @@ function CountrySelectionPage({route, navigation}) {
textInputLabel={translate('common.country')}
textInputValue={searchValue}
sections={[{data: searchResults, indexOffset: 0}]}
+ ListItem={RadioListItem}
onSelectRow={selectCountry}
onChangeText={setSearchValue}
initiallyFocusedOptionKey={currentCountry}
diff --git a/src/pages/settings/Profile/PronounsPage.js b/src/pages/settings/Profile/PronounsPage.js
index 5bb528373e8f..1d4675a42b8a 100644
--- a/src/pages/settings/Profile/PronounsPage.js
+++ b/src/pages/settings/Profile/PronounsPage.js
@@ -7,6 +7,7 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import Text from '@components/Text';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
@@ -100,6 +101,7 @@ function PronounsPage({currentUserPersonalDetails, isLoadingApp}) {
textInputPlaceholder={translate('pronounsPage.placeholderText')}
textInputValue={searchValue}
sections={[{data: filteredPronounsList, indexOffset: 0}]}
+ ListItem={RadioListItem}
onSelectRow={updatePronouns}
onChangeText={setSearchValue}
initiallyFocusedOptionKey={currentPronounsKey}
diff --git a/src/pages/settings/Profile/TimezoneSelectPage.js b/src/pages/settings/Profile/TimezoneSelectPage.js
index 8280d9b5c604..b6c8a5967abc 100644
--- a/src/pages/settings/Profile/TimezoneSelectPage.js
+++ b/src/pages/settings/Profile/TimezoneSelectPage.js
@@ -4,6 +4,7 @@ import _ from 'underscore';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
import useInitialValue from '@hooks/useInitialValue';
import useLocalize from '@hooks/useLocalize';
@@ -97,6 +98,7 @@ function TimezoneSelectPage(props) {
initiallyFocusedOptionKey={_.get(_.filter(timezoneOptions, (tz) => tz.text === timezone.selected)[0], 'keyForList')}
showScrollIndicator
shouldShowTooltips={false}
+ ListItem={RadioListItem}
/>
);
diff --git a/src/pages/settings/Report/NotificationPreferencePage.tsx b/src/pages/settings/Report/NotificationPreferencePage.tsx
index 05f3483f7ce8..3977bdd0233d 100644
--- a/src/pages/settings/Report/NotificationPreferencePage.tsx
+++ b/src/pages/settings/Report/NotificationPreferencePage.tsx
@@ -4,6 +4,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import * as ReportUtils from '@libs/ReportUtils';
import type {ReportSettingsNavigatorParamList} from '@navigation/types';
@@ -39,6 +40,7 @@ function NotificationPreferencePage({report}: NotificationPreferencePageProps) {
/>
report && ReportActions.updateNotificationPreference(report.reportID, report.notificationPreference, option.value, true, undefined, undefined, report)
}
diff --git a/src/pages/settings/Report/ReportSettingsPage.tsx b/src/pages/settings/Report/ReportSettingsPage.tsx
index 613dcd460e26..d738fc7ac3cf 100644
--- a/src/pages/settings/Report/ReportSettingsPage.tsx
+++ b/src/pages/settings/Report/ReportSettingsPage.tsx
@@ -43,6 +43,7 @@ function ReportSettingsPage({report, policies}: ReportSettingsPageProps) {
const writeCapabilityText = translate(`writeCapabilityPage.writeCapability.${writeCapability}`);
const shouldAllowWriteCapabilityEditing = useMemo(() => ReportUtils.canEditWriteCapability(report, linkedWorkspace), [report, linkedWorkspace]);
+ const shouldAllowChangeVisibility = useMemo(() => ReportUtils.canEditRoomVisibility(report, linkedWorkspace), [report, linkedWorkspace]);
const shouldShowNotificationPref = !isMoneyRequestReport && report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
const roomNameLabel = translate(isMoneyRequestReport ? 'workspace.editor.nameInputLabel' : 'newRoomPage.roomName');
@@ -141,8 +142,17 @@ function ReportSettingsPage({report, policies}: ReportSettingsPageProps) {
/>
)}
- {report?.visibility !== undefined && (
-
+
+ {report?.visibility !== undefined &&
+ (shouldAllowChangeVisibility ? (
+ Navigation.navigate(ROUTES.REPORT_SETTINGS_VISIBILITY.getRoute(report.reportID))}
+ />
+ ) : (
+
{translate(`newRoomPage.${report.visibility}Description`)}
- )}
-
+ ))}
diff --git a/src/pages/settings/Report/VisibilityPage.tsx b/src/pages/settings/Report/VisibilityPage.tsx
new file mode 100644
index 000000000000..d3b8b2656d50
--- /dev/null
+++ b/src/pages/settings/Report/VisibilityPage.tsx
@@ -0,0 +1,98 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback, useMemo, useState} from 'react';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import ConfirmModal from '@components/ConfirmModal';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import useLocalize from '@hooks/useLocalize';
+import type {ReportSettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as ReportUtils from '@libs/ReportUtils';
+import type {WithReportOrNotFoundProps} from '@pages/home/report/withReportOrNotFound';
+import withReportOrNotFound from '@pages/home/report/withReportOrNotFound';
+import * as ReportActions from '@userActions/Report';
+import CONST from '@src/CONST';
+import type SCREENS from '@src/SCREENS';
+import type {RoomVisibility} from '@src/types/onyx/Report';
+
+type VisibilityProps = WithReportOrNotFoundProps & StackScreenProps;
+
+function VisibilityPage({report}: VisibilityProps) {
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
+
+ const shouldDisableVisibility = ReportUtils.isArchivedRoom(report);
+ const {translate} = useLocalize();
+
+ const visibilityOptions = useMemo(
+ () =>
+ Object.values(CONST.REPORT.VISIBILITY)
+ .filter((visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE)
+ .map((visibilityOption) => ({
+ text: translate(`newRoomPage.visibilityOptions.${visibilityOption}`),
+ value: visibilityOption,
+ alternateText: translate(`newRoomPage.${visibilityOption}Description`),
+ keyForList: visibilityOption,
+ isSelected: visibilityOption === report?.visibility,
+ })),
+ [translate, report?.visibility],
+ );
+
+ const changeVisibility = useCallback(
+ (newVisibility: RoomVisibility) => {
+ if (!report) {
+ return;
+ }
+ ReportActions.updateRoomVisibility(report.reportID, report.visibility, newVisibility, true, report);
+ },
+ [report],
+ );
+
+ const hideModal = useCallback(() => {
+ setShowConfirmModal(false);
+ }, []);
+
+ return (
+
+
+ ReportUtils.goBackToDetailsPage(report)}
+ />
+ {
+ if (option.value === CONST.REPORT.VISIBILITY.PUBLIC) {
+ setShowConfirmModal(true);
+ return;
+ }
+ changeVisibility(option.value);
+ }}
+ initiallyFocusedOptionKey={visibilityOptions.find((visibility) => visibility.isSelected)?.keyForList}
+ ListItem={RadioListItem}
+ />
+ {
+ changeVisibility(CONST.REPORT.VISIBILITY.PUBLIC);
+ hideModal();
+ }}
+ onCancel={hideModal}
+ title={translate('common.areYouSure')}
+ prompt={translate('newRoomPage.publicDescription')}
+ confirmText={translate('common.yes')}
+ cancelText={translate('common.no')}
+ danger
+ />
+
+
+ );
+}
+
+VisibilityPage.displayName = 'VisibilityPage';
+
+export default withReportOrNotFound()(VisibilityPage);
diff --git a/src/pages/settings/Report/WriteCapabilityPage.tsx b/src/pages/settings/Report/WriteCapabilityPage.tsx
index 5f5fe73e5199..1f991ef87c9a 100644
--- a/src/pages/settings/Report/WriteCapabilityPage.tsx
+++ b/src/pages/settings/Report/WriteCapabilityPage.tsx
@@ -6,6 +6,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
@@ -52,6 +53,7 @@ function WriteCapabilityPage({report, policy}: WriteCapabilityPageProps) {
/>
report && ReportActions.updateWriteCapabilityAndNavigate(report, option.value)}
initiallyFocusedOptionKey={writeCapabilityOptions.find((locale) => locale.isSelected)?.keyForList}
/>
diff --git a/src/pages/settings/Security/CloseAccountPage.tsx b/src/pages/settings/Security/CloseAccountPage.tsx
index 84934aa01089..bcf65ecfe837 100644
--- a/src/pages/settings/Security/CloseAccountPage.tsx
+++ b/src/pages/settings/Security/CloseAccountPage.tsx
@@ -67,16 +67,16 @@ function CloseAccountPage({session}: CloseAccountPageProps) {
const sanitizePhoneOrEmail = (phoneOrEmail: string): string => phoneOrEmail.replace(/\s+/g, '').toLowerCase();
const validate = (values: FormOnyxValues): FormInputErrors => {
- const userEmailOrPhone = formatPhoneNumber(session?.email);
+ const userEmailOrPhone = session?.email ? formatPhoneNumber(session.email) : null;
const errors = ValidationUtils.getFieldRequiredErrors(values, ['phoneOrEmail']);
- if (values.phoneOrEmail && sanitizePhoneOrEmail(userEmailOrPhone) !== sanitizePhoneOrEmail(values.phoneOrEmail)) {
+ if (values.phoneOrEmail && userEmailOrPhone && sanitizePhoneOrEmail(userEmailOrPhone) !== sanitizePhoneOrEmail(values.phoneOrEmail)) {
errors.phoneOrEmail = 'closeAccountPage.enterYourDefaultContactMethod';
}
return errors;
};
- const userEmailOrPhone = formatPhoneNumber(session?.email);
+ const userEmailOrPhone = session?.email ? formatPhoneNumber(session.email) : null;
return (
>;
};
-const defaultProps = {
- cardList: {},
-};
+type ActivatePhysicalCardPageProps = ActivatePhysicalCardPageOnyxProps & StackScreenProps;
const LAST_FOUR_DIGITS_LENGTH = 4;
const MAGIC_INPUT_MIN_HEIGHT = 86;
@@ -52,9 +42,9 @@ const MAGIC_INPUT_MIN_HEIGHT = 86;
function ActivatePhysicalCardPage({
cardList,
route: {
- params: {domain},
+ params: {domain = ''},
},
-}) {
+}: ActivatePhysicalCardPageProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {isExtraSmallScreenHeight} = useWindowDimensions();
@@ -65,23 +55,24 @@ function ActivatePhysicalCardPage({
const [lastFourDigits, setLastFourDigits] = useState('');
const [lastPressedDigit, setLastPressedDigit] = useState('');
- const domainCards = CardUtils.getDomainCards(cardList)[domain];
- const physicalCard = _.find(domainCards, (card) => !card.isVirtual) || {};
- const cardID = lodashGet(physicalCard, 'cardID', 0);
- const cardError = ErrorUtils.getLatestErrorMessage(physicalCard);
+ const domainCards = CardUtils.getDomainCards(cardList)[domain] ?? [];
+ const physicalCard = domainCards.find((card) => !card.isVirtual);
+ const cardID = physicalCard?.cardID ?? 0;
+ const cardError = ErrorUtils.getLatestErrorMessage(physicalCard ?? {});
- const activateCardCodeInputRef = useRef(null);
+ const activateCardCodeInputRef = useRef(null);
/**
* If state of the card is CONST.EXPENSIFY_CARD.STATE.OPEN, navigate to card details screen.
*/
useEffect(() => {
- if (physicalCard.isLoading || lodashGet(cardList, `${cardID}.state`, 0) !== CONST.EXPENSIFY_CARD.STATE.OPEN) {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (physicalCard?.isLoading || cardList?.[cardID]?.state !== CONST.EXPENSIFY_CARD.STATE.OPEN) {
return;
}
Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain));
- }, [cardID, cardList, domain, physicalCard.isLoading]);
+ }, [cardID, cardList, domain, physicalCard?.isLoading]);
useEffect(
() => () => {
@@ -95,17 +86,13 @@ function ActivatePhysicalCardPage({
*
* NOTE: If the same digit is pressed twice in a row, append it to the end of the string
* so that useEffect inside MagicCodeInput will be triggered by artificial change of the value.
- *
- * @param {String} key
*/
- const updateLastPressedDigit = useCallback((key) => setLastPressedDigit(lastPressedDigit === key ? lastPressedDigit + key : key), [lastPressedDigit]);
+ const updateLastPressedDigit = useCallback((key: string) => setLastPressedDigit(lastPressedDigit === key ? lastPressedDigit + key : key), [lastPressedDigit]);
/**
* Handle card activation code input
- *
- * @param {String} text
*/
- const onCodeInput = (text) => {
+ const onCodeInput = (text: string) => {
setFormError('');
if (cardError) {
@@ -116,7 +103,7 @@ function ActivatePhysicalCardPage({
};
const submitAndNavigateToNextPage = useCallback(() => {
- activateCardCodeInputRef.current.blur();
+ activateCardCodeInputRef.current?.blur();
if (lastFourDigits.replace(CONST.MAGIC_CODE_EMPTY_CHAR, '').length !== LAST_FOUR_DIGITS_LENGTH) {
setFormError('activateCardPage.error.thatDidntMatch');
@@ -126,7 +113,7 @@ function ActivatePhysicalCardPage({
CardSettings.activatePhysicalExpensifyCard(lastFourDigits, cardID);
}, [lastFourDigits, cardID]);
- if (_.isEmpty(physicalCard)) {
+ if (isEmptyObject(physicalCard)) {
return ;
}
@@ -161,7 +148,7 @@ function ActivatePhysicalCardPage({
- {hasDetectedDomainFraud ? (
+ {hasDetectedDomainFraud && (
- ) : null}
+ )}
- {hasDetectedIndividualFraud && !hasDetectedDomainFraud ? (
+ {hasDetectedIndividualFraud && !hasDetectedDomainFraud && (
<>
Link.openOldDotLink(CONST.OLDDOT_URLS.INBOX)}
/>
>
- ) : null}
+ )}
- {!hasDetectedDomainFraud ? (
+ {!hasDetectedDomainFraud && (
<>
- {!_.isEmpty(virtualCard) && (
+ {!isEmptyObject(virtualCard) && (
<>
- {details.pan ? (
+ {details?.pan ? (
) : (
@@ -251,7 +192,7 @@ function ExpensifyCardPage({
}
/>
@@ -266,11 +207,11 @@ function ExpensifyCardPage({
/>
>
)}
- {physicalCard.state === CONST.EXPENSIFY_CARD.STATE.OPEN && (
+ {physicalCard?.state === CONST.EXPENSIFY_CARD.STATE.OPEN && (
<>
@@ -283,9 +224,9 @@ function ExpensifyCardPage({
>
)}
>
- ) : null}
+ )}
- {physicalCard.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED && (
+ {physicalCard?.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED && (
)}
- {physicalCard.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED && (
+ {physicalCard?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED && (
);
}
-RedDotCardSection.propTypes = propTypes;
RedDotCardSection.displayName = 'RedDotCardSection';
export default RedDotCardSection;
diff --git a/src/pages/settings/Wallet/ReportCardLostPage.js b/src/pages/settings/Wallet/ReportCardLostPage.tsx
similarity index 76%
rename from src/pages/settings/Wallet/ReportCardLostPage.js
rename to src/pages/settings/Wallet/ReportCardLostPage.tsx
index b78c0b6bdc21..f53ebfb65e3f 100644
--- a/src/pages/settings/Wallet/ReportCardLostPage.js
+++ b/src/pages/settings/Wallet/ReportCardLostPage.tsx
@@ -1,8 +1,8 @@
-import PropTypes from 'prop-types';
+import type {StackScreenProps} from '@react-navigation/stack';
import React, {useEffect, useState} from 'react';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
@@ -15,21 +15,32 @@ import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails';
import useThemeStyles from '@hooks/useThemeStyles';
import * as CardUtils from '@libs/CardUtils';
import Navigation from '@libs/Navigation/Navigation';
+import type {PublicScreensParamList} from '@libs/Navigation/types';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import type {ReplacementReason} from '@userActions/Card';
import * as CardActions from '@userActions/Card';
import * as FormActions from '@userActions/FormActions';
+import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import assignedCardPropTypes from './assignedCardPropTypes';
+import type SCREENS from '@src/SCREENS';
+import type {ReportPhysicalCardForm} from '@src/types/form';
+import type {Card, PrivatePersonalDetails} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
const OPTIONS_KEYS = {
DAMAGED: 'damaged',
STOLEN: 'stolen',
};
+type Option = {
+ key: string;
+ label: TranslationPaths;
+};
+
/** Options for reason selector */
-const OPTIONS = [
+const OPTIONS: Option[] = [
{
key: OPTIONS_KEYS.DAMAGED,
label: 'reportCardLostOrDamaged.cardDamaged',
@@ -40,36 +51,21 @@ const OPTIONS = [
},
];
-const propTypes = {
+type ReportCardLostPageOnyxProps = {
/** Onyx form data */
- formData: PropTypes.shape({
- isLoading: PropTypes.bool,
- }),
+ formData: OnyxEntry;
+
/** User's private personal details */
- privatePersonalDetails: PropTypes.shape({
- /** User's home address */
- address: PropTypes.shape({
- street: PropTypes.string,
- city: PropTypes.string,
- state: PropTypes.string,
- zip: PropTypes.string,
- country: PropTypes.string,
- }),
- }),
+ privatePersonalDetails: OnyxEntry;
+
/** User's cards list */
- cardList: PropTypes.objectOf(assignedCardPropTypes),
- route: PropTypes.shape({
- /** Each parameter passed via the URL */
- params: PropTypes.shape({
- /** Domain string */
- domain: PropTypes.string,
- }),
- }).isRequired,
+ cardList: OnyxEntry>;
};
-const defaultProps = {
- formData: {},
- privatePersonalDetails: {
+type ReportCardLostPageProps = ReportCardLostPageOnyxProps & StackScreenProps;
+
+function ReportCardLostPage({
+ privatePersonalDetails = {
address: {
street: '',
street2: '',
@@ -79,51 +75,46 @@ const defaultProps = {
country: '',
},
},
- cardList: {},
-};
-
-function ReportCardLostPage({
- privatePersonalDetails,
- cardList,
+ cardList = {},
route: {
- params: {domain},
+ params: {domain = ''},
},
formData,
-}) {
+}: ReportCardLostPageProps) {
const styles = useThemeStyles();
usePrivatePersonalDetails();
- const domainCards = CardUtils.getDomainCards(cardList)[domain];
+ const domainCards = CardUtils.getDomainCards(cardList ?? {})[domain];
const physicalCard = CardUtils.findPhysicalCard(domainCards);
const {translate} = useLocalize();
- const [reason, setReason] = useState();
+ const [reason, setReason] = useState
@@ -222,11 +212,9 @@ function ReportCardLostPage({
);
}
-ReportCardLostPage.propTypes = propTypes;
-ReportCardLostPage.defaultProps = defaultProps;
ReportCardLostPage.displayName = 'ReportCardLostPage';
-export default withOnyx({
+export default withOnyx({
privatePersonalDetails: {
key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
},
diff --git a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx
similarity index 61%
rename from src/pages/settings/Wallet/ReportVirtualCardFraudPage.js
rename to src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx
index fa88e01d8b41..40a3eacb4ed9 100644
--- a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js
+++ b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx
@@ -1,8 +1,8 @@
-import PropTypes from 'prop-types';
+import type {StackScreenProps} from '@react-navigation/stack';
import React, {useEffect} from 'react';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -13,61 +13,54 @@ import useThemeStyles from '@hooks/useThemeStyles';
import * as CardUtils from '@libs/CardUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
+import type {PublicScreensParamList} from '@libs/Navigation/types';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import * as Card from '@userActions/Card';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import assignedCardPropTypes from './assignedCardPropTypes';
+import type SCREENS from '@src/SCREENS';
+import type {ReportVirtualCardFraudForm} from '@src/types/form';
+import type {Card as OnyxCard} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
-const propTypes = {
- /* Onyx Props */
- formData: PropTypes.shape({
- isLoading: PropTypes.bool,
- }),
- cardList: PropTypes.objectOf(assignedCardPropTypes),
- /** The parameters needed to authenticate with a short-lived token are in the URL */
- route: PropTypes.shape({
- /** Each parameter passed via the URL */
- params: PropTypes.shape({
- /** Domain string */
- domain: PropTypes.string,
- }),
- }).isRequired,
-};
+type ReportVirtualCardFraudPageOnyxProps = {
+ /** Form data propTypes */
+ formData: OnyxEntry;
-const defaultProps = {
- cardList: {},
- formData: {},
+ /** Card list propTypes */
+ cardList: OnyxEntry>;
};
+type ReportVirtualCardFraudPageProps = ReportVirtualCardFraudPageOnyxProps & StackScreenProps;
+
function ReportVirtualCardFraudPage({
route: {
- params: {domain},
+ params: {domain = ''},
},
cardList,
formData,
-}) {
+}: ReportVirtualCardFraudPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const domainCards = CardUtils.getDomainCards(cardList)[domain];
- const virtualCard = _.find(domainCards, (card) => card.isVirtual) || {};
- const virtualCardError = ErrorUtils.getLatestErrorMessage(virtualCard) || '';
+ const virtualCard = domainCards?.find((card) => card.isVirtual);
+ const virtualCardError = ErrorUtils.getLatestErrorMessage(virtualCard?.errors ?? {});
- const prevIsLoading = usePrevious(formData.isLoading);
+ const prevIsLoading = usePrevious(formData?.isLoading);
useEffect(() => {
- if (!prevIsLoading || formData.isLoading) {
+ if (!prevIsLoading || formData?.isLoading) {
return;
}
- if (!_.isEmpty(virtualCard.errors)) {
+ if (!isEmptyObject(virtualCard?.errors)) {
return;
}
Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain));
- }, [domain, formData.isLoading, prevIsLoading, virtualCard.errors]);
+ }, [domain, formData?.isLoading, prevIsLoading, virtualCard?.errors]);
- if (_.isEmpty(virtualCard)) {
+ if (isEmptyObject(virtualCard)) {
return ;
}
@@ -78,12 +71,12 @@ function ReportVirtualCardFraudPage({
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain))}
/>
- {translate('reportFraudPage.description')}
+ {translate('reportFraudPage.description')}
Card.reportVirtualExpensifyCardFraud(virtualCard.cardID)}
message={virtualCardError}
- isLoading={formData.isLoading}
+ isLoading={formData?.isLoading}
buttonText={translate('reportFraudPage.deactivateCard')}
/>
@@ -91,11 +84,9 @@ function ReportVirtualCardFraudPage({
);
}
-ReportVirtualCardFraudPage.propTypes = propTypes;
-ReportVirtualCardFraudPage.defaultProps = defaultProps;
ReportVirtualCardFraudPage.displayName = 'ReportVirtualCardFraudPage';
-export default withOnyx({
+export default withOnyx({
cardList: {
key: ONYXKEYS.CARD_LIST,
},
diff --git a/src/pages/settings/Wallet/TransferBalancePage.js b/src/pages/settings/Wallet/TransferBalancePage.tsx
similarity index 57%
rename from src/pages/settings/Wallet/TransferBalancePage.js
rename to src/pages/settings/Wallet/TransferBalancePage.tsx
index 5c012fdac92e..93ead17e9523 100644
--- a/src/pages/settings/Wallet/TransferBalancePage.js
+++ b/src/pages/settings/Wallet/TransferBalancePage.tsx
@@ -1,81 +1,63 @@
-import PropTypes from 'prop-types';
import React, {useEffect} from 'react';
import {ScrollView, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
-import cardPropTypes from '@components/cardPropTypes';
import ConfirmationPage from '@components/ConfirmationPage';
import CurrentWalletBalance from '@components/CurrentWalletBalance';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
-import {withNetwork} from '@components/OnyxProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as PaymentUtils from '@libs/PaymentUtils';
-import userWalletPropTypes from '@pages/EnablePayments/userWalletPropTypes';
import variables from '@styles/variables';
import * as PaymentMethods from '@userActions/PaymentMethods';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import walletTransferPropTypes from './walletTransferPropTypes';
+import type {BankAccountList, FundList, UserWallet, WalletTransfer} from '@src/types/onyx';
+import type PaymentMethod from '@src/types/onyx/PaymentMethod';
+import type {FilterMethodPaymentType} from '@src/types/onyx/WalletTransfer';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
-const propTypes = {
+type TransferBalancePageOnyxProps = {
/** User's wallet information */
- userWallet: userWalletPropTypes,
+ userWallet: OnyxEntry;
/** List of bank accounts */
- bankAccountList: PropTypes.objectOf(
- PropTypes.shape({
- /** The name of the institution (bank of america, etc) */
- addressName: PropTypes.string,
-
- /** The masked bank account number */
- accountNumber: PropTypes.string,
-
- /** The bankAccountID in the bankAccounts db */
- bankAccountID: PropTypes.number,
-
- /** The bank account type */
- type: PropTypes.string,
- }),
- ),
+ bankAccountList: OnyxEntry;
/** List of user's card objects */
- fundList: PropTypes.objectOf(cardPropTypes),
+ fundList: OnyxEntry;
/** Wallet balance transfer props */
- walletTransfer: walletTransferPropTypes,
-
- ...withLocalizePropTypes,
+ walletTransfer: OnyxEntry;
};
-const defaultProps = {
- bankAccountList: {},
- fundList: null,
- userWallet: {},
- walletTransfer: {},
-};
+type TransferBalancePageProps = TransferBalancePageOnyxProps;
+
+const TRANSFER_TIER_NAMES: string[] = [CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM];
-function TransferBalancePage(props) {
+function TransferBalancePage({bankAccountList, fundList, userWallet, walletTransfer}: TransferBalancePageProps) {
const styles = useThemeStyles();
- const paymentCardList = props.fundList || {};
+ const {numberFormat, translate} = useLocalize();
+ const {isOffline} = useNetwork();
+ const paymentCardList = fundList ?? {};
const paymentTypes = [
{
key: CONST.WALLET.TRANSFER_METHOD_TYPE.INSTANT,
- title: props.translate('transferAmountPage.instant'),
- description: props.translate('transferAmountPage.instantSummary', {
- rate: props.numberFormat(CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT.RATE),
+ title: translate('transferAmountPage.instant'),
+ description: translate('transferAmountPage.instantSummary', {
+ rate: numberFormat(CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT.RATE),
minAmount: CurrencyUtils.convertToDisplayString(CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT.MINIMUM_FEE),
}),
icon: Expensicons.Bolt,
@@ -83,8 +65,8 @@ function TransferBalancePage(props) {
},
{
key: CONST.WALLET.TRANSFER_METHOD_TYPE.ACH,
- title: props.translate('transferAmountPage.ach'),
- description: props.translate('transferAmountPage.achSummary'),
+ title: translate('transferAmountPage.ach'),
+ description: translate('transferAmountPage.achSummary'),
icon: Expensicons.Bank,
type: CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT,
},
@@ -92,32 +74,27 @@ function TransferBalancePage(props) {
/**
* Get the selected/default payment method account for wallet transfer
- * @returns {Object|undefined}
*/
- function getSelectedPaymentMethodAccount() {
- const paymentMethods = PaymentUtils.formatPaymentMethods(props.bankAccountList, paymentCardList, styles);
+ function getSelectedPaymentMethodAccount(): PaymentMethod | undefined {
+ const paymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList ?? {}, paymentCardList, styles);
- const defaultAccount = _.find(paymentMethods, (method) => method.isDefault);
- const selectedAccount = _.find(
- paymentMethods,
- (method) => method.accountType === props.walletTransfer.selectedAccountType && method.methodID === props.walletTransfer.selectedAccountID,
+ const defaultAccount = paymentMethods.find((method) => method.isDefault);
+ const selectedAccount = paymentMethods.find(
+ (method) => method.accountType === walletTransfer?.selectedAccountType && method.methodID?.toString() === walletTransfer?.selectedAccountID?.toString(),
);
- return selectedAccount || defaultAccount;
+ return selectedAccount ?? defaultAccount;
}
- /**
- * @param {String} filterPaymentMethodType
- */
- function navigateToChooseTransferAccount(filterPaymentMethodType) {
+ function navigateToChooseTransferAccount(filterPaymentMethodType: FilterMethodPaymentType) {
PaymentMethods.saveWalletTransferMethodType(filterPaymentMethodType);
// If we only have a single option for the given paymentMethodType do not force the user to make a selection
- const combinedPaymentMethods = PaymentUtils.formatPaymentMethods(props.bankAccountList, paymentCardList, styles);
+ const combinedPaymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList ?? {}, paymentCardList, styles);
- const filteredMethods = _.filter(combinedPaymentMethods, (paymentMethod) => paymentMethod.accountType === filterPaymentMethodType);
+ const filteredMethods = combinedPaymentMethods.filter((paymentMethod) => paymentMethod.accountType === filterPaymentMethodType);
if (filteredMethods.length === 1) {
- const account = _.first(filteredMethods);
- PaymentMethods.saveWalletTransferAccountTypeAndID(filterPaymentMethodType, account.methodID);
+ const account = filteredMethods[0];
+ PaymentMethods.saveWalletTransferAccountTypeAndID(filterPaymentMethodType ?? '', account?.methodID?.toString() ?? '');
return;
}
@@ -133,26 +110,26 @@ function TransferBalancePage(props) {
return;
}
- PaymentMethods.saveWalletTransferAccountTypeAndID(selectedAccount.accountType, selectedAccount.methodID);
+ PaymentMethods.saveWalletTransferAccountTypeAndID(selectedAccount?.accountType ?? '', selectedAccount?.methodID?.toString() ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this effect to run on initial render
}, []);
- if (props.walletTransfer.shouldShowSuccess && !props.walletTransfer.loading) {
+ if (walletTransfer?.shouldShowSuccess && !walletTransfer?.loading) {
return (
@@ -163,15 +140,13 @@ function TransferBalancePage(props) {
const selectedPaymentType =
selectedAccount && selectedAccount.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? CONST.WALLET.TRANSFER_METHOD_TYPE.ACH : CONST.WALLET.TRANSFER_METHOD_TYPE.INSTANT;
- const calculatedFee = PaymentUtils.calculateWalletTransferBalanceFee(props.userWallet.currentBalance, selectedPaymentType);
- const transferAmount = props.userWallet.currentBalance - calculatedFee;
+ const calculatedFee = PaymentUtils.calculateWalletTransferBalanceFee(userWallet?.currentBalance ?? 0, selectedPaymentType);
+ const transferAmount = userWallet?.currentBalance ?? 0 - calculatedFee;
const isTransferable = transferAmount > 0;
const isButtonDisabled = !isTransferable || !selectedAccount;
- const errorMessage = ErrorUtils.getLatestErrorMessage(props.walletTransfer);
+ const errorMessage = ErrorUtils.getLatestErrorMessage(walletTransfer);
- const shouldShowTransferView =
- PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, props.bankAccountList) &&
- _.contains([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM], props.userWallet.tierName);
+ const shouldShowTransferView = PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, bankAccountList ?? {}) && TRANSFER_TIER_NAMES.includes(userWallet?.tierName ?? '');
return (
@@ -183,7 +158,7 @@ function TransferBalancePage(props) {
onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET)}
>
Navigation.goBack(ROUTES.SETTINGS_WALLET)}
/>
@@ -195,7 +170,7 @@ function TransferBalancePage(props) {
contentContainerStyle={styles.pv5}
>
- {_.map(paymentTypes, (paymentType) => (
+ {paymentTypes.map((paymentType) => (
))}
- {props.translate('transferAmountPage.whichAccount')}
+ {translate('transferAmountPage.whichAccount')}
{Boolean(selectedAccount) && (
navigateToChooseTransferAccount(selectedAccount.accountType)}
+ iconStyles={selectedAccount?.iconStyles}
+ iconWidth={selectedAccount?.iconSize}
+ iconHeight={selectedAccount?.iconSize}
+ icon={selectedAccount?.icon}
+ onPress={() => navigateToChooseTransferAccount(selectedAccount?.accountType ?? CONST.PAYMENT_METHODS.DEBIT_CARD)}
displayInDefaultIconColor
/>
)}
- {props.translate('transferAmountPage.fee')}
+ {translate('transferAmountPage.fee')}
{CurrencyUtils.convertToDisplayString(calculatedFee)}
PaymentMethods.transferWalletBalance(selectedAccount)}
- isDisabled={isButtonDisabled || props.network.isOffline}
+ isLoading={walletTransfer?.loading}
+ onSubmit={() => selectedAccount && PaymentMethods.transferWalletBalance(selectedAccount)}
+ isDisabled={isButtonDisabled || isOffline}
message={errorMessage}
- isAlertVisible={!_.isEmpty(errorMessage)}
+ isAlertVisible={!isEmptyObject(errorMessage)}
/>
@@ -250,25 +225,19 @@ function TransferBalancePage(props) {
);
}
-TransferBalancePage.propTypes = propTypes;
-TransferBalancePage.defaultProps = defaultProps;
TransferBalancePage.displayName = 'TransferBalancePage';
-export default compose(
- withLocalize,
- withNetwork(),
- withOnyx({
- userWallet: {
- key: ONYXKEYS.USER_WALLET,
- },
- walletTransfer: {
- key: ONYXKEYS.WALLET_TRANSFER,
- },
- bankAccountList: {
- key: ONYXKEYS.BANK_ACCOUNT_LIST,
- },
- fundList: {
- key: ONYXKEYS.FUND_LIST,
- },
- }),
-)(TransferBalancePage);
+export default withOnyx({
+ userWallet: {
+ key: ONYXKEYS.USER_WALLET,
+ },
+ walletTransfer: {
+ key: ONYXKEYS.WALLET_TRANSFER,
+ },
+ bankAccountList: {
+ key: ONYXKEYS.BANK_ACCOUNT_LIST,
+ },
+ fundList: {
+ key: ONYXKEYS.FUND_LIST,
+ },
+})(TransferBalancePage);
diff --git a/src/pages/settings/Wallet/walletTransferPropTypes.js b/src/pages/settings/Wallet/walletTransferPropTypes.js
deleted file mode 100644
index 9e25213382e9..000000000000
--- a/src/pages/settings/Wallet/walletTransferPropTypes.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import PropTypes from 'prop-types';
-import CONST from '@src/CONST';
-
-/** Wallet balance transfer props */
-const walletTransferPropTypes = PropTypes.shape({
- /** Selected accountID for transfer */
- selectedAccountID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
-
- /** Type to filter the payment Method list */
- filterPaymentMethodType: PropTypes.oneOf([CONST.PAYMENT_METHODS.DEBIT_CARD, CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT, '']),
-
- /** Whether the success screen is shown to user. */
- shouldShowSuccess: PropTypes.bool,
-});
-
-export default walletTransferPropTypes;
diff --git a/src/pages/signin/ChangeExpensifyLoginLink.js b/src/pages/signin/ChangeExpensifyLoginLink.js
deleted file mode 100755
index 6ba9b7fcd0f5..000000000000
--- a/src/pages/signin/ChangeExpensifyLoginLink.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-
-const propTypes = {
- /** The credentials of the logged in person */
- credentials: PropTypes.shape({
- /** The email the user logged in with */
- login: PropTypes.string,
- }),
-
- /** Callback to navigate back to email form */
- onPress: PropTypes.func.isRequired,
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- credentials: {
- login: '',
- },
-};
-
-function ChangeExpensifyLoginLink(props) {
- const styles = useThemeStyles();
- return (
-
- {!_.isEmpty(props.credentials.login) && {props.translate('loginForm.notYou', {user: props.formatPhoneNumber(props.credentials.login)})}}
-
-
- {props.translate('common.goBack')}
- {'.'}
-
-
-
- );
-}
-
-ChangeExpensifyLoginLink.propTypes = propTypes;
-ChangeExpensifyLoginLink.defaultProps = defaultProps;
-ChangeExpensifyLoginLink.displayName = 'ChangeExpensifyLoginLink';
-
-export default compose(
- withLocalize,
- withOnyx({
- credentials: {key: ONYXKEYS.CREDENTIALS},
- }),
-)(ChangeExpensifyLoginLink);
diff --git a/src/pages/signin/ChangeExpensifyLoginLink.tsx b/src/pages/signin/ChangeExpensifyLoginLink.tsx
new file mode 100755
index 000000000000..7f6eb05ff663
--- /dev/null
+++ b/src/pages/signin/ChangeExpensifyLoginLink.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Credentials} from '@src/types/onyx';
+
+type ChangeExpensifyLoginLinkOnyxProps = {
+ /** The credentials of the person logging in */
+ credentials: OnyxEntry;
+};
+
+type ChangeExpensifyLoginLinkProps = ChangeExpensifyLoginLinkOnyxProps & {
+ onPress: () => void;
+};
+
+function ChangeExpensifyLoginLink({credentials, onPress}: ChangeExpensifyLoginLinkProps) {
+ const styles = useThemeStyles();
+ const {translate, formatPhoneNumber} = useLocalize();
+
+ return (
+
+ {!!credentials?.login && {translate('loginForm.notYou', {user: formatPhoneNumber(credentials.login)})}}
+
+ {translate('common.goBack')}.
+
+
+ );
+}
+
+ChangeExpensifyLoginLink.displayName = 'ChangeExpensifyLoginLink';
+
+export default withOnyx({
+ credentials: {
+ key: ONYXKEYS.CREDENTIALS,
+ },
+})(ChangeExpensifyLoginLink);
diff --git a/src/pages/signin/ChooseSSOOrMagicCode.tsx b/src/pages/signin/ChooseSSOOrMagicCode.tsx
index d3140da278e8..7a39df332611 100644
--- a/src/pages/signin/ChooseSSOOrMagicCode.tsx
+++ b/src/pages/signin/ChooseSSOOrMagicCode.tsx
@@ -81,10 +81,7 @@ function ChooseSSOOrMagicCode({credentials, account, setIsUsingMagicCode}: Choos
}}
/>
{!!account && !isEmptyObject(account.errors) && }
- Session.clearSignInData()}
- />
+ Session.clearSignInData()} />
diff --git a/src/pages/signin/SignInModal.tsx b/src/pages/signin/SignInModal.tsx
index b2ee9d218da9..b53092f5652a 100644
--- a/src/pages/signin/SignInModal.tsx
+++ b/src/pages/signin/SignInModal.tsx
@@ -26,7 +26,7 @@ function SignInModal() {
testID={SignInModal.displayName}
>
Navigation.goBack()} />
-
+
);
}
diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx
index a033088f7727..cdb400de9742 100644
--- a/src/pages/signin/SignInPage.tsx
+++ b/src/pages/signin/SignInPage.tsx
@@ -1,10 +1,10 @@
import Str from 'expensify-common/lib/str';
import React, {useEffect, useRef, useState} from 'react';
-import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import ColorSchemeWrapper from '@components/ColorSchemeWrapper';
import CustomStatusBarAndBackground from '@components/CustomStatusBarAndBackground';
+import ScreenWrapper from '@components/ScreenWrapper';
import ThemeProvider from '@components/ThemeProvider';
import ThemeStylesProvider from '@components/ThemeStylesProvider';
import useLocalize from '@hooks/useLocalize';
@@ -48,7 +48,9 @@ type SignInPageInnerOnyxProps = {
preferredLocale: OnyxEntry;
};
-type SignInPageInnerProps = SignInPageInnerOnyxProps;
+type SignInPageInnerProps = SignInPageInnerOnyxProps & {
+ shouldEnableMaxHeight?: boolean;
+};
type RenderOption = {
shouldShowLoginForm: boolean;
@@ -124,7 +126,7 @@ function getRenderOptions({
};
}
-function SignInPageInner({credentials, account, activeClients = [], preferredLocale}: SignInPageInnerProps) {
+function SignInPageInner({credentials, account, activeClients = [], preferredLocale, shouldEnableMaxHeight = true}: SignInPageInnerProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {translate, formatPhoneNumber} = useLocalize();
@@ -245,7 +247,8 @@ function SignInPageInner({credentials, account, activeClients = [], preferredLoc
return (
// Bottom SafeAreaView is removed so that login screen svg displays correctly on mobile.
// The SVG should flow under the Home Indicator on iOS.
-
@@ -267,7 +270,6 @@ function SignInPageInner({credentials, account, activeClients = [], preferredLoc
/>
{shouldShowValidateCodeForm && (
)}
-
+
);
}
diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx
similarity index 58%
rename from src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
rename to src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx
index 4afba77b77b5..428df32bf032 100755
--- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -1,140 +1,111 @@
import {useIsFocused} from '@react-navigation/native';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import Button from '@components/Button';
import FormHelpMessage from '@components/FormHelpMessage';
+import type {MagicCodeInputHandle} from '@components/MagicCodeInput';
import MagicCodeInput from '@components/MagicCodeInput';
-import networkPropTypes from '@components/networkPropTypes';
-import {withNetwork} from '@components/OnyxProvider';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import type {WithToggleVisibilityViewProps} from '@components/withToggleVisibilityView';
import withToggleVisibilityView from '@components/withToggleVisibilityView';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import usePrevious from '@hooks/usePrevious';
import useStyleUtils from '@hooks/useStyleUtils';
-import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
-import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import ChangeExpensifyLoginLink from '@pages/signin/ChangeExpensifyLoginLink';
import Terms from '@pages/signin/Terms';
-import * as Session from '@userActions/Session';
+import * as SessionActions 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 type {Account, Credentials, Session} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import type ValidateCodeFormProps from './types';
-const propTypes = {
- /* Onyx Props */
-
+type BaseValidateCodeFormOnyxProps = {
/** The details about the account that the user is signing in with */
- account: PropTypes.shape({
- /** Whether or not two-factor authentication is required */
- requiresTwoFactorAuth: PropTypes.bool,
-
- /** Whether or not a sign on form is loading (being submitted) */
- isLoading: PropTypes.bool,
-
- /** Whether or not the user has SAML enabled on their account */
- isSAMLEnabled: PropTypes.bool,
-
- /** Whether or not SAML is required on the account */
- isSAMLRequired: PropTypes.bool,
- }),
-
- /** The credentials of the person signing in */
- credentials: PropTypes.shape({
- /** The login of the person signing in */
- login: PropTypes.string,
- }),
-
- /** Session of currently logged in user */
- session: PropTypes.shape({
- /** Currently logged in user authToken */
- authToken: PropTypes.string,
- }),
+ account: OnyxEntry;
- /** Information about the network */
- network: networkPropTypes.isRequired,
+ /** The credentials of the person logging in */
+ credentials: OnyxEntry;
- /** Specifies autocomplete hints for the system, so it can provide autofill */
- autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired,
-
- /** Determines if user is switched to using recovery code instead of 2fa code */
- isUsingRecoveryCode: PropTypes.bool.isRequired,
+ /** Session info for the currently logged in user. */
+ session: OnyxEntry;
+};
- /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
- setIsUsingRecoveryCode: PropTypes.func.isRequired,
+type BaseValidateCodeFormProps = WithToggleVisibilityViewProps &
+ ValidateCodeFormProps &
+ BaseValidateCodeFormOnyxProps & {
+ /** Specifies autocomplete hints for the system, so it can provide autofill */
+ autoComplete: 'sms-otp' | 'one-time-code';
+ };
- ...withLocalizePropTypes,
-};
+type ValidateCodeFormVariant = 'validateCode' | 'twoFactorAuthCode' | 'recoveryCode';
-const defaultProps = {
- account: {},
- credentials: {},
- session: {
- authToken: null,
- },
-};
+type FormError = Partial>;
-function BaseValidateCodeForm(props) {
- const theme = useTheme();
+function BaseValidateCodeForm({account, credentials, session, autoComplete, isUsingRecoveryCode, setIsUsingRecoveryCode, isVisible}: BaseValidateCodeFormProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ const {translate} = useLocalize();
const isFocused = useIsFocused();
- const [formError, setFormError] = useState({});
- const [validateCode, setValidateCode] = useState(props.credentials.validateCode || '');
+ const {isOffline} = useNetwork();
+ const [formError, setFormError] = useState({});
+ const [validateCode, setValidateCode] = useState(credentials?.validateCode ?? '');
const [twoFactorAuthCode, setTwoFactorAuthCode] = useState('');
const [timeRemaining, setTimeRemaining] = useState(30);
const [recoveryCode, setRecoveryCode] = useState('');
- const [needToClearError, setNeedToClearError] = useState(props.account.errors);
+ const [needToClearError, setNeedToClearError] = useState(!!account?.errors);
- const prevRequiresTwoFactorAuth = usePrevious(props.account.requiresTwoFactorAuth);
- const prevValidateCode = usePrevious(props.credentials.validateCode);
+ const prevRequiresTwoFactorAuth = usePrevious(account?.requiresTwoFactorAuth);
+ const prevValidateCode = usePrevious(credentials?.validateCode);
- const inputValidateCodeRef = useRef();
- const input2FARef = useRef();
- const timerRef = useRef();
+ const inputValidateCodeRef = useRef();
+ const input2FARef = useRef();
+ const timerRef = useRef();
- const hasError = Boolean(props.account) && !_.isEmpty(props.account.errors) && !needToClearError;
- const isLoadingResendValidationForm = props.account.loadingForm === CONST.FORMS.RESEND_VALIDATE_CODE_FORM;
- const shouldDisableResendValidateCode = props.network.isOffline || props.account.isLoading;
+ const hasError = !!account && !isEmptyObject(account?.errors) && !needToClearError;
+ const isLoadingResendValidationForm = account?.loadingForm === CONST.FORMS.RESEND_VALIDATE_CODE_FORM;
+ const shouldDisableResendValidateCode = isOffline ?? account?.isLoading;
const isValidateCodeFormSubmitting =
- props.account.isLoading && props.account.loadingForm === (props.account.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM);
+ account?.isLoading && account?.loadingForm === (account?.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM);
useEffect(() => {
- if (!(inputValidateCodeRef.current && hasError && (props.session.autoAuthState === CONST.AUTO_AUTH_STATE.FAILED || props.account.isLoading))) {
+ if (!(inputValidateCodeRef.current && hasError && (session?.autoAuthState === CONST.AUTO_AUTH_STATE.FAILED || account?.isLoading))) {
return;
}
inputValidateCodeRef.current.blur();
- }, [props.account.isLoading, props.session.autoAuthState, hasError]);
+ }, [account?.isLoading, session?.autoAuthState, hasError]);
useEffect(() => {
- if (!inputValidateCodeRef.current || !canFocusInputOnScreenFocus() || !props.isVisible || !isFocused) {
+ if (!inputValidateCodeRef.current || !canFocusInputOnScreenFocus() || !isVisible || !isFocused) {
return;
}
inputValidateCodeRef.current.focus();
- }, [props.isVisible, isFocused]);
+ }, [isVisible, isFocused]);
useEffect(() => {
- if (prevValidateCode || !props.credentials.validateCode) {
+ if (!!prevValidateCode || !credentials?.validateCode) {
return;
}
- setValidateCode(props.credentials.validateCode);
- }, [props.credentials.validateCode, prevValidateCode]);
+ setValidateCode(credentials.validateCode);
+ }, [credentials?.validateCode, prevValidateCode]);
useEffect(() => {
- if (!input2FARef.current || prevRequiresTwoFactorAuth || !props.account.requiresTwoFactorAuth) {
+ if (!input2FARef.current || !!prevRequiresTwoFactorAuth || !account?.requiresTwoFactorAuth) {
return;
}
input2FARef.current.focus();
- }, [props.account.requiresTwoFactorAuth, prevRequiresTwoFactorAuth]);
+ }, [account?.requiresTwoFactorAuth, prevRequiresTwoFactorAuth]);
useEffect(() => {
if (!inputValidateCodeRef.current || validateCode.length > 0) {
@@ -163,27 +134,22 @@ function BaseValidateCodeForm(props) {
/**
* Handle text input and clear formError upon text change
- *
- * @param {String} text
- * @param {String} key
*/
- const onTextInput = (text, key) => {
- let setInput;
+ const onTextInput = (text: string, key: ValidateCodeFormVariant) => {
if (key === 'validateCode') {
- setInput = setValidateCode;
+ setValidateCode(text);
}
if (key === 'twoFactorAuthCode') {
- setInput = setTwoFactorAuthCode;
+ setTwoFactorAuthCode(text);
}
if (key === 'recoveryCode') {
- setInput = setRecoveryCode;
+ setRecoveryCode(text);
}
- setInput(text);
- setFormError((prevError) => ({...prevError, [key]: ''}));
+ setFormError((prevError) => ({...prevError, [key]: undefined}));
- if (props.account.errors) {
- Session.clearAccountMessages();
+ if (account?.errors) {
+ SessionActions.clearAccountMessages();
}
};
@@ -191,8 +157,8 @@ function BaseValidateCodeForm(props) {
* Trigger the reset validate code flow and ensure the 2FA input field is reset to avoid it being permanently hidden
*/
const resendValidateCode = () => {
- User.resendValidateCode(props.credentials.login);
- inputValidateCodeRef.current.clear();
+ User.resendValidateCode(credentials?.login ?? '');
+ inputValidateCodeRef.current?.clear();
// Give feedback to the user to let them know the email was sent so that they don't spam the button.
setTimeRemaining(30);
};
@@ -204,7 +170,7 @@ function BaseValidateCodeForm(props) {
setTwoFactorAuthCode('');
setFormError({});
setValidateCode('');
- props.setIsUsingRecoveryCode(false);
+ setIsUsingRecoveryCode(false);
setRecoveryCode('');
};
@@ -213,7 +179,7 @@ function BaseValidateCodeForm(props) {
*/
const clearSignInData = () => {
clearLocalSignInData();
- Session.clearSignInData();
+ SessionActions.clearSignInData();
};
useEffect(() => {
@@ -221,26 +187,26 @@ function BaseValidateCodeForm(props) {
return;
}
- if (props.account.errors) {
- Session.clearAccountMessages();
+ if (account?.errors) {
+ SessionActions.clearAccountMessages();
return;
}
setNeedToClearError(false);
- }, [props.account.errors, needToClearError]);
+ }, [account?.errors, needToClearError]);
/**
* Switches between 2fa and recovery code, clears inputs and errors
*/
const switchBetween2faAndRecoveryCode = () => {
- props.setIsUsingRecoveryCode(!props.isUsingRecoveryCode);
+ setIsUsingRecoveryCode(!isUsingRecoveryCode);
setRecoveryCode('');
setTwoFactorAuthCode('');
- setFormError((prevError) => ({...prevError, recoveryCode: '', twoFactorAuthCode: ''}));
+ setFormError((prevError) => ({...prevError, recoveryCode: undefined, twoFactorAuthCode: undefined}));
- if (props.account.errors) {
- Session.clearAccountMessages();
+ if (account?.errors) {
+ SessionActions.clearAccountMessages();
}
};
@@ -258,10 +224,10 @@ function BaseValidateCodeForm(props) {
* Check that all the form fields are valid, then trigger the submit callback
*/
const validateAndSubmitForm = useCallback(() => {
- if (props.account.isLoading) {
+ if (account?.isLoading) {
return;
}
- const requiresTwoFactorAuth = props.account.requiresTwoFactorAuth;
+ const requiresTwoFactorAuth = account?.requiresTwoFactorAuth;
if (requiresTwoFactorAuth) {
if (input2FARef.current) {
input2FARef.current.blur();
@@ -269,7 +235,7 @@ function BaseValidateCodeForm(props) {
/**
* User could be using either recovery code or 2fa code
*/
- if (!props.isUsingRecoveryCode) {
+ if (!isUsingRecoveryCode) {
if (!twoFactorAuthCode.trim()) {
setFormError({twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'});
return;
@@ -303,30 +269,30 @@ function BaseValidateCodeForm(props) {
}
setFormError({});
- const recoveryCodeOr2faCode = props.isUsingRecoveryCode ? recoveryCode : twoFactorAuthCode;
+ const recoveryCodeOr2faCode = isUsingRecoveryCode ? recoveryCode : twoFactorAuthCode;
- const accountID = lodashGet(props.credentials, 'accountID');
+ const accountID = credentials?.accountID;
if (accountID) {
- Session.signInWithValidateCode(accountID, validateCode, recoveryCodeOr2faCode);
+ SessionActions.signInWithValidateCode(accountID, validateCode, recoveryCodeOr2faCode);
} else {
- Session.signIn(validateCode, recoveryCodeOr2faCode);
+ SessionActions.signIn(validateCode, recoveryCodeOr2faCode);
}
- }, [props.account, props.credentials, twoFactorAuthCode, validateCode, props.isUsingRecoveryCode, recoveryCode]);
+ }, [account, credentials, twoFactorAuthCode, validateCode, isUsingRecoveryCode, recoveryCode]);
return (
<>
{/* At this point, if we know the account requires 2FA we already successfully authenticated */}
- {props.account.requiresTwoFactorAuth ? (
+ {account?.requiresTwoFactorAuth ? (
- {props.isUsingRecoveryCode ? (
+ {isUsingRecoveryCode ? (
onTextInput(text, 'recoveryCode')}
maxLength={CONST.RECOVERY_CODE_LENGTH}
- label={props.translate('recoveryCodeForm.recoveryCode')}
- errorText={formError.recoveryCode || ''}
+ label={translate('recoveryCodeForm.recoveryCode')}
+ errorText={formError?.recoveryCode ?? ''}
hasError={hasError}
onSubmitEditing={validateAndSubmitForm}
autoFocus
@@ -334,70 +300,76 @@ function BaseValidateCodeForm(props) {
) : (
{
+ if (!magicCodeInput) {
+ return;
+ }
+ input2FARef.current = magicCodeInput;
+ }}
name="twoFactorAuthCode"
value={twoFactorAuthCode}
onChangeText={(text) => onTextInput(text, 'twoFactorAuthCode')}
onFulfill={validateAndSubmitForm}
maxLength={CONST.TFA_CODE_LENGTH}
- errorText={formError.twoFactorAuthCode || ''}
+ errorText={formError?.twoFactorAuthCode ?? ''}
hasError={hasError}
autoFocus
key="twoFactorAuthCode"
/>
)}
- {hasError && }
+ {hasError && }
- {props.isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')}
+ {isUsingRecoveryCode ? translate('recoveryCodeForm.use2fa') : translate('recoveryCodeForm.useRecoveryCode')}
) : (
{
+ if (!magicCodeInput) {
+ return;
+ }
+ inputValidateCodeRef.current = magicCodeInput;
+ }}
name="validateCode"
value={validateCode}
onChangeText={(text) => onTextInput(text, 'validateCode')}
onFulfill={validateAndSubmitForm}
- errorText={formError.validateCode || ''}
+ errorText={formError?.validateCode ?? ''}
hasError={hasError}
autoFocus
key="validateCode"
testID="validateCode"
/>
- {hasError && }
+ {hasError && }
- {timeRemaining > 0 && !props.network.isOffline ? (
+ {timeRemaining > 0 && !isOffline ? (
- {props.translate('validateCodeForm.requestNewCode')}
+ {translate('validateCodeForm.requestNewCode')}
00:{String(timeRemaining).padStart(2, '0')}
) : (
- {hasError ? props.translate('validateCodeForm.requestNewCodeAfterErrorOccurred') : props.translate('validateCodeForm.magicCodeNotReceived')}
+ {hasError ? translate('validateCodeForm.requestNewCodeAfterErrorOccurred') : translate('validateCodeForm.magicCodeNotReceived')}
)}
@@ -406,10 +378,10 @@ function BaseValidateCodeForm(props) {
)}
@@ -422,17 +394,12 @@ function BaseValidateCodeForm(props) {
);
}
-BaseValidateCodeForm.propTypes = propTypes;
-BaseValidateCodeForm.defaultProps = defaultProps;
BaseValidateCodeForm.displayName = 'BaseValidateCodeForm';
-export default compose(
- withLocalize,
- withOnyx({
+export default withToggleVisibilityView(
+ withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
credentials: {key: ONYXKEYS.CREDENTIALS},
session: {key: ONYXKEYS.SESSION},
- }),
- withNetwork(),
- withToggleVisibilityView,
-)(BaseValidateCodeForm);
+ })(BaseValidateCodeForm),
+);
diff --git a/src/pages/signin/ValidateCodeForm/index.android.js b/src/pages/signin/ValidateCodeForm/index.android.js
deleted file mode 100644
index 9adddf7c92d8..000000000000
--- a/src/pages/signin/ValidateCodeForm/index.android.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import BaseValidateCodeForm from './BaseValidateCodeForm';
-
-const defaultProps = {};
-
-const propTypes = {
- /** Determines if user is switched to using recovery code instead of 2fa code */
- isUsingRecoveryCode: PropTypes.bool.isRequired,
-
- /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
- setIsUsingRecoveryCode: PropTypes.func.isRequired,
-};
-function ValidateCodeForm(props) {
- return (
-
- );
-}
-
-ValidateCodeForm.displayName = 'ValidateCodeForm';
-ValidateCodeForm.propTypes = propTypes;
-ValidateCodeForm.defaultProps = defaultProps;
-
-export default ValidateCodeForm;
diff --git a/src/pages/signin/ValidateCodeForm/index.android.tsx b/src/pages/signin/ValidateCodeForm/index.android.tsx
new file mode 100644
index 000000000000..1edd17517539
--- /dev/null
+++ b/src/pages/signin/ValidateCodeForm/index.android.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import BaseValidateCodeForm from './BaseValidateCodeForm';
+import type ValidateCodeFormProps from './types';
+
+function ValidateCodeForm(props: ValidateCodeFormProps) {
+ return (
+
+ );
+}
+
+ValidateCodeForm.displayName = 'ValidateCodeForm';
+
+export default ValidateCodeForm;
diff --git a/src/pages/signin/ValidateCodeForm/index.js b/src/pages/signin/ValidateCodeForm/index.js
deleted file mode 100644
index 35afc283972b..000000000000
--- a/src/pages/signin/ValidateCodeForm/index.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import BaseValidateCodeForm from './BaseValidateCodeForm';
-
-const defaultProps = {};
-
-const propTypes = {
- /** Determines if user is switched to using recovery code instead of 2fa code */
- isUsingRecoveryCode: PropTypes.bool.isRequired,
-
- /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
- setIsUsingRecoveryCode: PropTypes.func.isRequired,
-};
-function ValidateCodeForm(props) {
- return (
-
- );
-}
-
-ValidateCodeForm.displayName = 'ValidateCodeForm';
-ValidateCodeForm.propTypes = propTypes;
-ValidateCodeForm.defaultProps = defaultProps;
-
-export default ValidateCodeForm;
diff --git a/src/pages/signin/ValidateCodeForm/index.tsx b/src/pages/signin/ValidateCodeForm/index.tsx
new file mode 100644
index 000000000000..8c1528ae7409
--- /dev/null
+++ b/src/pages/signin/ValidateCodeForm/index.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import BaseValidateCodeForm from './BaseValidateCodeForm';
+import type ValidateCodeFormProps from './types';
+
+function ValidateCodeForm(props: ValidateCodeFormProps) {
+ return (
+
+ );
+}
+
+ValidateCodeForm.displayName = 'ValidateCodeForm';
+
+export default ValidateCodeForm;
diff --git a/src/pages/signin/ValidateCodeForm/types.ts b/src/pages/signin/ValidateCodeForm/types.ts
new file mode 100644
index 000000000000..6edb6eace231
--- /dev/null
+++ b/src/pages/signin/ValidateCodeForm/types.ts
@@ -0,0 +1,11 @@
+type ValidateCodeFormProps = {
+ /** Determines if user is switched to using recovery code instead of 2fa code */
+ isUsingRecoveryCode: boolean;
+
+ /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
+ setIsUsingRecoveryCode: (value: boolean) => void;
+
+ isVisible: boolean;
+};
+
+export default ValidateCodeFormProps;
diff --git a/src/pages/tasks/TaskAssigneeSelectorModal.js b/src/pages/tasks/TaskAssigneeSelectorModal.js
index 14d2867aa1f4..0e1e64dfa415 100644
--- a/src/pages/tasks/TaskAssigneeSelectorModal.js
+++ b/src/pages/tasks/TaskAssigneeSelectorModal.js
@@ -11,6 +11,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {useBetas, usePersonalDetails, useSession} from '@components/OnyxProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
@@ -203,6 +204,7 @@ function TaskAssigneeSelectorModal({reports, task, rootParentReportPolicy}) {
;
+
+ /** Indicated whether the report data is loading */
+ isLoadingReportData: OnyxEntry;
+};
+
+type AdminPolicyAccessOrNotFoundComponentProps = AdminAccessOrNotFoundOnyxProps & {
+ /** The children to render */
+ children: ((props: AdminAccessOrNotFoundOnyxProps) => React.ReactNode) | React.ReactNode;
+
+ /** The report currently being looked at */
+ policyID: string;
+};
+
+function AdminPolicyAccessOrNotFoundComponent(props: AdminPolicyAccessOrNotFoundComponentProps) {
+ const isPolicyIDInRoute = !!props.policyID?.length;
+
+ 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]);
+
+ const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.policy ?? {}).length || !props.policy?.id);
+
+ const shouldShowNotFoundPage = isEmptyObject(props.policy) || !props.policy?.id || !PolicyUtils.isPolicyAdmin(props.policy);
+
+ if (shouldShowFullScreenLoadingIndicator) {
+ return ;
+ }
+
+ if (shouldShowNotFoundPage) {
+ return Navigation.goBack(ROUTES.WORKSPACE_PROFILE.getRoute(props.policyID))} />;
+ }
+
+ 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,
+ },
+})(AdminPolicyAccessOrNotFoundComponent);
diff --git a/src/pages/workspace/PaidPolicyAccessOrNotFoundWrapper.tsx b/src/pages/workspace/PaidPolicyAccessOrNotFoundWrapper.tsx
new file mode 100644
index 000000000000..eafd42c6e996
--- /dev/null
+++ b/src/pages/workspace/PaidPolicyAccessOrNotFoundWrapper.tsx
@@ -0,0 +1,66 @@
+/* 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 FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import Navigation from '@libs/Navigation/Navigation';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+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 {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+type PaidPolicyAccessOrNotFoundOnyxProps = {
+ /** The report currently being looked at */
+ policy: OnyxEntry;
+
+ /** Indicated whether the report data is loading */
+ isLoadingReportData: OnyxEntry;
+};
+
+type PaidPolicyAccessOrNotFoundComponentProps = PaidPolicyAccessOrNotFoundOnyxProps & {
+ /** The children to render */
+ children: ((props: PaidPolicyAccessOrNotFoundOnyxProps) => React.ReactNode) | React.ReactNode;
+
+ /** The report currently being looked at */
+ policyID: string;
+};
+
+function PaidPolicyAccessOrNotFoundComponent(props: PaidPolicyAccessOrNotFoundComponentProps) {
+ const isPolicyIDInRoute = !!props.policyID?.length;
+
+ 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]);
+
+ const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.policy ?? {}).length || !props.policy?.id);
+
+ const shouldShowNotFoundPage = isEmptyObject(props.policy) || !props.policy?.id || !PolicyUtils.isPaidGroupPolicy(props.policy) || !props.policy.isPolicyExpenseChatEnabled;
+
+ if (shouldShowFullScreenLoadingIndicator) {
+ return ;
+ }
+
+ if (shouldShowNotFoundPage) {
+ return Navigation.goBack(ROUTES.WORKSPACE_PROFILE.getRoute(props.policyID))} />;
+ }
+
+ 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,
+ },
+})(PaidPolicyAccessOrNotFoundComponent);
diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx
index 70d871849ee6..571e4cafce74 100644
--- a/src/pages/workspace/WorkspaceInitialPage.tsx
+++ b/src/pages/workspace/WorkspaceInitialPage.tsx
@@ -50,7 +50,7 @@ type WorkspaceInitialPageOnyxProps = {
type WorkspaceInitialPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceInitialPageOnyxProps & StackScreenProps;
function dismissError(policyID: string) {
- Navigation.goBack(ROUTES.SETTINGS_WORKSPACES);
+ PolicyUtils.goBackFromInvalidPolicy();
Policy.removeWorkspace(policyID);
}
@@ -96,7 +96,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r
const hasMembersError = PolicyUtils.hasPolicyMemberError(policyMembers);
const hasGeneralSettingsError = !isEmptyObject(policy?.errorFields?.generalSettings ?? {}) || !isEmptyObject(policy?.errorFields?.avatar ?? {});
-
const shouldShowProtectedItems = PolicyUtils.isPolicyAdmin(policy);
const isPaidGroupPolicy = PolicyUtils.isPaidGroupPolicy(policy);
const isFreeGroupPolicy = PolicyUtils.isFreeGroupPolicy(policy);
@@ -151,6 +150,12 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r
];
const protectedCollectPolicyMenuItems: WorkspaceMenuItem[] = [
+ {
+ translationKey: 'workspace.common.workflows',
+ icon: Expensicons.Workflows,
+ action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)))),
+ routeName: SCREENS.WORKSPACE.WORKFLOWS,
+ },
{
translationKey: 'workspace.common.members',
icon: Expensicons.Users,
@@ -158,6 +163,12 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r
brickRoadIndicator: hasMembersError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
routeName: SCREENS.WORKSPACE.MEMBERS,
},
+ {
+ translationKey: 'workspace.common.categories',
+ icon: Expensicons.Folder,
+ action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)))),
+ routeName: SCREENS.WORKSPACE.CATEGORIES,
+ },
];
const menuItems: WorkspaceMenuItem[] = [
@@ -188,7 +199,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r
style={[styles.pb0]}
>
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
+ onBackButtonPress={PolicyUtils.goBackFromInvalidPolicy}
+ onLinkPress={PolicyUtils.goBackFromInvalidPolicy}
shouldShow={shouldShowNotFoundPage}
subtitleKey={isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized'}
>
diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx
index adb7914fcac8..72f08095b58a 100644
--- a/src/pages/workspace/WorkspaceInviteMessagePage.tsx
+++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx
@@ -116,7 +116,8 @@ function WorkspaceInviteMessagePage({workspaceInviteMessageDraft, invitedEmailsT
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
+ onBackButtonPress={PolicyUtils.goBackFromInvalidPolicy}
+ onLinkPress={PolicyUtils.goBackFromInvalidPolicy}
>
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
+ onBackButtonPress={PolicyUtils.goBackFromInvalidPolicy}
+ onLinkPress={PolicyUtils.goBackFromInvalidPolicy}
>
{
diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js
deleted file mode 100644
index c88b3d56cb20..000000000000
--- a/src/pages/workspace/WorkspaceMembersPage.js
+++ /dev/null
@@ -1,535 +0,0 @@
-import {useIsFocused} from '@react-navigation/native';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import {InteractionManager, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
-import Button from '@components/Button';
-import ConfirmModal from '@components/ConfirmModal';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import * as Expensicons from '@components/Icon/Expensicons';
-import * as Illustrations from '@components/Icon/Illustrations';
-import MessagesRow from '@components/MessagesRow';
-import networkPropTypes from '@components/networkPropTypes';
-import {withNetwork} from '@components/OnyxProvider';
-import ScreenWrapper from '@components/ScreenWrapper';
-import SelectionList from '@components/SelectionList';
-import Text from '@components/Text';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
-import usePrevious from '@hooks/usePrevious';
-import useThemeStyles from '@hooks/useThemeStyles';
-import useWindowDimensions from '@hooks/useWindowDimensions';
-import compose from '@libs/compose';
-import * as DeviceCapabilities from '@libs/DeviceCapabilities';
-import Log from '@libs/Log';
-import Navigation from '@libs/Navigation/Navigation';
-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 personalDetailsPropType from '@pages/personalDetailsPropType';
-import * as Policy from '@userActions/Policy';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import SearchInputManager from './SearchInputManager';
-import {policyDefaultProps, policyPropTypes} from './withPolicy';
-import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
-
-const propTypes = {
- /** All personal details asssociated with user */
- personalDetails: PropTypes.objectOf(personalDetailsPropType),
-
- /** URL Route params */
- route: PropTypes.shape({
- /** Params from the URL path */
- params: PropTypes.shape({
- /** policyID passed via route: /workspace/:policyID/members */
- policyID: PropTypes.string,
- }),
- }).isRequired,
-
- /** Session info for the currently logged in user. */
- session: PropTypes.shape({
- /** Currently logged in user accountID */
- accountID: PropTypes.number,
- }),
-
- isLoadingReportData: PropTypes.bool,
- ...policyPropTypes,
- ...withLocalizePropTypes,
- ...windowDimensionsPropTypes,
- ...withCurrentUserPersonalDetailsPropTypes,
- network: networkPropTypes.isRequired,
-};
-
-const defaultProps = {
- personalDetails: {},
- session: {
- accountID: 0,
- },
- isLoadingReportData: true,
- ...policyDefaultProps,
- ...withCurrentUserPersonalDetailsDefaultProps,
-};
-
-function WorkspaceMembersPage(props) {
- const styles = useThemeStyles();
- const [selectedEmployees, setSelectedEmployees] = useState([]);
- const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false);
- const [errors, setErrors] = useState({});
- const [searchValue, setSearchValue] = useState('');
- const prevIsOffline = usePrevious(props.network.isOffline);
- const accountIDs = useMemo(() => _.map(_.keys(props.policyMembers), (accountID) => Number(accountID)), [props.policyMembers]);
- const prevAccountIDs = usePrevious(accountIDs);
- const textInputRef = useRef(null);
- const isOfflineAndNoMemberDataAvailable = _.isEmpty(props.policyMembers) && props.network.isOffline;
- const prevPersonalDetails = usePrevious(props.personalDetails);
- const {isSmallScreenWidth} = useWindowDimensions();
-
- const isFocusedScreen = useIsFocused();
-
- useEffect(() => {
- setSearchValue(SearchInputManager.searchInput);
- }, [isFocusedScreen]);
-
- useEffect(() => () => (SearchInputManager.searchInput = ''), []);
-
- /**
- * Get filtered personalDetails list with current policyMembers
- * @param {Object} policyMembers
- * @param {Object} personalDetails
- * @returns {Object}
- */
- const filterPersonalDetails = (policyMembers, personalDetails) =>
- _.reduce(
- _.keys(policyMembers),
- (result, key) => {
- if (personalDetails[key]) {
- return {
- ...result,
- [key]: personalDetails[key],
- };
- }
- return result;
- },
- {},
- );
-
- /**
- * Get members for the current workspace
- */
- const getWorkspaceMembers = useCallback(() => {
- Policy.openWorkspaceMembersPage(props.route.params.policyID, _.keys(PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails)));
- }, [props.route.params.policyID, props.policyMembers, props.personalDetails]);
-
- /**
- * Check if the current selection includes members that cannot be removed
- */
- const validateSelection = useCallback(() => {
- const newErrors = {};
- const ownerAccountID = _.first(PersonalDetailsUtils.getAccountIDsByLogins(props.policy.owner ? [props.policy.owner] : []));
- _.each(selectedEmployees, (member) => {
- if (member !== ownerAccountID && member !== props.session.accountID) {
- return;
- }
- newErrors[member] = props.translate('workspace.people.error.cannotRemove');
- });
- setErrors(newErrors);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedEmployees, props.policy.owner, props.session.accountID]);
-
- useEffect(() => {
- getWorkspaceMembers();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- useEffect(() => {
- validateSelection();
- }, [props.preferredLocale, validateSelection]);
-
- useEffect(() => {
- if (removeMembersConfirmModalVisible && !_.isEqual(accountIDs, prevAccountIDs)) {
- setRemoveMembersConfirmModalVisible(false);
- }
- setSelectedEmployees((prevSelected) => {
- // Filter all personal details in order to use the elements needed for the current workspace
- const currentPersonalDetails = filterPersonalDetails(props.policyMembers, props.personalDetails);
- // We need to filter the previous selected employees by the new personal details, since unknown/new user id's change when transitioning from offline to online
- const prevSelectedElements = _.map(prevSelected, (id) => {
- const prevItem = lodashGet(prevPersonalDetails, id);
- const res = _.find(_.values(currentPersonalDetails), (item) => lodashGet(prevItem, 'login') === lodashGet(item, 'login'));
- return lodashGet(res, 'accountID', id);
- });
- return _.intersection(prevSelectedElements, _.values(PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails)));
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.policyMembers]);
-
- useEffect(() => {
- const isReconnecting = prevIsOffline && !props.network.isOffline;
- if (!isReconnecting) {
- return;
- }
- getWorkspaceMembers();
- }, [props.network.isOffline, prevIsOffline, getWorkspaceMembers]);
-
- /**
- * Open the modal to invite a user
- */
- const inviteUser = () => {
- setSearchValue('');
- Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(props.route.params.policyID));
- };
-
- /**
- * Remove selected users from the workspace
- * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details
- */
- const removeUsers = () => {
- if (!_.isEmpty(errors)) {
- return;
- }
-
- // Remove the admin from the list
- const accountIDsToRemove = _.without(selectedEmployees, props.session.accountID);
-
- Policy.removeMembers(accountIDsToRemove, props.route.params.policyID);
- setSelectedEmployees([]);
- setRemoveMembersConfirmModalVisible(false);
- };
-
- /**
- * Show the modal to confirm removal of the selected members
- */
- const askForConfirmationToRemove = () => {
- if (!_.isEmpty(errors)) {
- return;
- }
- setRemoveMembersConfirmModalVisible(true);
- };
-
- /**
- * Add or remove all users passed from the selectedEmployees list
- * @param {Object} memberList
- */
- const toggleAllUsers = (memberList) => {
- const enabledAccounts = _.filter(memberList, (member) => !member.isDisabled);
- const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedEmployees, member.accountID));
-
- if (everyoneSelected) {
- setSelectedEmployees([]);
- } else {
- const everyAccountId = _.map(enabledAccounts, (member) => member.accountID);
- setSelectedEmployees(everyAccountId);
- }
-
- validateSelection();
- };
-
- /**
- * Add user from the selectedEmployees list
- *
- * @param {String} login
- */
- const addUser = useCallback(
- (accountID) => {
- setSelectedEmployees((prevSelected) => [...prevSelected, accountID]);
- validateSelection();
- },
- [validateSelection],
- );
-
- /**
- * Remove user from the selectedEmployees list
- *
- * @param {String} login
- */
- const removeUser = useCallback(
- (accountID) => {
- setSelectedEmployees((prevSelected) => _.without(prevSelected, accountID));
- validateSelection();
- },
- [validateSelection],
- );
-
- /**
- * Toggle user from the selectedEmployees list
- *
- * @param {String} accountID
- * @param {String} pendingAction
- *
- */
- const toggleUser = useCallback(
- (accountID, pendingAction) => {
- if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- return;
- }
-
- // Add or remove the user if the checkbox is enabled
- if (_.contains(selectedEmployees, accountID)) {
- removeUser(accountID);
- } else {
- addUser(accountID);
- }
- },
- [selectedEmployees, addUser, removeUser],
- );
-
- /**
- * Dismisses the errors on one item
- *
- * @param {Object} item
- */
- const dismissError = useCallback(
- (item) => {
- if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- Policy.clearDeleteMemberError(props.route.params.policyID, item.accountID);
- } else {
- Policy.clearAddMemberError(props.route.params.policyID, item.accountID);
- }
- },
- [props.route.params.policyID],
- );
-
- /**
- * Check if the policy member is deleted from the workspace
- *
- * @param {Object} policyMember
- * @returns {Boolean}
- */
- const isDeletedPolicyMember = (policyMember) => !props.network.isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && _.isEmpty(policyMember.errors);
- const policyOwner = lodashGet(props.policy, 'owner');
- const currentUserLogin = lodashGet(props.currentUserPersonalDetails, 'login');
- const policyID = lodashGet(props.route, 'params.policyID');
- const invitedPrimaryToSecondaryLogins = _.invert(props.policy.primaryLoginsInvited);
-
- const getMemberOptions = () => {
- let result = [];
-
- _.each(props.policyMembers, (policyMember, accountIDKey) => {
- const accountID = Number(accountIDKey);
- if (isDeletedPolicyMember(policyMember)) {
- return;
- }
-
- const details = props.personalDetails[accountID];
-
- if (!details) {
- Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`);
- return;
- }
-
- // If search value is provided, filter out members that don't match the search value
- if (searchValue.trim()) {
- let memberDetails = '';
- if (details.login) {
- memberDetails += ` ${details.login.toLowerCase()}`;
- }
- if (details.firstName) {
- memberDetails += ` ${details.firstName.toLowerCase()}`;
- }
- if (details.lastName) {
- memberDetails += ` ${details.lastName.toLowerCase()}`;
- }
- if (details.displayName) {
- memberDetails += ` ${details.displayName.toLowerCase()}`;
- }
- if (details.phoneNumber) {
- memberDetails += ` ${details.phoneNumber.toLowerCase()}`;
- }
-
- if (!OptionsListUtils.isSearchStringMatch(searchValue.trim(), memberDetails)) {
- return;
- }
- }
-
- // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
- // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
- // see random people added to their policy, but guides having access to the policies help set them up.
- if (PolicyUtils.isExpensifyTeam(details.login || details.displayName)) {
- if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
- return;
- }
- }
-
- const isAdmin = props.session.email === details.login || policyMember.role === CONST.POLICY.ROLE.ADMIN;
-
- result.push({
- keyForList: accountIDKey,
- accountID,
- isSelected: _.contains(selectedEmployees, accountID),
- isDisabled:
- accountID === props.session.accountID ||
- details.login === props.policy.owner ||
- policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ||
- !_.isEmpty(policyMember.errors),
- text: props.formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
- alternateText: props.formatPhoneNumber(details.login),
- rightElement: isAdmin ? (
-
- {props.translate('common.admin')}
-
- ) : null,
- icons: [
- {
- source: UserUtils.getAvatar(details.avatar, accountID),
- name: props.formatPhoneNumber(details.login),
- type: CONST.ICON_TYPE_AVATAR,
- id: accountID,
- },
- ],
- errors: policyMember.errors,
- pendingAction: policyMember.pendingAction,
-
- // Note which secondary login was used to invite this primary login
- invitedSecondaryLogin: invitedPrimaryToSecondaryLogins[details.login] || '',
- });
- });
-
- result = _.sortBy(result, (value) => value.text.toLowerCase());
-
- return result;
- };
- const data = getMemberOptions();
-
- const getHeaderMessage = () => {
- if (isOfflineAndNoMemberDataAvailable) {
- return props.translate('workspace.common.mustBeOnlineToViewMembers');
- }
- return searchValue.trim() && !data.length ? props.translate('workspace.common.memberNotFound') : '';
- };
-
- const getHeaderContent = () => {
- if (_.isEmpty(invitedPrimaryToSecondaryLogins)) {
- return null;
- }
- return (
- Policy.dismissAddedWithPrimaryLoginMessages(policyID)}
- />
- );
- };
-
- const getHeaderButtons = () => (
-
-
-
-
- );
-
- return (
-
- Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- >
- {
- setSearchValue('');
- Navigation.goBack();
- }}
- shouldShowBackButton={isSmallScreenWidth}
- guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
- >
- {!isSmallScreenWidth && getHeaderButtons()}
-
- {isSmallScreenWidth && {getHeaderButtons()}}
- setRemoveMembersConfirmModalVisible(false)}
- prompt={props.translate('workspace.people.removeMembersPrompt')}
- confirmText={props.translate('common.remove')}
- cancelText={props.translate('common.cancel')}
- onModalHide={() =>
- InteractionManager.runAfterInteractions(() => {
- if (!textInputRef.current) {
- return;
- }
- textInputRef.current.focus();
- })
- }
- />
-
- {
- SearchInputManager.searchInput = value;
- setSearchValue(value);
- }}
- disableKeyboardShortcuts={removeMembersConfirmModalVisible}
- headerMessage={getHeaderMessage()}
- headerContent={getHeaderContent()}
- onSelectRow={(item) => toggleUser(item.accountID)}
- onSelectAll={() => toggleAllUsers(data)}
- onDismissError={dismissError}
- showLoadingPlaceholder={!isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(props.personalDetails) || _.isEmpty(props.policyMembers))}
- showScrollIndicator
- shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
- inputRef={textInputRef}
- />
-
-
-
- );
-}
-
-WorkspaceMembersPage.propTypes = propTypes;
-WorkspaceMembersPage.defaultProps = defaultProps;
-WorkspaceMembersPage.displayName = 'WorkspaceMembersPage';
-
-export default compose(
- withLocalize,
- withWindowDimensions,
- withPolicyAndFullscreenLoading,
- withNetwork(),
- withOnyx({
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- isLoadingReportData: {
- key: ONYXKEYS.IS_LOADING_REPORT_DATA,
- },
- }),
- withCurrentUserPersonalDetails,
-)(WorkspaceMembersPage);
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
new file mode 100644
index 000000000000..3bf5a1b130fe
--- /dev/null
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -0,0 +1,488 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import lodashIsEqual from 'lodash/isEqual';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import type {TextInput} from 'react-native';
+import {InteractionManager, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import Badge from '@components/Badge';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import Button from '@components/Button';
+import ConfirmModal from '@components/ConfirmModal';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
+import * as Illustrations from '@components/Icon/Illustrations';
+import MessagesRow from '@components/MessagesRow';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import TableListItem from '@components/SelectionList/TableListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import Text from '@components/Text';
+import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
+import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import usePrevious from '@hooks/usePrevious';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import Log from '@libs/Log';
+import Navigation from '@libs/Navigation/Navigation';
+import type {CentralPaneNavigatorParamList} 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 * 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 {PersonalDetailsList, PolicyMember, PolicyMembers, Session} from '@src/types/onyx';
+import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
+
+type WorkspaceMembersPageOnyxProps = {
+ /** Personal details of all users */
+ personalDetails: OnyxEntry;
+ /** Session info for the currently logged in user. */
+ session: OnyxEntry;
+};
+
+type WorkspaceMembersPageProps = WithPolicyAndFullscreenLoadingProps &
+ WithCurrentUserPersonalDetailsProps &
+ WorkspaceMembersPageOnyxProps &
+ StackScreenProps;
+
+/**
+ * Inverts an object, equivalent of _.invert
+ */
+function invertObject(object: Record): Record {
+ const invertedEntries = Object.entries(object).map(([key, value]) => [value, key]);
+ const inverted: Record = Object.fromEntries(invertedEntries);
+ return inverted;
+}
+
+type MemberOption = Omit & {accountID: number};
+
+function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, session, currentUserPersonalDetails, isLoadingReportData = true}: WorkspaceMembersPageProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const [selectedEmployees, setSelectedEmployees] = useState([]);
+ const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false);
+ const [errors, setErrors] = useState({});
+ const {isOffline} = useNetwork();
+ const prevIsOffline = usePrevious(isOffline);
+ const accountIDs = useMemo(() => Object.keys(policyMembers ?? {}).map((accountID) => Number(accountID)), [policyMembers]);
+ const prevAccountIDs = usePrevious(accountIDs);
+ const textInputRef = useRef(null);
+ const isOfflineAndNoMemberDataAvailable = isEmptyObject(policyMembers) && isOffline;
+ const prevPersonalDetails = usePrevious(personalDetails);
+ const {translate, formatPhoneNumber, preferredLocale} = useLocalize();
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ /**
+ * Get filtered personalDetails list with current policyMembers
+ */
+ const filterPersonalDetails = (members: OnyxEntry, details: OnyxEntry): PersonalDetailsList =>
+ Object.keys(members ?? {}).reduce((result, key) => {
+ if (details?.[key]) {
+ return {
+ ...result,
+ [key]: details[key],
+ };
+ }
+ return result;
+ }, {});
+
+ /**
+ * Get members for the current workspace
+ */
+ const getWorkspaceMembers = useCallback(() => {
+ Policy.openWorkspaceMembersPage(route.params.policyID, Object.keys(PolicyUtils.getMemberAccountIDsForWorkspace(policyMembers, personalDetails)));
+ }, [route.params.policyID, policyMembers, personalDetails]);
+
+ /**
+ * Check if the current selection includes members that cannot be removed
+ */
+ const validateSelection = useCallback(() => {
+ const newErrors: Errors = {};
+ const ownerAccountID = PersonalDetailsUtils.getAccountIDsByLogins(policy?.owner ? [policy.owner] : [])[0];
+ selectedEmployees.forEach((member) => {
+ if (member !== ownerAccountID && member !== session?.accountID) {
+ return;
+ }
+ newErrors[member] = translate('workspace.people.error.cannotRemove');
+ });
+ setErrors(newErrors);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedEmployees, policy?.owner, session?.accountID]);
+
+ useEffect(() => {
+ getWorkspaceMembers();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ validateSelection();
+ }, [preferredLocale, validateSelection]);
+
+ useEffect(() => {
+ if (removeMembersConfirmModalVisible && !lodashIsEqual(accountIDs, prevAccountIDs)) {
+ setRemoveMembersConfirmModalVisible(false);
+ }
+ setSelectedEmployees((prevSelected) => {
+ // Filter all personal details in order to use the elements needed for the current workspace
+ const currentPersonalDetails = filterPersonalDetails(policyMembers, personalDetails);
+ // We need to filter the previous selected employees by the new personal details, since unknown/new user id's change when transitioning from offline to online
+ const prevSelectedElements = prevSelected.map((id) => {
+ const prevItem = prevPersonalDetails?.id;
+ const res = Object.values(currentPersonalDetails).find((item) => prevItem?.login === item?.login);
+ return res?.accountID ?? id;
+ });
+ // This is an equivalent of the lodash intersection function. The reduce method below is used to filter the items that exist in both arrays.
+ return [prevSelectedElements, Object.values(PolicyUtils.getMemberAccountIDsForWorkspace(policyMembers, personalDetails))].reduce((prev, members) =>
+ prev.filter((item) => members.includes(item)),
+ );
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [policyMembers]);
+
+ useEffect(() => {
+ const isReconnecting = prevIsOffline && !isOffline;
+ if (!isReconnecting) {
+ return;
+ }
+ getWorkspaceMembers();
+ }, [isOffline, prevIsOffline, getWorkspaceMembers]);
+
+ /**
+ * Open the modal to invite a user
+ */
+ const inviteUser = () => {
+ Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID));
+ };
+
+ /**
+ * Remove selected users from the workspace
+ * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details
+ */
+ const removeUsers = () => {
+ if (!isEmptyObject(errors)) {
+ return;
+ }
+
+ // Remove the admin from the list
+ const accountIDsToRemove = session?.accountID ? selectedEmployees.filter((id) => id !== session.accountID) : selectedEmployees;
+
+ Policy.removeMembers(accountIDsToRemove, route.params.policyID);
+ setSelectedEmployees([]);
+ setRemoveMembersConfirmModalVisible(false);
+ };
+
+ /**
+ * Show the modal to confirm removal of the selected members
+ */
+ const askForConfirmationToRemove = () => {
+ if (!isEmptyObject(errors)) {
+ return;
+ }
+ setRemoveMembersConfirmModalVisible(true);
+ };
+
+ /**
+ * Add or remove all users passed from the selectedEmployees list
+ */
+ const toggleAllUsers = (memberList: MemberOption[]) => {
+ const enabledAccounts = memberList.filter((member) => !member.isDisabled);
+ const everyoneSelected = enabledAccounts.every((member) => selectedEmployees.includes(member.accountID));
+
+ if (everyoneSelected) {
+ setSelectedEmployees([]);
+ } else {
+ const everyAccountId = enabledAccounts.map((member) => member.accountID);
+ setSelectedEmployees(everyAccountId);
+ }
+
+ validateSelection();
+ };
+
+ /**
+ * Add user from the selectedEmployees list
+ */
+ const addUser = useCallback(
+ (accountID: number) => {
+ setSelectedEmployees((prevSelected) => [...prevSelected, accountID]);
+ validateSelection();
+ },
+ [validateSelection],
+ );
+
+ /**
+ * Remove user from the selectedEmployees list
+ */
+ const removeUser = useCallback(
+ (accountID: number) => {
+ setSelectedEmployees((prevSelected) => prevSelected.filter((id) => id !== accountID));
+ validateSelection();
+ },
+ [validateSelection],
+ );
+
+ /**
+ * Toggle user from the selectedEmployees list
+ */
+ const toggleUser = useCallback(
+ (accountID: number, pendingAction?: PendingAction) => {
+ if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
+ return;
+ }
+
+ // Add or remove the user if the checkbox is enabled
+ if (selectedEmployees.includes(accountID)) {
+ removeUser(accountID);
+ } else {
+ addUser(accountID);
+ }
+ },
+ [selectedEmployees, addUser, removeUser],
+ );
+
+ /**
+ * Dismisses the errors on one item
+ */
+ const dismissError = useCallback(
+ (item: MemberOption) => {
+ if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
+ Policy.clearDeleteMemberError(route.params.policyID, item.accountID);
+ } else {
+ Policy.clearAddMemberError(route.params.policyID, item.accountID);
+ }
+ },
+ [route.params.policyID],
+ );
+
+ /**
+ * Check if the policy member is deleted from the workspace
+ */
+ const isDeletedPolicyMember = (policyMember: PolicyMember): boolean =>
+ !isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyMember.errors);
+ const policyOwner = policy?.owner;
+ const currentUserLogin = currentUserPersonalDetails.login;
+ const policyID = route.params.policyID;
+
+ const invitedPrimaryToSecondaryLogins = invertObject(policy?.primaryLoginsInvited ?? {});
+
+ const getUsers = (): MemberOption[] => {
+ let result: MemberOption[] = [];
+
+ Object.entries(policyMembers ?? {}).forEach(([accountIDKey, policyMember]) => {
+ const accountID = Number(accountIDKey);
+ if (isDeletedPolicyMember(policyMember)) {
+ return;
+ }
+
+ const details = personalDetails?.[accountID];
+
+ if (!details) {
+ Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`);
+ return;
+ }
+
+ // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
+ // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
+ // see random people added to their policy, but guides having access to the policies help set them up.
+ if (PolicyUtils.isExpensifyTeam(details?.login ?? details?.displayName)) {
+ if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
+ return;
+ }
+ }
+
+ const isOwner = policy?.owner === details.login;
+ const isAdmin = session?.email === details.login || policyMember.role === CONST.POLICY.ROLE.ADMIN;
+
+ let roleBadge = null;
+ if (isOwner || isAdmin) {
+ roleBadge = (
+
+ );
+ }
+
+ result.push({
+ keyForList: accountIDKey,
+ accountID,
+ isSelected: selectedEmployees.includes(accountID),
+ isDisabled:
+ accountID === session?.accountID ||
+ details.login === policy?.owner ||
+ policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ||
+ !isEmptyObject(policyMember.errors),
+ text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
+ alternateText: formatPhoneNumber(details?.login ?? ''),
+ rightElement: roleBadge,
+ icons: [
+ {
+ source: UserUtils.getAvatar(details.avatar, accountID),
+ name: formatPhoneNumber(details?.login ?? ''),
+ type: CONST.ICON_TYPE_AVATAR,
+ id: accountID,
+ },
+ ],
+ errors: policyMember.errors,
+ pendingAction: policyMember.pendingAction,
+
+ // Note which secondary login was used to invite this primary login
+ invitedSecondaryLogin: details?.login ? invitedPrimaryToSecondaryLogins[details.login] ?? '' : '',
+ });
+ });
+
+ result = result.sort((a, b) => a.text.toLowerCase().localeCompare(b.text.toLowerCase()));
+
+ return result;
+ };
+ const data = getUsers();
+
+ const getHeaderMessage = () => {
+ if (isOfflineAndNoMemberDataAvailable) {
+ return translate('workspace.common.mustBeOnlineToViewMembers');
+ }
+ return !data.length ? translate('workspace.common.memberNotFound') : '';
+ };
+
+ const getHeaderContent = () => (
+ <>
+ {translate('workspace.people.membersListTitle')}
+ {!isEmptyObject(invitedPrimaryToSecondaryLogins) && (
+ Policy.dismissAddedWithPrimaryLoginMessages(policyID)}
+ />
+ )}
+ >
+ );
+
+ const getCustomListHeader = () => (
+
+
+ {translate('common.member')}
+
+
+ {translate('common.role')}
+
+
+ );
+
+ const getHeaderButtons = () => (
+
+
+
+
+ );
+
+ return (
+
+
+ {
+ Navigation.goBack();
+ }}
+ shouldShowBackButton={isSmallScreenWidth}
+ guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
+ >
+ {!isSmallScreenWidth && getHeaderButtons()}
+
+ {isSmallScreenWidth && {getHeaderButtons()}}
+ setRemoveMembersConfirmModalVisible(false)}
+ prompt={translate('workspace.people.removeMembersPrompt')}
+ confirmText={translate('common.remove')}
+ cancelText={translate('common.cancel')}
+ onModalHide={() => {
+ InteractionManager.runAfterInteractions(() => {
+ if (!textInputRef.current) {
+ return;
+ }
+ textInputRef.current.focus();
+ });
+ }}
+ />
+
+ toggleUser(item.accountID)}
+ onSelectAll={() => toggleAllUsers(data)}
+ onDismissError={dismissError}
+ showLoadingPlaceholder={!isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(personalDetails) || isEmptyObject(policyMembers))}
+ showScrollIndicator
+ shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
+ ref={textInputRef}
+ customListHeader={getCustomListHeader()}
+ listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]}
+ />
+
+
+
+ );
+}
+
+WorkspaceMembersPage.displayName = 'WorkspaceMembersPage';
+
+export default withCurrentUserPersonalDetails(
+ withPolicyAndFullscreenLoading(
+ withOnyx({
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ })(WorkspaceMembersPage),
+ ),
+);
diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.tsx
similarity index 71%
rename from src/pages/workspace/WorkspaceNewRoomPage.js
rename to src/pages/workspace/WorkspaceNewRoomPage.tsx
index 36f874e8919d..1be4e6f486b0 100644
--- a/src/pages/workspace/WorkspaceNewRoomPage.js
+++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx
@@ -1,12 +1,14 @@
-import PropTypes from 'prop-types';
+import {useIsFocused} from '@react-navigation/core';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import BlockingView from '@components/BlockingViews/BlockingView';
import Button from '@components/Button';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
import * as Illustrations from '@components/Icon/Illustrations';
import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
import OfflineIndicator from '@components/OfflineIndicator';
@@ -14,16 +16,14 @@ import RoomNameInput from '@components/RoomNameInput';
import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';
import ValuePicker from '@components/ValuePicker';
-import withNavigationFocus from '@components/withNavigationFocus';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
-import {translatableTextPropTypes} from '@libs/Localize';
+import localeCompare from '@libs/LocaleCompare';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
@@ -33,93 +33,57 @@ import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
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 * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
-const propTypes = {
- /** All reports shared with the user */
- reports: PropTypes.shape({
- /** The report name */
- reportName: PropTypes.string,
-
- /** The report type */
- type: PropTypes.string,
-
- /** ID of the policy */
- policyID: PropTypes.string,
- }),
-
+type WorkspaceNewRoomPageOnyxProps = {
/** The list of policies the user has access to. */
- policies: PropTypes.objectOf(
- PropTypes.shape({
- /** The policy type */
- type: PropTypes.oneOf(_.values(CONST.POLICY.TYPE)),
+ policies: OnyxCollection;
- /** The name of the policy */
- name: PropTypes.string,
-
- /** The ID of the policy */
- id: PropTypes.string,
- }),
- ),
-
- /** Whether navigation is focused */
- isFocused: PropTypes.bool.isRequired,
+ /** All reports shared with the user */
+ reports: OnyxCollection;
/** Form state for NEW_ROOM_FORM */
- formState: PropTypes.shape({
- /** Loading state for the form */
- isLoading: PropTypes.bool,
-
- /** Field errors in the form */
- errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)),
- }),
+ formState: OnyxEntry;
/** Session details for the user */
- session: PropTypes.shape({
- /** accountID of current user */
- accountID: PropTypes.number,
- }),
+ session: OnyxEntry;
/** policyID for main workspace */
- activePolicyID: PropTypes.string,
-};
-const defaultProps = {
- reports: {},
- policies: {},
- formState: {
- isLoading: false,
- errorFields: {},
- },
- session: {
- accountID: 0,
- },
- activePolicyID: null,
+ activePolicyID: OnyxEntry['activePolicyID']>;
};
-function WorkspaceNewRoomPage(props) {
+type WorkspaceNewRoomPageProps = WorkspaceNewRoomPageOnyxProps;
+
+function WorkspaceNewRoomPage({policies, reports, formState, session, activePolicyID}: WorkspaceNewRoomPageProps) {
const styles = useThemeStyles();
+ const isFocused = useIsFocused();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {isSmallScreenWidth} = useWindowDimensions();
- const [visibility, setVisibility] = useState(CONST.REPORT.VISIBILITY.RESTRICTED);
- const [writeCapability, setWriteCapability] = useState(CONST.REPORT.WRITE_CAPABILITIES.ALL);
- const wasLoading = usePrevious(props.formState.isLoading);
+ const [visibility, setVisibility] = useState>(CONST.REPORT.VISIBILITY.RESTRICTED);
+ const [writeCapability, setWriteCapability] = useState>(CONST.REPORT.WRITE_CAPABILITIES.ALL);
+ const wasLoading = usePrevious(!!formState?.isLoading);
const visibilityDescription = useMemo(() => translate(`newRoomPage.${visibility}Description`), [translate, visibility]);
+ const {isLoading = false, errorFields = {}} = formState ?? {};
const workspaceOptions = useMemo(
() =>
- _.map(
- _.filter(PolicyUtils.getActivePolicies(props.policies), (policy) => policy.type !== CONST.POLICY.TYPE.PERSONAL),
- (policy) => ({
+ PolicyUtils.getActivePolicies(policies)
+ ?.filter((policy) => policy.type !== CONST.POLICY.TYPE.PERSONAL)
+ .map((policy) => ({
label: policy.name,
value: policy.id,
- }),
- ).sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())),
- [props.policies],
+ }))
+ .sort((a, b) => localeCompare(a.label, b.label)) ?? [],
+ [policies],
);
- const [policyID, setPolicyID] = useState(() => {
- if (_.some(workspaceOptions, (option) => option.value === props.activePolicyID)) {
- return props.activePolicyID;
+ const [policyID, setPolicyID] = useState(() => {
+ if (!!activePolicyID && workspaceOptions.some((option) => option.value === activePolicyID)) {
+ return activePolicyID;
}
return '';
});
@@ -128,16 +92,16 @@ function WorkspaceNewRoomPage(props) {
return false;
}
- return ReportUtils.isPolicyAdmin(policyID, props.policies);
- }, [policyID, props.policies]);
- const [newRoomReportID, setNewRoomReportID] = useState(undefined);
+ return ReportUtils.isPolicyAdmin(policyID, policies);
+ }, [policyID, policies]);
+ const [newRoomReportID, setNewRoomReportID] = useState();
/**
- * @param {Object} values - form input values passed by the Form component
+ * @param values - form input values passed by the Form component
*/
- const submit = (values) => {
- const participants = [props.session.accountID];
- const parsedDescription = ReportUtils.getParsedComment(values.reportDescription);
+ const submit = (values: FormOnyxValues) => {
+ const participants = [session?.accountID ?? 0];
+ const parsedDescription = ReportUtils.getParsedComment(values.reportDescription ?? '');
const policyReport = ReportUtils.buildOptimisticChatReport(
participants,
values.roomName,
@@ -163,25 +127,25 @@ function WorkspaceNewRoomPage(props) {
useEffect(() => {
if (policyID) {
- if (!_.some(workspaceOptions, (opt) => opt.value === policyID)) {
+ if (!workspaceOptions.some((opt) => opt.value === policyID)) {
setPolicyID('');
}
return;
}
- if (_.some(workspaceOptions, (opt) => opt.value === props.activePolicyID)) {
- setPolicyID(props.activePolicyID);
+ if (!!activePolicyID && workspaceOptions.some((opt) => opt.value === activePolicyID)) {
+ setPolicyID(activePolicyID);
} else {
setPolicyID('');
}
- }, [props.activePolicyID, policyID, workspaceOptions]);
+ }, [activePolicyID, policyID, workspaceOptions]);
useEffect(() => {
- if (!(((wasLoading && !props.formState.isLoading) || (isOffline && props.formState.isLoading)) && _.isEmpty(props.formState.errorFields))) {
+ if (!(((wasLoading && !isLoading) || (isOffline && isLoading)) && isEmptyObject(errorFields))) {
return;
}
Navigation.dismissModal(newRoomReportID);
// eslint-disable-next-line react-hooks/exhaustive-deps -- we just want this to update on changing the form State
- }, [props.formState]);
+ }, [isLoading, errorFields]);
useEffect(() => {
if (isPolicyAdmin) {
@@ -192,12 +156,12 @@ function WorkspaceNewRoomPage(props) {
}, [isPolicyAdmin]);
/**
- * @param {Object} values - form input values passed by the Form component
- * @returns {Boolean}
+ * @param values - form input values passed by the Form component
+ * @returns an object containing validation errors, if any were found during validation
*/
const validate = useCallback(
- (values) => {
- const errors = {};
+ (values: FormOnyxValues): OnyxCommon.Errors => {
+ const errors: {policyID?: string; roomName?: string} = {};
if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) {
// We error if the user doesn't enter a room name or left blank
@@ -208,7 +172,7 @@ function WorkspaceNewRoomPage(props) {
} else if (ValidationUtils.isReservedRoomName(values.roomName)) {
// Certain names are reserved for default rooms and should not be used for policy rooms.
ErrorUtils.addErrorMessage(errors, 'roomName', ['newRoomPage.roomNameReservedError', {reservedName: values.roomName}]);
- } else if (ValidationUtils.isExistingRoomName(values.roomName, props.reports, values.policyID)) {
+ } else if (ValidationUtils.isExistingRoomName(values.roomName, reports, values.policyID ?? '')) {
// Certain names are reserved for default rooms and should not be used for policy rooms.
ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomAlreadyExistsError');
} else if (values.roomName.length > CONST.TITLE_CHARACTER_LIMIT) {
@@ -221,12 +185,12 @@ function WorkspaceNewRoomPage(props) {
return errors;
},
- [props.reports],
+ [reports],
);
const writeCapabilityOptions = useMemo(
() =>
- _.map(CONST.REPORT.WRITE_CAPABILITIES, (value) => ({
+ Object.values(CONST.REPORT.WRITE_CAPABILITIES).map((value) => ({
value,
label: translate(`writeCapabilityPage.writeCapability.${value}`),
})),
@@ -235,14 +199,13 @@ function WorkspaceNewRoomPage(props) {
const visibilityOptions = useMemo(
() =>
- _.map(
- _.filter(_.values(CONST.REPORT.VISIBILITY), (visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE),
- (visibilityOption) => ({
+ Object.values(CONST.REPORT.VISIBILITY)
+ .filter((visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE)
+ .map((visibilityOption) => ({
label: translate(`newRoomPage.visibilityOptions.${visibilityOption}`),
value: visibilityOption,
description: translate(`newRoomPage.${visibilityOption}Description`),
- }),
- ),
+ })),
[translate],
);
@@ -302,7 +265,8 @@ function WorkspaceNewRoomPage(props) {
InputComponent={RoomNameInput}
ref={inputCallbackRef}
inputID={INPUT_IDS.ROOM_NAME}
- isFocused={props.isFocused}
+ isFocused={isFocused}
+ // @ts-expect-error TODO: Remove this once RoomNameInput (https://github.com/Expensify/App/issues/25090) is migrated to TypeScript.
shouldDelayFocus
autoFocus
/>
@@ -313,7 +277,7 @@ function WorkspaceNewRoomPage(props) {
inputID={INPUT_IDS.REPORT_DESCRIPTION}
label={translate('reportDescriptionPage.roomDescriptionOptional')}
accessibilityLabel={translate('reportDescriptionPage.roomDescriptionOptional')}
- role={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ROLE.PRESENTATION}
autoGrowHeight
maxLength={CONST.REPORT_DESCRIPTION.MAX_LENGTH}
autoCapitalize="none"
@@ -327,7 +291,7 @@ function WorkspaceNewRoomPage(props) {
label={translate('workspace.common.workspace')}
items={workspaceOptions}
value={policyID}
- onValueChange={setPolicyID}
+ onValueChange={(value) => setPolicyID(value as typeof policyID)}
/>
{isPolicyAdmin && (
@@ -338,7 +302,7 @@ function WorkspaceNewRoomPage(props) {
label={translate('writeCapabilityPage.label')}
items={writeCapabilityOptions}
value={writeCapability}
- onValueChange={setWriteCapability}
+ onValueChange={(value) => setWriteCapability(value as typeof writeCapability)}
/>
)}
@@ -348,7 +312,7 @@ function WorkspaceNewRoomPage(props) {
inputID={INPUT_IDS.VISIBILITY}
label={translate('newRoomPage.visibility')}
items={visibilityOptions}
- onValueChange={setVisibility}
+ onValueChange={(value) => setVisibility(value as typeof visibility)}
value={visibility}
furtherDetails={visibilityDescription}
shouldShowTooltips={false}
@@ -363,32 +327,24 @@ function WorkspaceNewRoomPage(props) {
);
}
-WorkspaceNewRoomPage.propTypes = propTypes;
-WorkspaceNewRoomPage.defaultProps = defaultProps;
WorkspaceNewRoomPage.displayName = 'WorkspaceNewRoomPage';
-export default compose(
- withNavigationFocus,
- withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- },
- formState: {
- key: ONYXKEYS.FORMS.NEW_ROOM_FORM,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- activePolicyID: {
- key: ONYXKEYS.ACCOUNT,
- selector: (account) => (account && account.activePolicyID) || null,
- initialValue: null,
- },
- }),
-)(WorkspaceNewRoomPage);
+export default withOnyx({
+ policies: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ },
+ reports: {
+ key: ONYXKEYS.COLLECTION.REPORT,
+ },
+ formState: {
+ key: ONYXKEYS.FORMS.NEW_ROOM_FORM,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ activePolicyID: {
+ key: ONYXKEYS.ACCOUNT,
+ selector: (account) => account?.activePolicyID ?? null,
+ initialValue: null,
+ },
+})(WorkspaceNewRoomPage);
diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx
index 4a77adac7b37..b9b14e27d01d 100644
--- a/src/pages/workspace/WorkspacePageWithSections.tsx
+++ b/src/pages/workspace/WorkspacePageWithSections.tsx
@@ -42,7 +42,7 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps &
headerText: string;
/** Main content of the page */
- children: (hasVBA: boolean, policyID: string, isUsingECard: boolean) => ReactNode;
+ children: ((hasVBA: boolean, policyID: string, isUsingECard: boolean) => ReactNode) | ReactNode;
/** Content to be added as fixed footer */
footer?: ReactNode;
@@ -68,6 +68,9 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps &
/** Whether to show this page to non admin policy members */
shouldShowNonAdmin?: boolean;
+ /** Whether to show the not found page */
+ shouldShowNotFoundPage?: boolean;
+
/** Policy values needed in the component */
policy: OnyxEntry;
@@ -91,6 +94,7 @@ function WorkspacePageWithSections({
backButtonRoute,
children = () => null,
footer = null,
+ icon = undefined,
guidesCallTaskID = '',
headerText,
policy,
@@ -104,7 +108,7 @@ function WorkspacePageWithSections({
shouldShowLoading = true,
shouldShowOfflineIndicatorInWideScreen = false,
shouldShowNonAdmin = false,
- icon,
+ shouldShowNotFoundPage = false,
}: WorkspacePageWithSectionsProps) {
const styles = useThemeStyles();
const policyID = route.params?.policyID ?? '';
@@ -114,17 +118,10 @@ function WorkspacePageWithSections({
const achState = reimbursementAccount?.achData?.state ?? '';
const isUsingECard = user?.isUsingExpensifyCard ?? false;
const hasVBA = achState === BankAccount.STATE.OPEN;
- const content = children(hasVBA, policyID, isUsingECard);
+ const content = typeof children === 'function' ? children(hasVBA, policyID, isUsingECard) : children;
const {isSmallScreenWidth} = useWindowDimensions();
const firstRender = useRef(true);
- const goBack = () => {
- Navigation.goBack(ROUTES.SETTINGS_WORKSPACES);
-
- // Needed when workspace with given policyID does not exist
- Navigation.navigateWithSwitchPolicyID({route: ROUTES.ALL_SETTINGS});
- };
-
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
firstRender.current = false;
@@ -136,7 +133,7 @@ function WorkspacePageWithSections({
const shouldShow = useMemo(() => {
// If the policy object doesn't exist or contains only error data, we shouldn't display it.
- if ((isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors))) && isEmptyObject(policyDraft)) {
+ if (((isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors))) && isEmptyObject(policyDraft)) || shouldShowNotFoundPage) {
return true;
}
@@ -153,8 +150,8 @@ function WorkspacePageWithSections({
shouldShowOfflineIndicatorInWideScreen={shouldShowOfflineIndicatorInWideScreen && !shouldShow}
>
Navigation.goBack(backButtonRoute ?? ROUTES.WORKSPACE_INITIAL.getRoute(policyID))}
- icon={icon}
+ icon={icon ?? undefined}
/>
{(isLoading || firstRender.current) && shouldShowLoading ? (
diff --git a/src/pages/workspace/WorkspaceProfileCurrencyPage.js b/src/pages/workspace/WorkspaceProfileCurrencyPage.js
deleted file mode 100644
index 31b88c7c487b..000000000000
--- a/src/pages/workspace/WorkspaceProfileCurrencyPage.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {useState} from 'react';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import ScreenWrapper from '@components/ScreenWrapper';
-import SelectionList from '@components/SelectionList';
-import useLocalize from '@hooks/useLocalize';
-import compose from '@libs/compose';
-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 {policyDefaultProps, policyPropTypes} from './withPolicy';
-import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
-
-const propTypes = {
- /** Constant, list of available currencies */
- currencyList: PropTypes.objectOf(
- PropTypes.shape({
- /** Symbol of the currency */
- symbol: PropTypes.string.isRequired,
- }),
- ),
- isLoadingReportData: PropTypes.bool,
- ...policyPropTypes,
-};
-
-const defaultProps = {
- currencyList: {},
- isLoadingReportData: true,
- ...policyDefaultProps,
-};
-
-const getDisplayText = (currencyCode, currencySymbol) => `${currencyCode} - ${currencySymbol}`;
-
-function WorkspaceSettingsCurrencyPage({currencyList, policy, isLoadingReportData}) {
- const {translate} = useLocalize();
- const [searchText, setSearchText] = useState('');
- const trimmedText = searchText.trim().toLowerCase();
- const currencyListKeys = _.keys(currencyList);
-
- const filteredItems = _.filter(currencyListKeys, (currencyCode) => {
- const currency = currencyList[currencyCode];
- return getDisplayText(currencyCode, currency.symbol).toLowerCase().includes(trimmedText);
- });
-
- let initiallyFocusedOptionKey;
-
- const currencyItems = _.map(filteredItems, (currencyCode) => {
- const currency = currencyList[currencyCode];
- const isSelected = policy.outputCurrency === currencyCode;
-
- if (isSelected) {
- initiallyFocusedOptionKey = currencyCode;
- }
-
- return {
- text: getDisplayText(currencyCode, currency.symbol),
- keyForList: currencyCode,
- isSelected,
- };
- });
-
- const sections = [{data: currencyItems, indexOffset: 0}];
-
- const headerMessage = searchText.trim() && !currencyItems.length ? translate('common.noResultsFound') : '';
-
- const onSelectCurrency = (item) => {
- Policy.updateGeneralSettings(policy.id, policy.name, item.keyForList);
- Navigation.goBack();
- };
-
- return (
-
- Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- shouldShow={(_.isEmpty(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy)}
- subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'}
- >
- Navigation.goBack()}
- />
-
-
-
-
- );
-}
-
-WorkspaceSettingsCurrencyPage.displayName = 'WorkspaceSettingsCurrencyPage';
-WorkspaceSettingsCurrencyPage.propTypes = propTypes;
-WorkspaceSettingsCurrencyPage.defaultProps = defaultProps;
-
-export default compose(
- withPolicyAndFullscreenLoading,
- withOnyx({
- currencyList: {key: ONYXKEYS.CURRENCY_LIST},
- }),
-)(WorkspaceSettingsCurrencyPage);
diff --git a/src/pages/workspace/WorkspaceProfileCurrencyPage.tsx b/src/pages/workspace/WorkspaceProfileCurrencyPage.tsx
new file mode 100644
index 000000000000..cc73f4a64a80
--- /dev/null
+++ b/src/pages/workspace/WorkspaceProfileCurrencyPage.tsx
@@ -0,0 +1,111 @@
+import React, {useState} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import useLocalize from '@hooks/useLocalize';
+import Navigation from '@libs/Navigation/Navigation';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as Policy from '@userActions/Policy';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {CurrencyList} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
+
+type WorkspaceProfileCurrentPageOnyxProps = {
+ /** Constant, list of available currencies */
+ currencyList: OnyxEntry;
+};
+
+type WorkspaceProfileCurrentPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceProfileCurrentPageOnyxProps;
+
+type WorkspaceProfileCurrencyPageSectionItem = {
+ text: string;
+ keyForList: string;
+ isSelected: boolean;
+};
+
+const getDisplayText = (currencyCode: string, currencySymbol: string) => `${currencyCode} - ${currencySymbol}`;
+
+function WorkspaceProfileCurrencyPage({currencyList = {}, policy, isLoadingReportData = true}: WorkspaceProfileCurrentPageProps) {
+ const {translate} = useLocalize();
+ const [searchText, setSearchText] = useState('');
+ const trimmedText = searchText.trim().toLowerCase();
+ const currencyListKeys = Object.keys(currencyList ?? {});
+
+ const filteredItems = currencyListKeys.filter((currencyCode: string) => {
+ const currency = currencyList?.[currencyCode];
+ return getDisplayText(currencyCode, currency?.symbol ?? '')
+ .toLowerCase()
+ .includes(trimmedText);
+ });
+
+ let initiallyFocusedOptionKey;
+
+ const currencyItems: WorkspaceProfileCurrencyPageSectionItem[] = filteredItems.map((currencyCode: string) => {
+ const currency = currencyList?.[currencyCode];
+ const isSelected = policy?.outputCurrency === currencyCode;
+
+ if (isSelected) {
+ initiallyFocusedOptionKey = currencyCode;
+ }
+
+ return {
+ text: getDisplayText(currencyCode, currency?.symbol ?? ''),
+ keyForList: currencyCode,
+ isSelected,
+ };
+ });
+
+ const sections = [{data: currencyItems, indexOffset: 0}];
+
+ const headerMessage = searchText.trim() && !currencyItems.length ? translate('common.noResultsFound') : '';
+
+ const onSelectCurrency = (item: WorkspaceProfileCurrencyPageSectionItem) => {
+ Policy.updateGeneralSettings(policy?.id ?? '', policy?.name ?? '', item.keyForList);
+ Navigation.goBack();
+ };
+
+ return (
+
+
+ Navigation.goBack()}
+ />
+
+
+
+
+ );
+}
+
+WorkspaceProfileCurrencyPage.displayName = 'WorkspaceProfileCurrencyPage';
+
+export default withPolicyAndFullscreenLoading(
+ withOnyx({
+ currencyList: {key: ONYXKEYS.CURRENCY_LIST},
+ })(WorkspaceProfileCurrencyPage),
+);
diff --git a/src/pages/workspace/WorkspaceProfilePage.js b/src/pages/workspace/WorkspaceProfilePage.tsx
similarity index 68%
rename from src/pages/workspace/WorkspaceProfilePage.js
rename to src/pages/workspace/WorkspaceProfilePage.tsx
index c91f7ed8fb44..48dfe10a2a0e 100644
--- a/src/pages/workspace/WorkspaceProfilePage.js
+++ b/src/pages/workspace/WorkspaceProfilePage.tsx
@@ -1,12 +1,12 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
+import type {ImageStyle, StyleProp} from 'react-native';
import {Image, ScrollView, StyleSheet, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import WorkspaceProfile from '@assets/images/workspace-profile.png';
import Avatar from '@components/Avatar';
import AvatarWithImagePicker from '@components/AvatarWithImagePicker';
+import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
@@ -16,59 +16,46 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import StringUtils from '@libs/StringUtils';
import * as UserUtils from '@libs/UserUtils';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import withPolicy, {policyDefaultProps, policyPropTypes} from './withPolicy';
+import type {CurrencyList} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import withPolicy from './withPolicy';
+import type {WithPolicyProps} from './withPolicy';
import WorkspacePageWithSections from './WorkspacePageWithSections';
-const propTypes = {
+type WorkSpaceProfilePageOnyxProps = {
/** Constant, list of available currencies */
- currencyList: PropTypes.objectOf(
- PropTypes.shape({
- /** Symbol of the currency */
- symbol: PropTypes.string.isRequired,
- }),
- ),
-
- /** The route object passed to this page from the navigator */
- route: PropTypes.shape({
- /** Each parameter passed via the URL */
- params: PropTypes.shape({
- /** The policyID that is being configured */
- policyID: PropTypes.string.isRequired,
- }).isRequired,
- }).isRequired,
-
- ...policyPropTypes,
+ currencyList: OnyxEntry;
};
-const defaultProps = {
- currencyList: {},
- ...policyDefaultProps,
-};
+type WorkSpaceProfilePageProps = WithPolicyProps & WorkSpaceProfilePageOnyxProps;
-function WorkspaceProfilePage({policy, currencyList, route}) {
+function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfilePageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
- const formattedCurrency = !_.isEmpty(policy) && !_.isEmpty(currencyList) && !!policy.outputCurrency ? `${policy.outputCurrency} - ${currencyList[policy.outputCurrency].symbol}` : '';
+ const outputCurrency = policy?.outputCurrency ?? '';
+ const currencySymbol = currencyList?.[outputCurrency]?.symbol ?? '';
+ const formattedCurrency = !isEmptyObject(policy) && !isEmptyObject(currencyList) ? `${outputCurrency} - ${currencySymbol}` : '';
- const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_CURRENCY.getRoute(policy.id)), [policy.id]);
- const onPressName = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_NAME.getRoute(policy.id)), [policy.id]);
- const onPressDescription = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy.id)), [policy.id]);
+ const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_CURRENCY.getRoute(policy?.id ?? '')), [policy?.id]);
+ const onPressName = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_NAME.getRoute(policy?.id ?? '')), [policy?.id]);
+ const onPressDescription = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy?.id ?? '')), [policy?.id]);
+ const onPressShare = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_SHARE.getRoute(policy?.id ?? '')), [policy?.id]);
- const policyName = lodashGet(policy, 'name', '');
- const policyDescription = lodashGet(policy, 'description', '');
+ const policyName = policy?.name ?? '';
+ const policyDescription = policy?.description ?? '';
const readOnly = !PolicyUtils.isPolicyAdmin(policy);
- const imageStyle = isSmallScreenWidth ? [styles.mhv12, styles.mhn5] : [styles.mhv8, styles.mhn8];
+ const imageStyle: StyleProp = isSmallScreenWidth ? [styles.mhv12, styles.mhn5] : [styles.mhv8, styles.mhn8];
return (
- {(hasVBA) => (
+ {(hasVBA?: boolean) => (
-
+
Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy.id))}
- source={lodashGet(policy, 'avatar')}
+ onViewPhotoPress={() => Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy?.id ?? ''))}
+ source={policy?.avatar ?? ''}
size={CONST.AVATAR_SIZE.XLARGE}
avatarStyle={styles.avatarXLarge}
enablePreview
@@ -100,7 +90,7 @@ function WorkspaceProfilePage({policy, currencyList, route}) {
Policy.updateWorkspaceAvatar(lodashGet(policy, 'id', ''), file)}
- onImageRemoved={() => Policy.deleteWorkspaceAvatar(lodashGet(policy, 'id', ''))}
+ isUsingDefaultAvatar={!policy?.avatar ?? null}
+ onImageSelected={(file) => Policy.updateWorkspaceAvatar(policy?.id ?? '', file as File)}
+ onImageRemoved={() => Policy.deleteWorkspaceAvatar(policy?.id ?? '')}
editorMaskImage={Expensicons.ImageCropSquareMask}
- pendingAction={lodashGet(policy, 'pendingFields.avatar', null)}
- errors={lodashGet(policy, 'errorFields.avatar', null)}
- onErrorClose={() => Policy.clearAvatarErrors(policy.id)}
- previewSource={UserUtils.getFullSizeAvatar(policy.avatar, '')}
+ pendingAction={policy?.pendingFields?.avatar}
+ errors={policy?.errorFields?.avatar}
+ onErrorClose={() => Policy.clearAvatarErrors(policy?.id ?? '')}
+ previewSource={UserUtils.getFullSizeAvatar(policy?.avatar ?? '')}
headerTitle={translate('workspace.common.workspaceAvatar')}
- originalFileName={policy.originalFileName}
+ originalFileName={policy?.originalFileName}
disabled={readOnly}
disabledStyle={styles.cursorDefault}
+ errorRowStyles={undefined}
/>
-
+
- {(!_.isEmpty(policy.description) || !readOnly) && (
-
+ {(!StringUtils.isEmptyString(policy?.description ?? '') || !readOnly) && (
+
)}
-
+
+ {!readOnly && (
+
+
+
+ )}
@@ -176,13 +178,10 @@ function WorkspaceProfilePage({policy, currencyList, route}) {
);
}
-WorkspaceProfilePage.propTypes = propTypes;
-WorkspaceProfilePage.defaultProps = defaultProps;
WorkspaceProfilePage.displayName = 'WorkspaceProfilePage';
-export default compose(
- withPolicy,
- withOnyx({
+export default withPolicy(
+ withOnyx({
currencyList: {key: ONYXKEYS.CURRENCY_LIST},
- }),
-)(WorkspaceProfilePage);
+ })(WorkspaceProfilePage),
+);
diff --git a/src/pages/workspace/WorkspaceProfileSharePage.tsx b/src/pages/workspace/WorkspaceProfileSharePage.tsx
new file mode 100644
index 000000000000..dd03436042ca
--- /dev/null
+++ b/src/pages/workspace/WorkspaceProfileSharePage.tsx
@@ -0,0 +1,88 @@
+import React, {useRef} from 'react';
+import {ScrollView, View} from 'react-native';
+import type {ImageSourcePropType} from 'react-native';
+import expensifyLogo from '@assets/images/expensify-logo-round-transparent.png';
+import ContextMenuItem from '@components/ContextMenuItem';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItem from '@components/MenuItem';
+import QRShareWithDownload from '@components/QRShare/QRShareWithDownload';
+import type QRShareWithDownloadHandle from '@components/QRShare/QRShareWithDownload/types';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useEnvironment from '@hooks/useEnvironment';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import Clipboard from '@libs/Clipboard';
+import Navigation from '@libs/Navigation/Navigation';
+import shouldAllowDownloadQRCode from '@libs/shouldAllowDownloadQRCode';
+import * as Url from '@libs/Url';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import withPolicy from './withPolicy';
+import type {WithPolicyProps} from './withPolicy';
+
+function WorkspaceProfileSharePage({policy}: WithPolicyProps) {
+ const themeStyles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {environmentURL} = useEnvironment();
+ const qrCodeRef = useRef(null);
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ const policyName = policy?.name ?? '';
+ const id = policy?.id ?? '';
+ const urlWithTrailingSlash = Url.addTrailingForwardSlash(environmentURL);
+
+ const url = `${urlWithTrailingSlash}${ROUTES.WORKSPACE_PROFILE.getRoute(id)}`;
+ return (
+
+
+
+
+
+
+
+
+
+ Clipboard.setString(url)}
+ shouldLimitWidth={false}
+ wrapperStyle={themeStyles.sectionMenuItemTopDescription}
+ />
+ {shouldAllowDownloadQRCode && (
+ qrCodeRef.current?.download?.()}
+ wrapperStyle={themeStyles.sectionMenuItemTopDescription}
+ />
+ )}
+
+
+
+
+ );
+}
+
+WorkspaceProfileSharePage.displayName = 'WorkspaceProfileSharePage';
+
+export default withPolicy(WorkspaceProfileSharePage);
diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx
index 88ea1bf1ec54..d1edf7f2f783 100755
--- a/src/pages/workspace/WorkspacesListPage.tsx
+++ b/src/pages/workspace/WorkspacesListPage.tsx
@@ -23,6 +23,7 @@ import useNetwork from '@hooks/useNetwork';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import localeCompare from '@libs/LocaleCompare';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
@@ -308,7 +309,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r
type: policy.type,
}),
)
- .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
+ .sort((a, b) => localeCompare(a.title, b.title));
}, [reimbursementAccount?.errors, policies, isOffline, theme.textLight, allPolicyMembers, policyRooms]);
if (isEmptyObject(workspaces)) {
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
new file mode 100644
index 000000000000..b8a65c28806b
--- /dev/null
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -0,0 +1,136 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useMemo, useState} from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import * as Illustrations from '@components/Icon/Illustrations';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import TableListItem from '@components/SelectionList/TableListItem';
+import Text from '@components/Text';
+import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import type {CentralPaneNavigatorParamList} from '@navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import type * as OnyxTypes from '@src/types/onyx';
+
+type PolicyForList = {
+ value: string;
+ text: string;
+ keyForList: string;
+ isSelected: boolean;
+ rightElement: React.ReactNode;
+};
+
+type WorkspaceCategoriesOnyxProps = {
+ /** Collection of categories attached to a policy */
+ policyCategories: OnyxEntry;
+};
+
+type WorkspaceCategoriesPageProps = WorkspaceCategoriesOnyxProps & StackScreenProps;
+
+function WorkspaceCategoriesPage({policyCategories, route}: WorkspaceCategoriesPageProps) {
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const styles = useThemeStyles();
+ const theme = useTheme();
+ const {translate} = useLocalize();
+ const [selectedCategories, setSelectedCategories] = useState>({});
+
+ const categoryList = useMemo(
+ () =>
+ Object.values(policyCategories ?? {}).map((value) => ({
+ value: value.name,
+ text: value.name,
+ keyForList: value.name,
+ isSelected: !!selectedCategories[value.name],
+ rightElement: (
+
+ {value.enabled ? translate('workspace.common.enabled') : translate('workspace.common.disabled')}
+
+
+
+
+ ),
+ })),
+ [policyCategories, selectedCategories, styles.alignSelfCenter, styles.disabledText, styles.flexRow, styles.p1, styles.pl2, theme.icon, translate],
+ );
+
+ const toggleCategory = (category: PolicyForList) => {
+ setSelectedCategories((prev) => ({
+ ...prev,
+ [category.value]: !prev[category.value],
+ }));
+ };
+
+ const toggleAllCategories = () => {
+ const isAllSelected = categoryList.every((category) => !!selectedCategories[category.value]);
+ setSelectedCategories(isAllSelected ? {} : Object.fromEntries(categoryList.map((item) => [item.value, true])));
+ };
+
+ const getCustomListHeader = () => (
+
+ {translate('common.name')}
+ {translate('statusPage.status')}
+
+ );
+
+ return (
+
+
+
+
+
+ {translate('workspace.categories.subtitle')}
+
+ {categoryList.length ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+WorkspaceCategoriesPage.displayName = 'WorkspaceCategoriesPage';
+
+export default withOnyx({
+ policyCategories: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params.policyID}`,
+ },
+})(WorkspaceCategoriesPage);
diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx
index 07f31d53193e..3bd5a9e01bab 100644
--- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx
+++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx
@@ -2,6 +2,7 @@ import React, {useEffect, useMemo} from 'react';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -90,6 +91,7 @@ function WorkspaceUnitPage(props: WorkspaceUnitPageProps) {
updateUnit(unit.value)}
initiallyFocusedOptionKey={unitOptions.find((unit) => unit.isSelected)?.keyForList}
/>
diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx
index 3b10b9fb209c..76126040652b 100644
--- a/src/pages/workspace/withPolicy.tsx
+++ b/src/pages/workspace/withPolicy.tsx
@@ -6,6 +6,7 @@ import React, {forwardRef} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
+import taxPropTypes from '@components/taxPropTypes';
import {translatableTextPropTypes} from '@libs/Localize';
import type {BottomTabNavigatorParamList, CentralPaneNavigatorParamList, SettingsNavigatorParamList} from '@navigation/types';
import policyMemberPropType from '@pages/policyMemberPropType';
@@ -80,6 +81,9 @@ const policyPropTypes = {
tax: PropTypes.shape({
trackingEnabled: PropTypes.bool,
}),
+
+ /** Collection of tax rates attached to a policy */
+ taxRates: taxPropTypes,
}),
/** The employee list of this policy */
diff --git a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx
new file mode 100644
index 000000000000..62f32992601a
--- /dev/null
+++ b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx
@@ -0,0 +1,85 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import type {SvgProps} from 'react-native-svg';
+import Icon from '@components/Icon';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import Switch from '@components/Switch';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
+
+type ToggleSettingOptionRowProps = {
+ /** Icon to be shown for the option */
+ icon: React.FC;
+ /** Title of the option */
+ title: string;
+ /** Subtitle of the option */
+ subtitle: string;
+ /** Whether the option is enabled or not */
+ isActive: boolean;
+ /** Callback to be called when the switch is toggled */
+ onToggle: (isEnabled: boolean) => void;
+ /** SubMenuItems will be shown when the option is enabled */
+ subMenuItems?: React.ReactNode;
+ /** If there is a pending action, we will grey out the option */
+ pendingAction?: PendingAction;
+};
+const ICON_SIZE = 48;
+
+function ToggleSettingOptionRow({icon, title, subtitle, onToggle, subMenuItems, isActive, pendingAction}: ToggleSettingOptionRowProps) {
+ const [isEnabled, setIsEnabled] = useState(isActive);
+ const styles = useThemeStyles();
+ const toggleSwitch = () => {
+ setIsEnabled(!isEnabled);
+ onToggle(!isEnabled);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+
+ {isEnabled && subMenuItems}
+
+
+ );
+}
+
+export type {ToggleSettingOptionRowProps};
+export default ToggleSettingOptionRow;
diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
new file mode 100644
index 000000000000..fc1ed1d19560
--- /dev/null
+++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
@@ -0,0 +1,162 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useMemo} from 'react';
+import {FlatList, View} from 'react-native';
+import * as Illustrations from '@components/Icon/Illustrations';
+import MenuItem from '@components/MenuItem';
+import Section from '@components/Section';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import type {CentralPaneNavigatorParamList} from '@navigation/types';
+import withPolicy from '@pages/workspace/withPolicy';
+import type {WithPolicyProps} from '@pages/workspace/withPolicy';
+import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections';
+import * as Policy from '@userActions/Policy';
+import CONST from '@src/CONST';
+import type SCREENS from '@src/SCREENS';
+import ToggleSettingOptionRow from './ToggleSettingsOptionRow';
+import type {ToggleSettingOptionRowProps} from './ToggleSettingsOptionRow';
+
+type WorkspaceWorkflowsPageProps = WithPolicyProps & StackScreenProps;
+
+function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const {isOffline} = useNetwork();
+
+ const ownerPersonalDetails = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([policy?.ownerAccountID ?? 0], CONST.EMPTY_OBJECT), false);
+ const policyOwnerDisplayName = ownerPersonalDetails[0]?.displayName;
+ const containerStyle = useMemo(() => [styles.ph8, styles.mhn8, styles.ml11, styles.pv3, styles.pr0, styles.pl4, styles.mr0, styles.widthAuto, styles.mt4], [styles]);
+
+ const items: ToggleSettingOptionRowProps[] = useMemo(
+ () => [
+ {
+ icon: Illustrations.ReceiptEnvelope,
+ title: translate('workflowsPage.delaySubmissionTitle'),
+ subtitle: translate('workflowsPage.delaySubmissionDescription'),
+ onToggle: (isEnabled: boolean) => {
+ Policy.setWorkspaceAutoReporting(route.params.policyID, isEnabled);
+ },
+ subMenuItems: (
+ Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY).getRoute(route.params.policyID))}
+ // TODO will be done in https://github.com/Expensify/Expensify/issues/368332
+ description={translate('workflowsPage.weeklyFrequency')}
+ shouldShowRightIcon
+ wrapperStyle={containerStyle}
+ hoverAndPressStyle={[styles.mr0, styles.br2]}
+ />
+ ),
+ isActive: policy?.harvesting?.enabled ?? false,
+ pendingAction: policy?.pendingFields?.isAutoApprovalEnabled,
+ },
+ {
+ icon: Illustrations.Approval,
+ title: translate('workflowsPage.addApprovalsTitle'),
+ subtitle: translate('workflowsPage.addApprovalsDescription'),
+ onToggle: (isEnabled: boolean) => {
+ Policy.setWorkspaceApprovalMode(route.params.policyID, policy?.owner ?? '', isEnabled ? CONST.POLICY.APPROVAL_MODE.BASIC : CONST.POLICY.APPROVAL_MODE.OPTIONAL);
+ },
+ subMenuItems: (
+ Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVER.getRoute(route.params.policyID))}
+ // TODO will be done in https://github.com/Expensify/Expensify/issues/368334
+ shouldShowRightIcon
+ wrapperStyle={containerStyle}
+ hoverAndPressStyle={[styles.mr0, styles.br2]}
+ />
+ ),
+ isActive: policy?.isAutoApprovalEnabled ?? false,
+ pendingAction: policy?.pendingFields?.approvalMode,
+ },
+ {
+ icon: Illustrations.WalletAlt,
+ title: translate('workflowsPage.makeOrTrackPaymentsTitle'),
+ subtitle: translate('workflowsPage.makeOrTrackPaymentsDescription'),
+ onToggle: () => {
+ // TODO will be done in https://github.com/Expensify/Expensify/issues/368335
+ },
+ subMenuItems: (
+ Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_CONNECT_BANK_ACCOUNT.getRoute(route.params.policyID))}
+ // TODO will be done in https://github.com/Expensify/Expensify/issues/368335
+ shouldShowRightIcon
+ wrapperStyle={containerStyle}
+ hoverAndPressStyle={[styles.mr0, styles.br2]}
+ />
+ ),
+ isEndOptionRow: true,
+ isActive: false, // TODO will be done in https://github.com/Expensify/Expensify/issues/368335
+ },
+ ],
+ [policy, route.params.policyID, styles, translate, policyOwnerDisplayName, containerStyle, isOffline, StyleUtils],
+ );
+
+ const renderItem = ({item}: {item: ToggleSettingOptionRowProps}) => (
+
+
+
+ );
+
+ const isPaidGroupPolicy = PolicyUtils.isPaidGroupPolicy(policy);
+ const isPolicyAdmin = PolicyUtils.isPolicyAdmin(policy);
+
+ return (
+
+
+
+
+ {translate('workflowsPage.workflowDescription')}
+ item.title}
+ />
+
+
+
+
+ );
+}
+
+WorkspaceWorkflowsPage.displayName = 'WorkspaceWorkflowsPage';
+
+export default withPolicy(WorkspaceWorkflowsPage);
diff --git a/src/setup/addUtilsToWindow.ts b/src/setup/addUtilsToWindow.ts
new file mode 100644
index 000000000000..9991a3dc07cd
--- /dev/null
+++ b/src/setup/addUtilsToWindow.ts
@@ -0,0 +1,48 @@
+import Onyx from 'react-native-onyx';
+import * as Environment from '@libs/Environment/Environment';
+import * as Session from '@userActions/Session';
+
+/**
+ * This is used to inject development/debugging utilities into the window object on web and desktop.
+ * We do this only on non-production builds - these should not be used in any application code.
+ */
+export default function addUtilsToWindow() {
+ if (!window) {
+ return;
+ }
+
+ Environment.isProduction().then((isProduction) => {
+ if (isProduction) {
+ return;
+ }
+
+ window.Onyx = Onyx;
+
+ // We intentionally do not offer an Onyx.get API because we believe it will lead to code patterns we don't want to use in this repo, but we can offer a workaround for the sake of debugging
+ // @ts-expect-error TS233 - injecting additional utility for use in runtime debugging, should not be used in any compiled code
+ window.Onyx.get = function (key) {
+ return new Promise((resolve) => {
+ // eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs
+ const connectionID = Onyx.connect({
+ key,
+ callback: (value) => {
+ Onyx.disconnect(connectionID);
+ resolve(value);
+ },
+ waitForCollectionCallback: true,
+ });
+ });
+ };
+
+ // @ts-expect-error TS233 - injecting additional utility for use in runtime debugging, should not be used in any compiled code
+ window.Onyx.log = function (key) {
+ // @ts-expect-error TS2339 - using additional utility injected above
+ window.Onyx.get(key).then((value) => {
+ /* eslint-disable-next-line no-console */
+ console.log(value);
+ });
+ };
+
+ window.setSupportToken = Session.setSupportAuthToken;
+ });
+}
diff --git a/src/setup/index.ts b/src/setup/index.ts
index fe9d80ec5fb1..a97862df9ae7 100644
--- a/src/setup/index.ts
+++ b/src/setup/index.ts
@@ -6,6 +6,7 @@ import * as Device from '@userActions/Device';
import exposeGlobalMemoryOnlyKeysMethods from '@userActions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import addUtilsToWindow from './addUtilsToWindow';
import initializeLastVisitedPath from './initializeLastVisitedPath';
import platformSetup from './platformSetup';
@@ -34,7 +35,7 @@ export default function () {
// Clear any loading and error messages so they do not appear on app startup
[ONYXKEYS.SESSION]: {loading: false},
[ONYXKEYS.ACCOUNT]: CONST.DEFAULT_ACCOUNT_DATA,
- [ONYXKEYS.NETWORK]: {isOffline: false},
+ [ONYXKEYS.NETWORK]: CONST.DEFAULT_NETWORK_DATA,
[ONYXKEYS.IS_SIDEBAR_LOADED]: false,
[ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: true,
[ONYXKEYS.MODAL]: {
@@ -59,4 +60,6 @@ export default function () {
// Perform any other platform-specific setup
platformSetup();
+
+ addUtilsToWindow();
}
diff --git a/src/stories/SelectionList.stories.js b/src/stories/SelectionList.stories.js
index 835bf67fbfd7..6c289097552b 100644
--- a/src/stories/SelectionList.stories.js
+++ b/src/stories/SelectionList.stories.js
@@ -1,8 +1,8 @@
import React, {useMemo, useState} from 'react';
-import {View} from 'react-native';
import _ from 'underscore';
+import Badge from '@components/Badge';
import SelectionList from '@components/SelectionList';
-import Text from '@components/Text';
+import RadioListItem from '@components/SelectionList/RadioListItem';
// eslint-disable-next-line no-restricted-imports
import {defaultStyles} from '@styles/index';
import CONST from '@src/CONST';
@@ -89,6 +89,7 @@ function Default(args) {
// eslint-disable-next-line react/jsx-props-no-spreading
{...args}
sections={sections}
+ ListItem={RadioListItem}
onSelectRow={onSelectRow}
/>
);
@@ -137,6 +138,7 @@ function WithTextInput(args) {
// eslint-disable-next-line react/jsx-props-no-spreading
{...args}
sections={sections}
+ ListItem={RadioListItem}
textInputValue={searchText}
onChangeText={setSearchText}
onSelectRow={onSelectRow}
@@ -229,9 +231,11 @@ function MultipleSelection(args) {
accountID: item.keyForList,
login: item.text,
rightElement: isAdmin && (
-
- Admin
-
+
),
};
});
@@ -260,6 +264,7 @@ function MultipleSelection(args) {
// eslint-disable-next-line react/jsx-props-no-spreading
{...args}
sections={memo.sections}
+ ListItem={RadioListItem}
onSelectRow={onSelectRow}
onSelectAll={onSelectAll}
/>
@@ -291,9 +296,11 @@ function WithSectionHeader(args) {
accountID: item.keyForList,
login: item.text,
rightElement: isAdmin && (
-
- Admin
-
+
),
};
});
@@ -322,6 +329,7 @@ function WithSectionHeader(args) {
// eslint-disable-next-line react/jsx-props-no-spreading
{...args}
sections={memo.sections}
+ ListItem={RadioListItem}
onSelectRow={onSelectRow}
onSelectAll={onSelectAll}
/>
@@ -351,9 +359,11 @@ function WithConfirmButton(args) {
accountID: item.keyForList,
login: item.text,
rightElement: isAdmin && (
-
- Admin
-
+
),
};
});
@@ -382,6 +392,7 @@ function WithConfirmButton(args) {
// eslint-disable-next-line react/jsx-props-no-spreading
{...args}
sections={memo.sections}
+ ListItem={RadioListItem}
onSelectRow={onSelectRow}
onSelectAll={onSelectAll}
/>
diff --git a/src/styles/index.ts b/src/styles/index.ts
index c49e67d10c1e..1f0ac1e853ac 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -400,6 +400,11 @@ const styles = (theme: ThemeColors) =>
fontSize: variables.fontSizeNormal,
},
+ textNormalThemeText: {
+ color: theme.text,
+ fontSize: variables.fontSizeNormal,
+ },
+
textLarge: {
fontSize: variables.fontSizeLarge,
},
@@ -487,6 +492,10 @@ const styles = (theme: ThemeColors) =>
opacity: 0,
},
+ opacitySemiTransparent: {
+ opacity: 0.5,
+ },
+
opacity1: {
opacity: 1,
},
@@ -826,6 +835,8 @@ const styles = (theme: ThemeColors) =>
borderWidth: 1,
borderRadius: variables.componentBorderRadiusSmall,
borderColor: theme.border,
+ paddingHorizontal: 12,
+ minHeight: 28,
},
badgeText: {
@@ -835,6 +846,10 @@ const styles = (theme: ThemeColors) =>
...whiteSpace.noWrap,
},
+ activeItemBadge: {
+ borderColor: theme.buttonHoveredBG,
+ },
+
border: {
borderWidth: 1,
borderRadius: variables.componentBorderRadius,
@@ -1229,6 +1244,13 @@ const styles = (theme: ThemeColors) =>
color: theme.textSupporting,
},
+ textLabelSupportingNormal: {
+ fontFamily: FontUtils.fontFamily.platform.EXP_NEUE,
+ fontSize: variables.fontSizeLabel,
+ color: theme.textSupporting,
+ fontWeight: FontUtils.fontWeight.normal,
+ },
+
textLabelError: {
fontFamily: FontUtils.fontFamily.platform.EXP_NEUE,
fontSize: variables.fontSizeLabel,
@@ -3061,6 +3083,20 @@ const styles = (theme: ThemeColors) =>
bottom: -8,
},
+ primaryMediumIcon: {
+ alignItems: 'center',
+ backgroundColor: theme.buttonDefaultBG,
+ borderRadius: 20,
+ color: theme.textReversed,
+ height: 40,
+ width: 40,
+ justifyContent: 'center',
+ },
+
+ primaryMediumText: {
+ fontSize: variables.iconSizeNormal,
+ },
+
workspaceOwnerAvatarWrapper: {
margin: 6,
},
@@ -3095,19 +3131,6 @@ const styles = (theme: ThemeColors) =>
...spacing.pb2,
},
- peopleBadge: {
- backgroundColor: theme.icon,
- ...spacing.ph3,
- ...spacing.ml3,
- },
-
- peopleBadgeText: {
- color: theme.textReversed,
- fontSize: variables.fontSizeSmall,
- lineHeight: variables.lineHeightNormal,
- ...whiteSpace.noWrap,
- },
-
offlineFeedback: {
deleted: {
textDecorationLine: 'line-through',
@@ -3432,6 +3455,19 @@ const styles = (theme: ThemeColors) =>
alignItems: 'center',
},
+ emptyCardSectionTitle: {
+ fontSize: variables.fontSizeXLarge,
+ lineHeight: variables.lineHeightXXLarge,
+ textAlign: 'center',
+ },
+
+ emptyCardSectionSubtitle: {
+ fontSize: variables.fontSizeNormal,
+ lineHeight: variables.lineHeightXLarge,
+ color: theme.textSupporting,
+ textAlign: 'center',
+ },
+
transferBalance: {
width: 'auto',
borderRadius: 0,
@@ -4211,6 +4247,17 @@ const styles = (theme: ThemeColors) =>
marginHorizontal: 20,
},
+ selectionListPressableItemWrapper: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ paddingHorizontal: 16,
+ paddingVertical: 16,
+ marginHorizontal: 20,
+ marginBottom: 12,
+ backgroundColor: theme.highlightBG,
+ borderRadius: 8,
+ },
+
draggableTopBar: {
height: 30,
width: '100%',
@@ -4283,6 +4330,11 @@ const styles = (theme: ThemeColors) =>
overflow: 'hidden',
},
+ walletCardNumber: {
+ color: theme.text,
+ fontSize: variables.fontSizeNormal,
+ },
+
walletCardMenuItem: {
fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD,
fontWeight: FontUtils.fontWeight.bold,
@@ -4573,6 +4625,10 @@ const styles = (theme: ThemeColors) =>
width: variables.updateTextViewContainerWidth,
},
+ widthAuto: {
+ width: 'auto',
+ },
+
workspaceTitleStyle: {
fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD,
fontWeight: '500',
diff --git a/src/styles/utils/autoCompleteSuggestion/index.android.ts b/src/styles/utils/autoCompleteSuggestion/index.android.ts
new file mode 100644
index 000000000000..88b7a7c84297
--- /dev/null
+++ b/src/styles/utils/autoCompleteSuggestion/index.android.ts
@@ -0,0 +1,5 @@
+import type ShouldPreventScrollOnAutoCompleteSuggestion from './types';
+
+const shouldPreventScrollOnAutoCompleteSuggestion: ShouldPreventScrollOnAutoCompleteSuggestion = () => false;
+
+export default shouldPreventScrollOnAutoCompleteSuggestion;
diff --git a/src/styles/utils/autoCompleteSuggestion/index.ts b/src/styles/utils/autoCompleteSuggestion/index.ts
new file mode 100644
index 000000000000..e756e7178c57
--- /dev/null
+++ b/src/styles/utils/autoCompleteSuggestion/index.ts
@@ -0,0 +1,5 @@
+import type ShouldPreventScrollOnAutoCompleteSuggestion from './types';
+
+const shouldPreventScrollOnAutoCompleteSuggestion: ShouldPreventScrollOnAutoCompleteSuggestion = () => true;
+
+export default shouldPreventScrollOnAutoCompleteSuggestion;
diff --git a/src/styles/utils/autoCompleteSuggestion/index.website.ts b/src/styles/utils/autoCompleteSuggestion/index.website.ts
new file mode 100644
index 000000000000..badec5dfc774
--- /dev/null
+++ b/src/styles/utils/autoCompleteSuggestion/index.website.ts
@@ -0,0 +1,8 @@
+import * as Browser from '@libs/Browser';
+import type ShouldPreventScrollOnAutoCompleteSuggestion from './types';
+
+const isMobileSafari = Browser.isMobileSafari();
+
+const shouldPreventScrollOnAutoCompleteSuggestion: ShouldPreventScrollOnAutoCompleteSuggestion = () => !isMobileSafari;
+
+export default shouldPreventScrollOnAutoCompleteSuggestion;
diff --git a/src/styles/utils/autoCompleteSuggestion/types.ts b/src/styles/utils/autoCompleteSuggestion/types.ts
new file mode 100644
index 000000000000..563d305eb236
--- /dev/null
+++ b/src/styles/utils/autoCompleteSuggestion/types.ts
@@ -0,0 +1,3 @@
+type ShouldPreventScrollOnAutoCompleteSuggestion = () => boolean;
+
+export default ShouldPreventScrollOnAutoCompleteSuggestion;
diff --git a/src/styles/utils/borders.ts b/src/styles/utils/borders.ts
index 26fdf6415fc7..2e20091e3fae 100644
--- a/src/styles/utils/borders.ts
+++ b/src/styles/utils/borders.ts
@@ -8,6 +8,10 @@ export default {
borderRadius: 0,
},
+ br1: {
+ borderRadius: 4,
+ },
+
br2: {
borderRadius: 8,
},
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index a0edb7fd4e23..72719e4795c4 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -14,8 +14,10 @@ import CONST from '@src/CONST';
import type {Transaction} from '@src/types/onyx';
import {defaultStyles} from '..';
import type {ThemeStyles} from '..';
+import shouldPreventScrollOnAutoCompleteSuggestion from './autoCompleteSuggestion';
import getCardStyles from './cardStyles';
import containerComposeStyles from './containerComposeStyles';
+import cursor from './cursor';
import FontUtils from './FontUtils';
import createModalStyleUtils from './generators/ModalStyleUtils';
import createReportActionContextMenuStyleUtils from './generators/ReportActionContextMenuStyleUtils';
@@ -790,6 +792,8 @@ function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}: GetB
};
}
+const shouldPreventScroll = shouldPreventScrollOnAutoCompleteSuggestion();
+
/**
* Gets the correct position for auto complete suggestion container
*/
@@ -797,13 +801,13 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle
'worklet';
const borderWidth = 2;
- const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING;
+ const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + (shouldPreventScroll ? borderWidth : 0);
// The suggester is positioned absolutely within the component that includes the input and RecipientLocalTime view (for non-expanded mode only). To position it correctly,
// we need to shift it by the suggester's height plus its padding and, if applicable, the height of the RecipientLocalTime view.
return {
overflow: 'hidden',
- top: -(height + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + borderWidth),
+ top: -(height + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + (shouldPreventScroll ? 0 : borderWidth)),
height,
minHeight: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT,
};
@@ -925,6 +929,7 @@ function getCheckboxPressableStyle(borderRadius = 6): ViewStyle {
alignItems: 'center',
// eslint-disable-next-line object-shorthand
borderRadius: borderRadius,
+ ...cursor.cursorPointer,
};
}
@@ -1224,7 +1229,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
/**
* Returns link styles based on whether the link is disabled or not
*/
- getDisabledLinkStyles: (isDisabled = false): ViewStyle => {
+ getDisabledLinkStyles: (isDisabled = false): TextStyle => {
const disabledLinkStyles = {
color: theme.textSupporting,
...styles.cursorDisabled,
@@ -1474,6 +1479,20 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
},
getFullscreenCenteredContentStyles: () => [StyleSheet.absoluteFill, styles.justifyContentCenter, styles.alignItemsCenter],
+
+ getMultiselectListStyles: (isSelected: boolean, isDisabled: boolean): ViewStyle => ({
+ ...styles.mr3,
+ ...(isSelected && styles.checkedContainer),
+ ...(isSelected && styles.borderColorFocus),
+ ...(isDisabled && styles.cursorDisabled),
+ ...(isDisabled && styles.buttonOpacityDisabled),
+ }),
+
+ // TODO: remove it when we'll implement the callback to handle this toggle in Expensify/Expensify#368335
+ getWorkspaceWorkflowsOfflineDescriptionStyle: (descriptionTextStyle: TextStyle | TextStyle[]): StyleProp => ({
+ ...StyleSheet.flatten(descriptionTextStyle),
+ opacity: styles.opacitySemiTransparent.opacity,
+ }),
});
type StyleUtilsType = ReturnType;
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index b4a6296507a4..0249b3c6dfc0 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -179,6 +179,10 @@ export default {
marginLeft: 40,
},
+ ml11: {
+ marginLeft: 44,
+ },
+
ml18: {
marginLeft: 72,
},
@@ -381,6 +385,10 @@ export default {
paddingVertical: 40,
},
+ pv12: {
+ paddingVertical: 48,
+ },
+
ph0: {
paddingHorizontal: 0,
},
@@ -477,6 +485,10 @@ export default {
paddingLeft: 12,
},
+ pl4: {
+ paddingLeft: 16,
+ },
+
pl5: {
paddingLeft: 20,
},
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index c6c29fdc4b79..f7c9bd055041 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -217,4 +217,7 @@ export default {
updateTextViewContainerWidth: 310,
updateViewHeaderHeight: 70,
workspaceProfileName: 20,
+
+ mushroomTopHatWidth: 138,
+ mushroomTopHatHeight: 128,
} as const;
diff --git a/src/types/form/AddDebitCardForm.ts b/src/types/form/AddDebitCardForm.ts
index 4a73766fb8f1..b5badf0bb7f3 100644
--- a/src/types/form/AddDebitCardForm.ts
+++ b/src/types/form/AddDebitCardForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -12,19 +13,23 @@ const INPUT_IDS = {
ACCEPT_TERMS: 'acceptTerms',
} as const;
-type AddDebitCardForm = Form<{
- /** Whether the form has been submitted */
- [INPUT_IDS.SETUP_COMPLETE]: boolean;
+type InputID = ValueOf;
- [INPUT_IDS.NAME_ON_CARD]?: string;
- [INPUT_IDS.CARD_NUMBER]?: string;
- [INPUT_IDS.EXPIRATION_DATE]?: string;
- [INPUT_IDS.SECURITY_CODE]?: string;
- [INPUT_IDS.ADDRESS_STREET]?: string;
- [INPUT_IDS.ADDRESS_ZIP_CODE]?: string;
- [INPUT_IDS.ADDRESS_STATE]?: string;
- [INPUT_IDS.ACCEPT_TERMS]?: string;
-}>;
+type AddDebitCardForm = Form<
+ InputID,
+ {
+ /** Whether the form has been submitted */
+ [INPUT_IDS.SETUP_COMPLETE]: boolean;
+ [INPUT_IDS.NAME_ON_CARD]: string;
+ [INPUT_IDS.CARD_NUMBER]: string;
+ [INPUT_IDS.EXPIRATION_DATE]: string;
+ [INPUT_IDS.SECURITY_CODE]: string;
+ [INPUT_IDS.ADDRESS_STREET]: string;
+ [INPUT_IDS.ADDRESS_ZIP_CODE]: string;
+ [INPUT_IDS.ADDRESS_STATE]: string;
+ [INPUT_IDS.ACCEPT_TERMS]: string;
+ }
+>;
export type {AddDebitCardForm};
export default INPUT_IDS;
diff --git a/src/types/form/CloseAccountForm.ts b/src/types/form/CloseAccountForm.ts
index 054dcd71e442..dd852ded653f 100644
--- a/src/types/form/CloseAccountForm.ts
+++ b/src/types/form/CloseAccountForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -6,11 +7,16 @@ const INPUT_IDS = {
SUCCESS: 'success',
} as const;
-type CloseAccountForm = Form<{
- [INPUT_IDS.REASON_FOR_LEAVING]: string;
- [INPUT_IDS.PHONE_OR_EMAIL]: string;
- [INPUT_IDS.SUCCESS]: string;
-}>;
+type InputID = ValueOf;
+
+type CloseAccountForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.REASON_FOR_LEAVING]: string;
+ [INPUT_IDS.PHONE_OR_EMAIL]: string;
+ [INPUT_IDS.SUCCESS]: string;
+ }
+>;
export type {CloseAccountForm};
export default INPUT_IDS;
diff --git a/src/types/form/DateOfBirthForm.ts b/src/types/form/DateOfBirthForm.ts
index 01678669f176..999b4e44459d 100644
--- a/src/types/form/DateOfBirthForm.ts
+++ b/src/types/form/DateOfBirthForm.ts
@@ -1,13 +1,19 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
DOB: 'dob',
} as const;
-type DateOfBirthForm = Form<{
- /** Date of birth */
- [INPUT_IDS.DOB]: string;
-}>;
+type InputID = ValueOf;
+
+type DateOfBirthForm = Form<
+ InputID,
+ {
+ /** Date of birth */
+ [INPUT_IDS.DOB]: string;
+ }
+>;
export type {DateOfBirthForm};
export default INPUT_IDS;
diff --git a/src/types/form/DisplayNameForm.ts b/src/types/form/DisplayNameForm.ts
index 3f9738c45a34..ae40e6020fd7 100644
--- a/src/types/form/DisplayNameForm.ts
+++ b/src/types/form/DisplayNameForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -5,10 +6,15 @@ const INPUT_IDS = {
LAST_NAME: 'lastName',
} as const;
-type DisplayNameForm = Form<{
- [INPUT_IDS.FIRST_NAME]: string;
- [INPUT_IDS.LAST_NAME]: string;
-}>;
+type InputID = ValueOf;
+
+type DisplayNameForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.FIRST_NAME]: string;
+ [INPUT_IDS.LAST_NAME]: string;
+ }
+>;
export type {DisplayNameForm};
export default INPUT_IDS;
diff --git a/src/types/form/EditTaskForm.ts b/src/types/form/EditTaskForm.ts
index 05de0310c784..86a1c8198fec 100644
--- a/src/types/form/EditTaskForm.ts
+++ b/src/types/form/EditTaskForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -5,10 +6,15 @@ const INPUT_IDS = {
DESCRIPTION: 'description',
} as const;
-type EditTaskForm = Form<{
- [INPUT_IDS.TITLE]: string;
- [INPUT_IDS.DESCRIPTION]: string;
-}>;
+type InputID = ValueOf;
+
+type EditTaskForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.TITLE]: string;
+ [INPUT_IDS.DESCRIPTION]: string;
+ }
+>;
export type {EditTaskForm};
export default INPUT_IDS;
diff --git a/src/types/form/ExitSurveyReasonForm.ts b/src/types/form/ExitSurveyReasonForm.ts
new file mode 100644
index 000000000000..48eddb026010
--- /dev/null
+++ b/src/types/form/ExitSurveyReasonForm.ts
@@ -0,0 +1,19 @@
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+import type Form from './Form';
+
+type ExitReason = ValueOf;
+
+const INPUT_IDS = {
+ REASON: 'reason',
+} as const;
+
+type ExitSurveyReasonForm = Form<
+ ValueOf,
+ {
+ [INPUT_IDS.REASON]: ExitReason;
+ }
+>;
+
+export type {ExitSurveyReasonForm, ExitReason};
+export default INPUT_IDS;
diff --git a/src/types/form/ExitSurveyResponseForm.ts b/src/types/form/ExitSurveyResponseForm.ts
new file mode 100644
index 000000000000..6e3458bd8e38
--- /dev/null
+++ b/src/types/form/ExitSurveyResponseForm.ts
@@ -0,0 +1,16 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ RESPONSE: 'response',
+} as const;
+
+type ExitSurveyResponseForm = Form<
+ ValueOf,
+ {
+ [INPUT_IDS.RESPONSE]: string;
+ }
+>;
+
+export type {ExitSurveyResponseForm};
+export default INPUT_IDS;
diff --git a/src/types/form/Form.ts b/src/types/form/Form.ts
index d80eb65f4215..cf35e84646b1 100644
--- a/src/types/form/Form.ts
+++ b/src/types/form/Form.ts
@@ -12,8 +12,8 @@ type BaseForm = {
errorFields?: OnyxCommon.ErrorFields | null;
};
-type FormValues = Record;
-type Form = TFormValues & BaseForm;
+type FormValues = Record;
+type Form = FormValues> = TFormValues & BaseForm;
export default Form;
export type {BaseForm};
diff --git a/src/types/form/GetPhysicalCardForm.ts b/src/types/form/GetPhysicalCardForm.ts
index 091113eb4763..c8fc6f3cce9e 100644
--- a/src/types/form/GetPhysicalCardForm.ts
+++ b/src/types/form/GetPhysicalCardForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
import ADDRESS_INPUT_IDS from './HomeAddressForm';
@@ -8,17 +9,22 @@ const INPUT_IDS = {
PHONE_NUMBER: 'phoneNumber',
} as const;
-type GetPhysicalCardForm = Form<{
- [INPUT_IDS.ADDRESS_LINE_1]?: string;
- [INPUT_IDS.ADDRESS_LINE_2]?: string;
- [INPUT_IDS.COUNTRY]?: string;
- [INPUT_IDS.STATE]?: string;
- [INPUT_IDS.CITY]?: string;
- [INPUT_IDS.ZIP_POST_CODE]?: string;
- [INPUT_IDS.LEGAL_FIRST_NAME]?: string;
- [INPUT_IDS.LEGAL_LAST_NAME]?: string;
- [INPUT_IDS.PHONE_NUMBER]?: string;
-}>;
+type InputID = ValueOf;
+
+type GetPhysicalCardForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.ADDRESS_LINE_1]: string;
+ [INPUT_IDS.ADDRESS_LINE_2]: string;
+ [INPUT_IDS.COUNTRY]: string;
+ [INPUT_IDS.STATE]: string;
+ [INPUT_IDS.CITY]: string;
+ [INPUT_IDS.ZIP_POST_CODE]: string;
+ [INPUT_IDS.LEGAL_FIRST_NAME]: string;
+ [INPUT_IDS.LEGAL_LAST_NAME]: string;
+ [INPUT_IDS.PHONE_NUMBER]: string;
+ }
+>;
export type {GetPhysicalCardForm};
export default INPUT_IDS;
diff --git a/src/types/form/HomeAddressForm.ts b/src/types/form/HomeAddressForm.ts
index 4e819146faf7..6d9ef8580078 100644
--- a/src/types/form/HomeAddressForm.ts
+++ b/src/types/form/HomeAddressForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -9,14 +10,19 @@ const INPUT_IDS = {
ZIP_POST_CODE: 'zipPostCode',
} as const;
-type HomeAddressForm = Form<{
- [INPUT_IDS.ADDRESS_LINE_1]: string;
- [INPUT_IDS.ADDRESS_LINE_2]: string;
- [INPUT_IDS.COUNTRY]: string;
- [INPUT_IDS.STATE]: string;
- [INPUT_IDS.CITY]: string;
- [INPUT_IDS.ZIP_POST_CODE]: string;
-}>;
+type InputID = ValueOf;
+
+type HomeAddressForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.ADDRESS_LINE_1]: string;
+ [INPUT_IDS.ADDRESS_LINE_2]: string;
+ [INPUT_IDS.COUNTRY]: string;
+ [INPUT_IDS.STATE]: string;
+ [INPUT_IDS.CITY]: string;
+ [INPUT_IDS.ZIP_POST_CODE]: string;
+ }
+>;
export type {HomeAddressForm};
export default INPUT_IDS;
diff --git a/src/types/form/IKnowTeacherForm.ts b/src/types/form/IKnowTeacherForm.ts
index 7d6ba4b9079a..d406dd57de56 100644
--- a/src/types/form/IKnowTeacherForm.ts
+++ b/src/types/form/IKnowTeacherForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -6,11 +7,16 @@ const INPUT_IDS = {
PARTNER_USER_ID: 'partnerUserID',
} as const;
-type IKnowTeacherForm = Form<{
- [INPUT_IDS.FIRST_NAME]: string;
- [INPUT_IDS.LAST_NAME]: string;
- [INPUT_IDS.PARTNER_USER_ID]: string;
-}>;
+type InputID = ValueOf;
+
+type IKnowTeacherForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.FIRST_NAME]: string;
+ [INPUT_IDS.LAST_NAME]: string;
+ [INPUT_IDS.PARTNER_USER_ID]: string;
+ }
+>;
export type {IKnowTeacherForm};
export default INPUT_IDS;
diff --git a/src/types/form/IntroSchoolPrincipalForm.ts b/src/types/form/IntroSchoolPrincipalForm.ts
index 61742a98c6fd..6cb4a74d0f7e 100644
--- a/src/types/form/IntroSchoolPrincipalForm.ts
+++ b/src/types/form/IntroSchoolPrincipalForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -6,11 +7,16 @@ const INPUT_IDS = {
PARTNER_USER_ID: 'partnerUserID',
} as const;
-type IntroSchoolPrincipalForm = Form<{
- [INPUT_IDS.FIRST_NAME]: string;
- [INPUT_IDS.LAST_NAME]: string;
- [INPUT_IDS.PARTNER_USER_ID]: string;
-}>;
+type InputID = ValueOf;
+
+type IntroSchoolPrincipalForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.FIRST_NAME]: string;
+ [INPUT_IDS.LAST_NAME]: string;
+ [INPUT_IDS.PARTNER_USER_ID]: string;
+ }
+>;
export type {IntroSchoolPrincipalForm};
export default INPUT_IDS;
diff --git a/src/types/form/LegalNameForm.ts b/src/types/form/LegalNameForm.ts
index 8ca9e44a7754..808d3f68639a 100644
--- a/src/types/form/LegalNameForm.ts
+++ b/src/types/form/LegalNameForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -5,10 +6,15 @@ const INPUT_IDS = {
LEGAL_LAST_NAME: 'legalLastName',
} as const;
-type LegalNameForm = Form<{
- [INPUT_IDS.LEGAL_FIRST_NAME]: string;
- [INPUT_IDS.LEGAL_LAST_NAME]: string;
-}>;
+type InputID = ValueOf;
+
+type LegalNameForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.LEGAL_FIRST_NAME]: string;
+ [INPUT_IDS.LEGAL_LAST_NAME]: string;
+ }
+>;
export type {LegalNameForm};
export default INPUT_IDS;
diff --git a/src/types/form/MoneyRequestCreatedForm.ts b/src/types/form/MoneyRequestCreatedForm.ts
deleted file mode 100644
index b7df7362a888..000000000000
--- a/src/types/form/MoneyRequestCreatedForm.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type Form from './Form';
-
-const INPUT_IDS = {
- CREATED: 'created',
- MONEY_REQUEST_CREATED: 'moneyRequestCreated',
-} as const;
-
-type MoneyRequestCreatedForm = Form<{
- [INPUT_IDS.CREATED]: string;
- [INPUT_IDS.MONEY_REQUEST_CREATED]: string;
-}>;
-
-export type {MoneyRequestCreatedForm};
-export default INPUT_IDS;
diff --git a/src/types/form/MoneyRequestDateForm.ts b/src/types/form/MoneyRequestDateForm.ts
new file mode 100644
index 000000000000..64e9336d0659
--- /dev/null
+++ b/src/types/form/MoneyRequestDateForm.ts
@@ -0,0 +1,20 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ CREATED: 'created',
+ MONEY_REQUEST_CREATED: 'moneyRequestCreated',
+} as const;
+
+type InputID = ValueOf;
+
+type MoneyRequestDateForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.CREATED]: string;
+ [INPUT_IDS.MONEY_REQUEST_CREATED]: string;
+ }
+>;
+
+export type {MoneyRequestDateForm};
+export default INPUT_IDS;
diff --git a/src/types/form/MoneyRequestDescriptionForm.ts b/src/types/form/MoneyRequestDescriptionForm.ts
index df8ae5d225f3..e64e183c57b2 100644
--- a/src/types/form/MoneyRequestDescriptionForm.ts
+++ b/src/types/form/MoneyRequestDescriptionForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -5,10 +6,15 @@ const INPUT_IDS = {
MONEY_REQUEST_COMMENT: 'moneyRequestComment',
} as const;
-type MoneyRequestDescriptionForm = Form<{
- [INPUT_IDS.COMMENT]: string;
- [INPUT_IDS.MONEY_REQUEST_COMMENT]: string;
-}>;
+type InputID = ValueOf;
+
+type MoneyRequestDescriptionForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.COMMENT]: string;
+ [INPUT_IDS.MONEY_REQUEST_COMMENT]: string;
+ }
+>;
export type {MoneyRequestDescriptionForm};
export default INPUT_IDS;
diff --git a/src/types/form/MoneyRequestHoldReasonForm.ts b/src/types/form/MoneyRequestHoldReasonForm.ts
index 0d80810eaf69..26ce5e2a88a9 100644
--- a/src/types/form/MoneyRequestHoldReasonForm.ts
+++ b/src/types/form/MoneyRequestHoldReasonForm.ts
@@ -1,12 +1,18 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
COMMENT: 'comment',
} as const;
-type MoneyRequestHoldReasonForm = Form<{
- [INPUT_IDS.COMMENT]: string;
-}>;
+type InputID = ValueOf;
+
+type MoneyRequestHoldReasonForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.COMMENT]: string;
+ }
+>;
export type {MoneyRequestHoldReasonForm};
export default INPUT_IDS;
diff --git a/src/types/form/MoneyRequestMerchantForm.ts b/src/types/form/MoneyRequestMerchantForm.ts
index 0dd194037b7d..589915e55694 100644
--- a/src/types/form/MoneyRequestMerchantForm.ts
+++ b/src/types/form/MoneyRequestMerchantForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -5,10 +6,15 @@ const INPUT_IDS = {
MONEY_REQUEST_MERCHANT: 'moneyRequestMerchant',
} as const;
-type MoneyRequestMerchantForm = Form<{
- [INPUT_IDS.MERCHANT]: string;
- [INPUT_IDS.MONEY_REQUEST_MERCHANT]: string;
-}>;
+type InputID = ValueOf;
+
+type MoneyRequestMerchantForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.MERCHANT]: string;
+ [INPUT_IDS.MONEY_REQUEST_MERCHANT]: string;
+ }
+>;
export type {MoneyRequestMerchantForm};
export default INPUT_IDS;
diff --git a/src/types/form/NewContactMethodForm.ts b/src/types/form/NewContactMethodForm.ts
index baa67cec1cbf..cea8ffad1992 100644
--- a/src/types/form/NewContactMethodForm.ts
+++ b/src/types/form/NewContactMethodForm.ts
@@ -1,12 +1,18 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
PHONE_OR_EMAIL: 'phoneOrEmail',
} as const;
-type NewContactMethodForm = Form<{
- [INPUT_IDS.PHONE_OR_EMAIL]: string;
-}>;
+type InputID = ValueOf;
+
+type NewContactMethodForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.PHONE_OR_EMAIL]: string;
+ }
+>;
export type {NewContactMethodForm};
export default INPUT_IDS;
diff --git a/src/types/form/NewRoomForm.ts b/src/types/form/NewRoomForm.ts
index 8fe047f8a7e4..3e21e97140ef 100644
--- a/src/types/form/NewRoomForm.ts
+++ b/src/types/form/NewRoomForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -8,13 +9,18 @@ const INPUT_IDS = {
VISIBILITY: 'visibility',
} as const;
-type NewRoomForm = Form<{
- [INPUT_IDS.ROOM_NAME]?: string;
- [INPUT_IDS.REPORT_DESCRIPTION]?: string;
- [INPUT_IDS.POLICY_ID]?: string;
- [INPUT_IDS.WRITE_CAPABILITY]?: string;
- [INPUT_IDS.VISIBILITY]?: string;
-}>;
+type InputID = ValueOf;
+
+type NewRoomForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.ROOM_NAME]: string;
+ [INPUT_IDS.REPORT_DESCRIPTION]: string;
+ [INPUT_IDS.POLICY_ID]: string;
+ [INPUT_IDS.WRITE_CAPABILITY]: string;
+ [INPUT_IDS.VISIBILITY]: string;
+ }
+>;
export type {NewRoomForm};
export default INPUT_IDS;
diff --git a/src/types/form/NewTaskForm.ts b/src/types/form/NewTaskForm.ts
index b281a79321fb..a9ef7155a9d1 100644
--- a/src/types/form/NewTaskForm.ts
+++ b/src/types/form/NewTaskForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -5,10 +6,15 @@ const INPUT_IDS = {
TASK_DESCRIPTION: 'taskDescription',
} as const;
-type NewTaskForm = Form<{
- [INPUT_IDS.TASK_TITLE]: string;
- [INPUT_IDS.TASK_DESCRIPTION]: string;
-}>;
+type InputID = ValueOf;
+
+type NewTaskForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.TASK_TITLE]: string;
+ [INPUT_IDS.TASK_DESCRIPTION]: string;
+ }
+>;
export type {NewTaskForm};
export default INPUT_IDS;
diff --git a/src/types/form/PrivateNotesForm.ts b/src/types/form/PrivateNotesForm.ts
index 2961b693ef6b..13fe312a1514 100644
--- a/src/types/form/PrivateNotesForm.ts
+++ b/src/types/form/PrivateNotesForm.ts
@@ -1,12 +1,18 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
PRIVATE_NOTES: 'privateNotes',
} as const;
-type PrivateNotesForm = Form<{
- [INPUT_IDS.PRIVATE_NOTES]: string;
-}>;
+type InputID = ValueOf;
+
+type PrivateNotesForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.PRIVATE_NOTES]: string;
+ }
+>;
export type {PrivateNotesForm};
export default INPUT_IDS;
diff --git a/src/types/form/ReimbursementAccountForm.ts b/src/types/form/ReimbursementAccountForm.ts
index 7bc1c52e8025..7860d5a066f1 100644
--- a/src/types/form/ReimbursementAccountForm.ts
+++ b/src/types/form/ReimbursementAccountForm.ts
@@ -1,3 +1,4 @@
+import type DeepValueOf from '@src/types/utils/DeepValueOf';
import type Form from './Form';
const INPUT_IDS = {
@@ -50,73 +51,79 @@ const INPUT_IDS = {
AMOUNT3: 'amount3',
} as const;
+type InputID = DeepValueOf;
+
+type BeneficialOwnersStepBaseProps = {
+ [INPUT_IDS.BENEFICIAL_OWNER_INFO_STEP.OWNS_MORE_THAN_25_PERCENT]: boolean;
+ [INPUT_IDS.BENEFICIAL_OWNER_INFO_STEP.HAS_OTHER_BENEFICIAL_OWNERS]: boolean;
+ [INPUT_IDS.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNERS]: string;
+};
+
// BeneficialOwnerDraftData is saved under dynamic key which consists of prefix, beneficial owner ID and input key
type BeneficialOwnerDataKey = `beneficialOwner_${string}_${string}`;
-type AdditionalDraftData = {selectedPlaidAccountID?: string; bankAccountID?: number};
+type ReimbursementAccountFormExtraProps = BeneficialOwnersStepExtraProps & {bankAccountID?: number};
+
+type BeneficialOwnersStepExtraProps = {
+ [key: BeneficialOwnerDataKey]: string;
+ beneficialOwnerKeys?: string[];
+};
+
+type BeneficialOwnersStepProps = BeneficialOwnersStepBaseProps & BeneficialOwnersStepExtraProps;
type BankAccountStepProps = {
- [INPUT_IDS.BANK_INFO_STEP.ACCOUNT_NUMBER]?: string;
- [INPUT_IDS.BANK_INFO_STEP.ROUTING_NUMBER]?: string;
- [INPUT_IDS.BANK_INFO_STEP.PLAID_ACCOUNT_ID]?: string;
- [INPUT_IDS.BANK_INFO_STEP.PLAID_MASK]?: string;
+ [INPUT_IDS.BANK_INFO_STEP.ACCOUNT_NUMBER]: string;
+ [INPUT_IDS.BANK_INFO_STEP.ROUTING_NUMBER]: string;
+ [INPUT_IDS.BANK_INFO_STEP.PLAID_ACCOUNT_ID]: string;
+ [INPUT_IDS.BANK_INFO_STEP.PLAID_MASK]: string;
};
type CompanyStepProps = {
- [INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_NAME]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.STREET]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.CITY]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.STATE]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.ZIP_CODE]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_PHONE]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_WEBSITE]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_TAX_ID]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.INCORPORATION_TYPE]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.INCORPORATION_DATE]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.INCORPORATION_STATE]?: string;
- [INPUT_IDS.BUSINESS_INFO_STEP.HAS_NO_CONNECTION_TO_CANNABIS]?: boolean;
+ [INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_NAME]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.STREET]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.CITY]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.STATE]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.ZIP_CODE]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_PHONE]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_WEBSITE]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_TAX_ID]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.INCORPORATION_TYPE]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.INCORPORATION_DATE]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.INCORPORATION_STATE]: string;
+ [INPUT_IDS.BUSINESS_INFO_STEP.HAS_NO_CONNECTION_TO_CANNABIS]: boolean;
};
type RequestorStepProps = {
- [INPUT_IDS.PERSONAL_INFO_STEP.FIRST_NAME]?: string;
- [INPUT_IDS.PERSONAL_INFO_STEP.LAST_NAME]?: string;
- [INPUT_IDS.PERSONAL_INFO_STEP.STREET]?: string;
- [INPUT_IDS.PERSONAL_INFO_STEP.CITY]?: string;
- [INPUT_IDS.PERSONAL_INFO_STEP.STATE]?: string;
- [INPUT_IDS.PERSONAL_INFO_STEP.ZIP_CODE]?: string;
- [INPUT_IDS.PERSONAL_INFO_STEP.DOB]?: string;
- [INPUT_IDS.PERSONAL_INFO_STEP.SSN_LAST_4]?: string;
-};
-
-type BeneficialOwnersStepProps = {
- [INPUT_IDS.BENEFICIAL_OWNER_INFO_STEP.OWNS_MORE_THAN_25_PERCENT]?: boolean;
- [INPUT_IDS.BENEFICIAL_OWNER_INFO_STEP.HAS_OTHER_BENEFICIAL_OWNERS]?: boolean;
- [INPUT_IDS.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNERS]?: string;
- [key: BeneficialOwnerDataKey]: string;
- beneficialOwnerKeys?: string[];
+ [INPUT_IDS.PERSONAL_INFO_STEP.FIRST_NAME]: string;
+ [INPUT_IDS.PERSONAL_INFO_STEP.LAST_NAME]: string;
+ [INPUT_IDS.PERSONAL_INFO_STEP.STREET]: string;
+ [INPUT_IDS.PERSONAL_INFO_STEP.CITY]: string;
+ [INPUT_IDS.PERSONAL_INFO_STEP.STATE]: string;
+ [INPUT_IDS.PERSONAL_INFO_STEP.ZIP_CODE]: string;
+ [INPUT_IDS.PERSONAL_INFO_STEP.DOB]: string;
+ [INPUT_IDS.PERSONAL_INFO_STEP.SSN_LAST_4]: string;
};
type ACHContractStepProps = {
- [INPUT_IDS.COMPLETE_VERIFICATION.ACCEPT_TERMS_AND_CONDITIONS]?: boolean;
- [INPUT_IDS.COMPLETE_VERIFICATION.CERTIFY_TRUE_INFORMATION]?: boolean;
- [INPUT_IDS.COMPLETE_VERIFICATION.IS_AUTHORIZED_TO_USE_BANK_ACCOUNT]?: boolean;
+ [INPUT_IDS.COMPLETE_VERIFICATION.ACCEPT_TERMS_AND_CONDITIONS]: boolean;
+ [INPUT_IDS.COMPLETE_VERIFICATION.CERTIFY_TRUE_INFORMATION]: boolean;
+ [INPUT_IDS.COMPLETE_VERIFICATION.IS_AUTHORIZED_TO_USE_BANK_ACCOUNT]: boolean;
};
type ReimbursementAccountProps = {
- [INPUT_IDS.BANK_INFO_STEP.IS_SAVINGS]?: boolean;
- [INPUT_IDS.BANK_INFO_STEP.BANK_NAME]?: string;
- [INPUT_IDS.BANK_INFO_STEP.PLAID_ACCESS_TOKEN]?: string;
- [INPUT_IDS.AMOUNT1]?: string;
- [INPUT_IDS.AMOUNT2]?: string;
- [INPUT_IDS.AMOUNT3]?: string;
+ [INPUT_IDS.BANK_INFO_STEP.IS_SAVINGS]: boolean;
+ [INPUT_IDS.BANK_INFO_STEP.BANK_NAME]: string;
+ [INPUT_IDS.BANK_INFO_STEP.PLAID_ACCESS_TOKEN]: string;
+ [INPUT_IDS.BANK_INFO_STEP.SELECTED_PLAID_ACCOUNT_ID]: string;
+ [INPUT_IDS.AMOUNT1]: string;
+ [INPUT_IDS.AMOUNT2]: string;
+ [INPUT_IDS.AMOUNT3]: string;
};
-type ReimbursementAccountForm = AdditionalDraftData &
- BeneficialOwnersStepProps &
- Form;
+type ReimbursementAccountForm = ReimbursementAccountFormExtraProps &
+ Form;
export type {
ReimbursementAccountForm,
- AdditionalDraftData,
BeneficialOwnerDataKey,
BankAccountStepProps,
CompanyStepProps,
diff --git a/src/types/form/ReportDescriptionForm.ts b/src/types/form/ReportDescriptionForm.ts
index 18f42ad80182..b3300051d90c 100644
--- a/src/types/form/ReportDescriptionForm.ts
+++ b/src/types/form/ReportDescriptionForm.ts
@@ -1,12 +1,18 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
REPORT_DESCRIPTION: 'reportDescription',
} as const;
-type ReportDescriptionForm = Form<{
- [INPUT_IDS.REPORT_DESCRIPTION]: string;
-}>;
+type InputID = ValueOf;
+
+type ReportDescriptionForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.REPORT_DESCRIPTION]: string;
+ }
+>;
export type {ReportDescriptionForm};
export default INPUT_IDS;
diff --git a/src/types/form/ReportFieldEditForm.ts b/src/types/form/ReportFieldEditForm.ts
index 3dce5456875c..7befa2cb6502 100644
--- a/src/types/form/ReportFieldEditForm.ts
+++ b/src/types/form/ReportFieldEditForm.ts
@@ -1,6 +1,6 @@
import type Form from './Form';
-type ReportFieldEditForm = Form>;
+type ReportFieldEditForm = Form>;
// eslint-disable-next-line import/prefer-default-export
export type {ReportFieldEditForm};
diff --git a/src/types/form/ReportPhysicalCardForm.ts b/src/types/form/ReportPhysicalCardForm.ts
new file mode 100644
index 000000000000..2daafb974c2c
--- /dev/null
+++ b/src/types/form/ReportPhysicalCardForm.ts
@@ -0,0 +1,6 @@
+import type Form from './Form';
+
+type ReportPhysicalCardForm = Form;
+
+// eslint-disable-next-line import/prefer-default-export
+export type {ReportPhysicalCardForm};
diff --git a/src/types/form/RoomNameForm.ts b/src/types/form/RoomNameForm.ts
index 0a128d2c175f..b9fb996986b9 100644
--- a/src/types/form/RoomNameForm.ts
+++ b/src/types/form/RoomNameForm.ts
@@ -1,12 +1,18 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
ROOM_NAME: 'roomName',
} as const;
-type RoomNameForm = Form<{
- [INPUT_IDS.ROOM_NAME]: string;
-}>;
+type InputID = ValueOf;
+
+type RoomNameForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.ROOM_NAME]: string;
+ }
+>;
export type {RoomNameForm};
export default INPUT_IDS;
diff --git a/src/types/form/SettingsStatusClearDateForm.ts b/src/types/form/SettingsStatusClearDateForm.ts
index fdb191cb4bc5..65f2e046d272 100644
--- a/src/types/form/SettingsStatusClearDateForm.ts
+++ b/src/types/form/SettingsStatusClearDateForm.ts
@@ -1,12 +1,18 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
DATE_TIME: 'dateTime',
} as const;
-type SettingsStatusClearDateForm = Form<{
- [INPUT_IDS.DATE_TIME]: string;
-}>;
+type InputID = ValueOf;
+
+type SettingsStatusClearDateForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.DATE_TIME]: string;
+ }
+>;
export type {SettingsStatusClearDateForm};
export default INPUT_IDS;
diff --git a/src/types/form/WorkspaceDescriptionForm.ts b/src/types/form/WorkspaceDescriptionForm.ts
index b06a37abf36b..e6f9596e79a1 100644
--- a/src/types/form/WorkspaceDescriptionForm.ts
+++ b/src/types/form/WorkspaceDescriptionForm.ts
@@ -1,12 +1,18 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
DESCRIPTION: 'description',
} as const;
-type WorkspaceDescriptionForm = Form<{
- [INPUT_IDS.DESCRIPTION]: string;
-}>;
+type InputID = ValueOf;
+
+type WorkspaceDescriptionForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.DESCRIPTION]: string;
+ }
+>;
export type {WorkspaceDescriptionForm};
export default INPUT_IDS;
diff --git a/src/types/form/WorkspaceInviteMessageForm.ts b/src/types/form/WorkspaceInviteMessageForm.ts
index 268e29224f35..7f36b3281703 100644
--- a/src/types/form/WorkspaceInviteMessageForm.ts
+++ b/src/types/form/WorkspaceInviteMessageForm.ts
@@ -1,12 +1,18 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
WELCOME_MESSAGE: 'welcomeMessage',
} as const;
-type WorkspaceInviteMessageForm = Form<{
- [INPUT_IDS.WELCOME_MESSAGE]: string;
-}>;
+type InputID = ValueOf;
+
+type WorkspaceInviteMessageForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.WELCOME_MESSAGE]: string;
+ }
+>;
export type {WorkspaceInviteMessageForm};
export default INPUT_IDS;
diff --git a/src/types/form/WorkspaceProfileDescriptionForm.ts b/src/types/form/WorkspaceProfileDescriptionForm.ts
deleted file mode 100644
index 2fc84334c03e..000000000000
--- a/src/types/form/WorkspaceProfileDescriptionForm.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type Form from './Form';
-
-const INPUT_IDS = {
- DESCRIPTION: 'description',
-} as const;
-
-type WorkspaceProfileDescriptionForm = Form<{
- [INPUT_IDS.DESCRIPTION]: string;
-}>;
-
-export type {WorkspaceProfileDescriptionForm};
-export default INPUT_IDS;
diff --git a/src/types/form/WorkspaceRateAndUnitForm.ts b/src/types/form/WorkspaceRateAndUnitForm.ts
index 52f13d3a1415..566f10df9d9d 100644
--- a/src/types/form/WorkspaceRateAndUnitForm.ts
+++ b/src/types/form/WorkspaceRateAndUnitForm.ts
@@ -1,3 +1,4 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
@@ -5,10 +6,15 @@ const INPUT_IDS = {
UNIT: 'unit',
} as const;
-type WorkspaceRateAndUnitForm = Form<{
- [INPUT_IDS.RATE]: string;
- [INPUT_IDS.UNIT]: string;
-}>;
+type InputID = ValueOf;
+
+type WorkspaceRateAndUnitForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.RATE]: string;
+ [INPUT_IDS.UNIT]: string;
+ }
+>;
export type {WorkspaceRateAndUnitForm};
export default INPUT_IDS;
diff --git a/src/types/form/WorkspaceSettingsForm.ts b/src/types/form/WorkspaceSettingsForm.ts
index a3714583477a..8e2f580942c9 100644
--- a/src/types/form/WorkspaceSettingsForm.ts
+++ b/src/types/form/WorkspaceSettingsForm.ts
@@ -1,12 +1,18 @@
+import type {ValueOf} from 'type-fest';
import type Form from './Form';
const INPUT_IDS = {
NAME: 'name',
} as const;
-type WorkspaceSettingsForm = Form<{
- [INPUT_IDS.NAME]: string;
-}>;
+type InputID = ValueOf;
+
+type WorkspaceSettingsForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.NAME]: string;
+ }
+>;
export type {WorkspaceSettingsForm};
export default INPUT_IDS;
diff --git a/src/types/form/index.ts b/src/types/form/index.ts
index f188dee6ab4f..1ff8d0df2031 100644
--- a/src/types/form/index.ts
+++ b/src/types/form/index.ts
@@ -3,13 +3,15 @@ export type {CloseAccountForm} from './CloseAccountForm';
export type {DateOfBirthForm} from './DateOfBirthForm';
export type {DisplayNameForm} from './DisplayNameForm';
export type {EditTaskForm} from './EditTaskForm';
+export type {ExitSurveyReasonForm} from './ExitSurveyReasonForm';
+export type {ExitSurveyResponseForm} from './ExitSurveyResponseForm';
export type {GetPhysicalCardForm} from './GetPhysicalCardForm';
export type {HomeAddressForm} from './HomeAddressForm';
export type {IKnowTeacherForm} from './IKnowTeacherForm';
export type {IntroSchoolPrincipalForm} from './IntroSchoolPrincipalForm';
export type {LegalNameForm} from './LegalNameForm';
export type {MoneyRequestAmountForm} from './MoneyRequestAmountForm';
-export type {MoneyRequestCreatedForm} from './MoneyRequestCreatedForm';
+export type {MoneyRequestDateForm} from './MoneyRequestDateForm';
export type {MoneyRequestDescriptionForm} from './MoneyRequestDescriptionForm';
export type {MoneyRequestMerchantForm} from './MoneyRequestMerchantForm';
export type {MoneyRequestHoldReasonForm} from './MoneyRequestHoldReasonForm';
@@ -33,6 +35,6 @@ export type {WaypointForm} from './WaypointForm';
export type {WorkspaceInviteMessageForm} from './WorkspaceInviteMessageForm';
export type {WorkspaceRateAndUnitForm} from './WorkspaceRateAndUnitForm';
export type {WorkspaceSettingsForm} from './WorkspaceSettingsForm';
+export type {ReportPhysicalCardForm} from './ReportPhysicalCardForm';
export type {WorkspaceDescriptionForm} from './WorkspaceDescriptionForm';
-export type {WorkspaceProfileDescriptionForm} from './WorkspaceProfileDescriptionForm';
export type {default as Form} from './Form';
diff --git a/src/types/onyx/Currency.ts b/src/types/onyx/Currency.ts
index a4767403381f..b8d6f8dda88b 100644
--- a/src/types/onyx/Currency.ts
+++ b/src/types/onyx/Currency.ts
@@ -21,4 +21,7 @@ type Currency = {
cacheBurst?: number;
};
+type CurrencyList = Record;
+
export default Currency;
+export type {CurrencyList};
diff --git a/src/types/onyx/Fund.ts b/src/types/onyx/Fund.ts
index 842a882ff23f..3073172a5eec 100644
--- a/src/types/onyx/Fund.ts
+++ b/src/types/onyx/Fund.ts
@@ -40,4 +40,4 @@ type Fund = {
type FundList = Record;
export default Fund;
-export type {FundList};
+export type {AccountData, FundList};
diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts
index b04011978d73..6bbcb174a617 100644
--- a/src/types/onyx/IOU.ts
+++ b/src/types/onyx/IOU.ts
@@ -35,6 +35,8 @@ type Split = {
createdChatReportActionID?: string;
createdIOUReportActionID?: string;
reportPreviewReportActionID?: string;
+ transactionThreadReportID?: string;
+ createdReportActionIDForThread?: string;
};
type IOU = {
diff --git a/src/types/onyx/Network.ts b/src/types/onyx/Network.ts
index 32b084bbf2f7..173ca486b53c 100644
--- a/src/types/onyx/Network.ts
+++ b/src/types/onyx/Network.ts
@@ -1,6 +1,6 @@
type Network = {
/** Is the network currently offline or not */
- isOffline?: boolean;
+ isOffline: boolean;
/** Should the network be forced offline */
shouldForceOffline?: boolean;
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 7d4c08374b81..b79325611e9f 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -11,6 +11,7 @@ type Rate = {
customUnitRateID?: string;
errors?: OnyxCommon.Errors;
pendingAction?: OnyxCommon.PendingAction;
+ enabled?: boolean;
};
type Attributes = {
@@ -22,6 +23,8 @@ type CustomUnit = {
customUnitID: string;
attributes: Attributes;
rates: Record;
+ defaultCategory?: string;
+ enabled?: boolean;
pendingAction?: OnyxCommon.PendingAction;
errors?: OnyxCommon.Errors;
};
@@ -31,6 +34,42 @@ type DisabledFields = {
reimbursable?: boolean;
};
+type TaxRate = {
+ /** Name of the a tax rate. */
+ name: string;
+
+ /** The value of the tax rate as percentage. */
+ value: string;
+
+ /** The code associated with the tax rate. */
+ code: string;
+
+ /** This contains the tax name and tax value as one name */
+ modifiedName: string;
+
+ /** Indicates if the tax rate is disabled. */
+ isDisabled?: boolean;
+};
+
+type TaxRates = Record;
+
+type TaxRatesWithDefault = {
+ /** Name of the tax */
+ name: string;
+
+ /** Default policy tax code */
+ defaultExternalID: string;
+
+ /** Default value of taxes */
+ defaultValue: string;
+
+ /** Default foreign policy tax code */
+ foreignTaxDefault: string;
+
+ /** List of tax names and values */
+ taxes: TaxRates;
+};
+
// These types are for the Integration connections for a policy (eg. Quickbooks, Xero, etc).
// This data is not yet used in the codebase which is why it is given a very generic type, but the data is being put into Onyx for future use.
// Once the data is being used, these types should be defined appropriately.
@@ -102,9 +141,12 @@ type Policy = {
enabled: boolean;
};
- /** Whether the self approval or submitting is enabled */
+ /** @deprecated Whether the self approval or submitting is enabled */
isPreventSelfApprovalEnabled?: boolean;
+ /** Whether the self approval or submitting is enabled */
+ preventSelfApprovalEnabled?: boolean;
+
/** When the monthly scheduled submit should happen */
autoReportingOffset?: AutoReportingOffset;
@@ -124,7 +166,7 @@ type Policy = {
makeMeAdmin?: boolean;
/** Pending fields for the policy */
- pendingFields?: Record;
+ pendingFields?: Record;
/** Original file name which is used for the policy avatar */
originalFileName?: string;
@@ -138,9 +180,15 @@ type Policy = {
/** Whether policy is updating */
isPolicyUpdating?: boolean;
+ /** The approver of the policy */
+ approver?: string;
+
/** The approval mode set up on this policy */
approvalMode?: ValueOf;
+ /** Whether the auto approval is enabled */
+ isAutoApprovalEnabled?: boolean;
+
/** Whether transactions should be billable by default */
defaultBillable?: boolean;
@@ -171,6 +219,9 @@ type Policy = {
trackingEnabled: boolean;
};
+ /** Collection of tax rates attached to a policy */
+ taxRates?: TaxRatesWithDefault;
+
/** ReportID of the admins room for this workspace */
chatReportIDAdmins?: number;
@@ -183,4 +234,4 @@ type Policy = {
export default Policy;
-export type {Unit, CustomUnit, Attributes, Rate};
+export type {Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault};
diff --git a/src/types/onyx/PolicyTaxRates.ts b/src/types/onyx/PolicyTaxRates.ts
deleted file mode 100644
index e2bea4a3fa44..000000000000
--- a/src/types/onyx/PolicyTaxRates.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-type PolicyTaxRate = {
- /** The name of the tax rate. */
- name: string;
-
- /** The value of the tax rate. */
- value: string;
-
- /** The code associated with the tax rate. */
- code: string;
-
- /** This contains the tax name and tax value as one name */
- modifiedName: string;
-
- /** Indicates if the tax rate is disabled. */
- isDisabled?: boolean;
-};
-
-type PolicyTaxRates = Record;
-
-export type {PolicyTaxRates, PolicyTaxRate};
diff --git a/src/types/onyx/PrivatePersonalDetails.ts b/src/types/onyx/PrivatePersonalDetails.ts
index 4d0dedf16ea7..780e3f71b61d 100644
--- a/src/types/onyx/PrivatePersonalDetails.ts
+++ b/src/types/onyx/PrivatePersonalDetails.ts
@@ -1,5 +1,6 @@
type Address = {
street: string;
+ street2?: string;
city: string;
state: string;
zip: string;
diff --git a/src/types/onyx/RecentWaypoint.ts b/src/types/onyx/RecentWaypoint.ts
index 097aed3be916..55232f7ef71d 100644
--- a/src/types/onyx/RecentWaypoint.ts
+++ b/src/types/onyx/RecentWaypoint.ts
@@ -3,13 +3,13 @@ type RecentWaypoint = {
name?: string;
/** The full address of the waypoint */
- address: string;
+ address?: string;
/** The lattitude of the waypoint */
- lat: number;
+ lat?: number;
/** The longitude of the waypoint */
- lng: number;
+ lng?: number;
};
export default RecentWaypoint;
diff --git a/src/types/onyx/ReimbursementAccount.ts b/src/types/onyx/ReimbursementAccount.ts
index 173200ba681a..5b16d15bf5cc 100644
--- a/src/types/onyx/ReimbursementAccount.ts
+++ b/src/types/onyx/ReimbursementAccount.ts
@@ -8,37 +8,34 @@ type BankAccountStep = ValueOf;
type BankAccountSubStep = ValueOf;
-type ACHData = BeneficialOwnersStepProps &
- CompanyStepProps &
- RequestorStepProps &
- ACHContractStepProps & {
- /** Step of the setup flow that we are on. Determines which view is presented. */
- currentStep?: BankAccountStep;
+type ACHData = Partial & {
+ /** Step of the setup flow that we are on. Determines which view is presented. */
+ currentStep?: BankAccountStep;
- /** Optional subStep we would like the user to start back on */
- subStep?: BankAccountSubStep;
+ /** Optional subStep we would like the user to start back on */
+ subStep?: BankAccountSubStep;
- /** Bank account state */
- state?: string;
+ /** Bank account state */
+ state?: string;
- /** Bank account ID of the VBA that we are validating is required */
- bankAccountID?: number;
+ /** Bank account ID of the VBA that we are validating is required */
+ bankAccountID?: number;
- /** Bank account routing number */
- routingNumber?: string;
+ /** Bank account routing number */
+ routingNumber?: string;
- /** Bank account number */
- accountNumber?: string;
+ /** Bank account number */
+ accountNumber?: string;
- /** Bank account name */
- bankName?: BankName;
+ /** Bank account name */
+ bankName?: BankName;
- /** Bank account owner name */
- addressName?: string;
+ /** Bank account owner name */
+ addressName?: string;
- /** Policy ID of the workspace the bank account is being set up on */
- policyID?: string;
- };
+ /** Policy ID of the workspace the bank account is being set up on */
+ policyID?: string;
+};
type ReimbursementAccount = {
/** Whether we are loading the data via the API */
diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
index fbd61a9c5365..f5c4606fd335 100644
--- a/src/types/onyx/Report.ts
+++ b/src/types/onyx/Report.ts
@@ -8,6 +8,8 @@ type NotificationPreference = ValueOf;
+type RoomVisibility = ValueOf;
+
type Note = {
note: string;
errors?: OnyxCommon.Errors;
@@ -110,7 +112,7 @@ type Report = {
openOnAdminRoom?: boolean;
/** The report visibility */
- visibility?: ValueOf;
+ visibility?: RoomVisibility;
/** Report cached total */
cachedTotal?: string;
@@ -178,4 +180,4 @@ type Report = {
export default Report;
-export type {NotificationPreference, WriteCapability, Note};
+export type {NotificationPreference, RoomVisibility, WriteCapability, Note};
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index ee6a7f2d0b4c..1324bb9c6902 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -16,6 +16,24 @@ type Waypoint = {
/** The longitude of the waypoint */
lng?: number;
+
+ /** Address city */
+ city?: string;
+
+ /** Address state */
+ state?: string;
+
+ /** Address zip code */
+ zipCode?: string;
+
+ /** Address country */
+ country?: string;
+
+ /** Address street line 1 */
+ street?: string;
+
+ /** Address street line 2 */
+ street2?: string;
};
type WaypointCollection = Record;
diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts
index b1764b4aeb80..9133eca63c65 100644
--- a/src/types/onyx/TransactionViolation.ts
+++ b/src/types/onyx/TransactionViolation.ts
@@ -9,7 +9,6 @@ type ViolationName = (typeof CONST.VIOLATIONS)[keyof typeof CONST.VIOLATIONS];
type TransactionViolation = {
type: string;
name: ViolationName;
- userMessage: string;
data?: {
rejectedBy?: string;
rejectReason?: string;
diff --git a/src/types/onyx/WalletTransfer.ts b/src/types/onyx/WalletTransfer.ts
index 3151ecb3ad5c..961a7c9752a5 100644
--- a/src/types/onyx/WalletTransfer.ts
+++ b/src/types/onyx/WalletTransfer.ts
@@ -25,7 +25,7 @@ type WalletTransfer = {
paymentMethodType?: ValueOf>;
};
-type FilterMethodPaymentType = typeof CONST.PAYMENT_METHODS.DEBIT_CARD | typeof CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT | null;
+type FilterMethodPaymentType = typeof CONST.PAYMENT_METHODS.DEBIT_CARD | typeof CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT | '';
export default WalletTransfer;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index 1b2ecdbdce12..6846fc302639 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -9,6 +9,7 @@ import type {CardList} from './Card';
import type Log from './Console';
import type Credentials from './Credentials';
import type Currency from './Currency';
+import type {CurrencyList} from './Currency';
import type CustomStatusDraft from './CustomStatusDraft';
import type Download from './Download';
import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji';
@@ -30,6 +31,7 @@ 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 {PolicyCategories, PolicyCategory} from './PolicyCategory';
import type {PolicyMembers} from './PolicyMember';
import type PolicyMember from './PolicyMember';
@@ -81,6 +83,7 @@ export type {
CardList,
Credentials,
Currency,
+ CurrencyList,
CustomStatusDraft,
Download,
FrequentlyUsedEmoji,
@@ -127,6 +130,9 @@ export type {
SecurityGroup,
Session,
Task,
+ TaxRate,
+ TaxRates,
+ TaxRatesWithDefault,
Transaction,
TransactionViolation,
TransactionViolations,
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index cb31afbf8f8f..ec32be4d241c 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -58,6 +58,8 @@ describe('actions/IOU', () => {
let createdAction;
let iouAction;
let transactionID;
+ let transactionThread;
+ let transactionThreadCreatedAction;
fetch.pause();
IOU.requestMoney({}, amount, CONST.CURRENCY.USD, '', merchant, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return waitForBatchedUpdates()
@@ -70,14 +72,16 @@ describe('actions/IOU', () => {
callback: (allReports) => {
Onyx.disconnect(connectionID);
- // A chat report and an iou report should be created
+ // A chat report, a transaction thread, and an iou report should be created
const chatReports = _.filter(allReports, (report) => report.type === CONST.REPORT.TYPE.CHAT);
const iouReports = _.filter(allReports, (report) => report.type === CONST.REPORT.TYPE.IOU);
- expect(_.size(chatReports)).toBe(1);
+ expect(_.size(chatReports)).toBe(2);
expect(_.size(iouReports)).toBe(1);
const chatReport = chatReports[0];
+ const transactionThreadReport = chatReports[1];
const iouReport = iouReports[0];
iouReportID = iouReport.reportID;
+ transactionThread = transactionThreadReport;
expect(iouReport.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
@@ -132,6 +136,27 @@ describe('actions/IOU', () => {
});
}),
)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (reportActionsForTransactionThread) => {
+ Onyx.disconnect(connectionID);
+
+ // The transaction thread should have a CREATED action
+ expect(_.size(reportActionsForTransactionThread)).toBe(1);
+ const createdActions = _.filter(reportActionsForTransactionThread, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED);
+ expect(_.size(createdActions)).toBe(1);
+ transactionThreadCreatedAction = createdActions[0];
+
+ expect(transactionThreadCreatedAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+ resolve();
+ },
+ });
+ }),
+ )
.then(
() =>
new Promise((resolve) => {
@@ -237,8 +262,8 @@ describe('actions/IOU', () => {
callback: (allReports) => {
Onyx.disconnect(connectionID);
- // The same chat report should be reused, and an IOU report should be created
- expect(_.size(allReports)).toBe(2);
+ // The same chat report should be reused, a transaction thread and an IOU report should be created
+ expect(_.size(allReports)).toBe(3);
expect(_.find(allReports, (report) => report.type === CONST.REPORT.TYPE.CHAT).reportID).toBe(chatReport.reportID);
chatReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.CHAT);
const iouReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.IOU);
@@ -431,7 +456,7 @@ describe('actions/IOU', () => {
Onyx.disconnect(connectionID);
// No new reports should be created
- expect(_.size(allReports)).toBe(2);
+ expect(_.size(allReports)).toBe(3);
expect(_.find(allReports, (report) => report.reportID === chatReportID)).toBeTruthy();
expect(_.find(allReports, (report) => report.reportID === iouReportID)).toBeTruthy();
@@ -550,6 +575,8 @@ describe('actions/IOU', () => {
let createdAction;
let iouAction;
let transactionID;
+ let transactionThreadReport;
+ let transactionThreadAction;
fetch.pause();
IOU.requestMoney({}, amount, CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return (
@@ -563,13 +590,15 @@ describe('actions/IOU', () => {
callback: (allReports) => {
Onyx.disconnect(connectionID);
- // A chat report and an iou report should be created
+ // A chat report, transaction thread and an iou report should be created
const chatReports = _.filter(allReports, (report) => report.type === CONST.REPORT.TYPE.CHAT);
const iouReports = _.filter(allReports, (report) => report.type === CONST.REPORT.TYPE.IOU);
- expect(_.size(chatReports)).toBe(1);
+ expect(_.size(chatReports)).toBe(2);
expect(_.size(iouReports)).toBe(1);
const chatReport = chatReports[0];
chatReportID = chatReport.reportID;
+ transactionThreadReport = chatReports[1];
+
const iouReport = iouReports[0];
iouReportID = iouReport.reportID;
@@ -674,6 +703,25 @@ describe('actions/IOU', () => {
});
}),
)
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`,
+ waitForCollectionCallback: true,
+ callback: (reportActionsForTransactionThread) => {
+ Onyx.disconnect(connectionID);
+ expect(_.size(reportActionsForTransactionThread)).toBe(3);
+ transactionThreadAction = _.find(
+ reportActionsForTransactionThread[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`],
+ (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED,
+ );
+ expect(transactionThreadAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
+ resolve();
+ },
+ });
+ }),
+ )
.then(
() =>
new Promise((resolve) => {
@@ -696,6 +744,7 @@ describe('actions/IOU', () => {
() =>
new Promise((resolve) => {
ReportActions.clearReportActionErrors(iouReportID, iouAction);
+ ReportActions.clearReportActionErrors(transactionThreadReport.reportID, transactionThreadAction);
resolve();
}),
)
@@ -738,6 +787,7 @@ describe('actions/IOU', () => {
() =>
new Promise((resolve) => {
Report.deleteReport(chatReportID);
+ Report.deleteReport(transactionThreadReport.reportID);
resolve();
}),
)
@@ -938,8 +988,8 @@ describe('actions/IOU', () => {
callback: (allReports) => {
Onyx.disconnect(connectionID);
- // There should now be 7 reports
- expect(_.size(allReports)).toBe(7);
+ // There should now be 10 reports
+ expect(_.size(allReports)).toBe(10);
// 1. The chat report with Rory + Carlos
carlosChatReport = _.find(allReports, (report) => report.reportID === carlosChatReport.reportID);
@@ -1006,8 +1056,8 @@ describe('actions/IOU', () => {
callback: (allReportActions) => {
Onyx.disconnect(connectionID);
- // There should be reportActions on all 4 chat reports + 3 IOU reports in each 1:1 chat
- expect(_.size(allReportActions)).toBe(7);
+ // There should be reportActions on all 7 chat reports + 3 IOU reports in each 1:1 chat
+ expect(_.size(allReportActions)).toBe(10);
const carlosReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${carlosChatReport.iouReportID}`];
const julesReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesChatReport.iouReportID}`];
@@ -1220,9 +1270,10 @@ describe('actions/IOU', () => {
callback: (allReports) => {
Onyx.disconnect(connectionID);
- expect(_.size(allReports)).toBe(2);
+ expect(_.size(allReports)).toBe(3);
- chatReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.CHAT);
+ const chatReports = _.filter(allReports, (report) => report.type === CONST.REPORT.TYPE.CHAT);
+ chatReport = chatReports[0];
expect(chatReport).toBeTruthy();
expect(chatReport).toHaveProperty('reportID');
expect(chatReport).toHaveProperty('iouReportID');
@@ -1299,7 +1350,7 @@ describe('actions/IOU', () => {
callback: (allReports) => {
Onyx.disconnect(connectionID);
- expect(_.size(allReports)).toBe(2);
+ expect(_.size(allReports)).toBe(3);
chatReport = _.find(allReports, (r) => r.type === CONST.REPORT.TYPE.CHAT);
iouReport = _.find(allReports, (r) => r.type === CONST.REPORT.TYPE.IOU);
@@ -1348,7 +1399,7 @@ describe('actions/IOU', () => {
callback: (allReports) => {
Onyx.disconnect(connectionID);
- expect(_.size(allReports)).toBe(2);
+ expect(_.size(allReports)).toBe(3);
chatReport = _.find(allReports, (r) => r.type === CONST.REPORT.TYPE.CHAT);
iouReport = _.find(allReports, (r) => r.type === CONST.REPORT.TYPE.IOU);
@@ -1742,7 +1793,7 @@ describe('actions/IOU', () => {
}),
]),
originalMessage: expect.objectContaining({
- amount: -amount,
+ amount,
paymentType: CONST.IOU.PAYMENT_TYPE.VBBA,
type: 'pay',
}),
@@ -1903,8 +1954,8 @@ describe('actions/IOU', () => {
});
});
- // Then we should have exactly 2 reports
- expect(_.size(allReports)).toBe(2);
+ // Then we should have exactly 3 reports
+ expect(_.size(allReports)).toBe(3);
// Then one of them should be a chat report with relevant properties
chatReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.CHAT);
diff --git a/tests/e2e/ADDING_TESTS.md b/tests/e2e/ADDING_TESTS.md
index 8d4683636e70..f262a5ed9a0a 100644
--- a/tests/e2e/ADDING_TESTS.md
+++ b/tests/e2e/ADDING_TESTS.md
@@ -1,51 +1,5 @@
# Adding new E2E Tests
-## Running your new test in development mode
-
-Typically you'd run all the tests with `npm run test:e2e` on your machine.
-This will run the tests with some local settings, however that is not
-optimal when you add a new test for which you want to quickly test if it works, as the prior command
-still runs the release version of the app, which is hard to debug.
-
-I recommend doing the following.
-
-1. We need to compile a android development app version that has capturing metrics enabled:
-```bash
-# Make sure that your .env file is the one we need for e2e testing:
-cp ./tests/e2e/.env.e2e .env
-
-# Build the android app like you normally would with
-npm run android
-```
-2. Rename `./index.js` to `./appIndex.js`
-3. Create a new `./index.js` with the following content:
-```js
-require('./src/libs/E2E/reactNativeLaunchingTest');
-```
-4. In `./src/libs/E2E/reactNativeLaunchingTest.ts` change the main app import to the new `./appIndex.js` file:
-```diff
-- import '../../../index';
-+ import '../../../appIndex';
-```
-
-> [!WARNING]
-> Make sure to not commit these changes to the repository!
-
-Now you can start the metro bundler in e2e mode with:
-
-```bash
-CAPTURE_METRICS=true E2E_TESTING=true npm start -- --reset-cache
-```
-
-Then we can execute our test with:
-
-```
-npm run test:e2e:dev -- --includes "My new test name"
-```
-
-> - `--includes "MyTestName"` will only run the test with the name "MyTestName", but is optional
-
-
## Creating a new test
Tests are executed on device, inside the app code.
@@ -144,8 +98,13 @@ Done! When you now start the test runner, your new test will be executed as well
## Quickly test your test
-To check your new test you can simply run `npm run test:e2e`, which uses the
-`--development` flag. This will run the tests on the branch you are currently on, runs fewer iterations and most importantly, it tries to reuse the existing APK and just patch into the new app bundle, instead of rebuilding the release app from scratch.
+> [!TIP]
+> You can only run a specific test by specifying the `--includes` flag:
+> ```sh
+> npm run test:e2e:dev -- --includes "My new test name"
+> ```
+
+It is recommended to run a debug build of the e2e tests first to iterate quickly on your test. Follow the explanation in the [README](./README.md) to create a debug build.
## Debugging your test
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index 64d11d3b2ca4..5f124f20e872 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -6,20 +6,6 @@ run the actual app on a real device (physical or emulated).
![Example of a e2e test run](https://raw.githubusercontent.com/hannojg/expensify-app/5f945c25e2a0650753f47f3f541b984f4d114f6d/e2e/example.gif)
-To run the e2e tests:
-
-1. Connect an android device. The tests are currently designed to run only on android. It can be
- a physical device or an emulator.
-
-2. Make sure Fastlane was initialized by running `bundle install`
-
-3. Run the tests with `npm run test:e2e`.
- > 💡 Tip: To run the tests locally faster, and you are only making changes to JS, it's recommended to
- build the app once with `npm run android-build-e2e` and from then on run the tests with
- `npm run test:e2e -- --buildMode js-only`. This will only rebuild the JS code, and not the
- whole native app!
-
-Ideally you want to run these tests on your branch before you want to merge your new feature to `main`.
## Available CLI options
@@ -27,23 +13,78 @@ The tests can be run with the following CLI options:
- `--config`: Extend/Overwrite the default config with your values, e.g. `--config config.local.ts`
- `--includes`: Expects a string/regexp to filter the tests to run, e.g. `--includes "login|signup"`
-- `--skipInstallDeps`: Skips the `npm install` step, useful during development
-- `--development`: Applies some default configurations:
- - Sets the config to `config.local.ts`, which executes the tests with fewer iterations
- - Runs the tests only on the current branch
-- `--buildMode`: There are three build modes, the default is `full`:
- 1. **full**: rebuilds the full native app in (e2e) release mode
- 2. **js-only**: only rebuilds the js bundle, and then re-packages
- the existing native app with the new package. If there
- is no existing native app, it will fallback to mode "full"
- 3. **skip**: does not rebuild anything, and just runs the existing native app
-- `--skipCheckout`: Won't checkout any baseline or comparison branch, and will just run the tests
-
-## Available environment variables
-
-The tests can be run with the following environment variables:
-
-- `baseline`: Change the baseline to run the tests again (default is `main`).
+
+## Running the tests on your machine
+
+You have two options when running the e2e tests:
+
+1. Run a debug build of the app (useful when developing a test)
+2. Run two (e2e) release builds against each other (useful to test performance regression and the suite as a whole)
+
+### Running a debug build
+
+1. You need to create a debug build of the app that's configured with some build flags to enable e2e testing.
+The build flags should be specified in your `./.env` file. You can use the `./tests/e2e/.env.e2e` file as a template:
+
+```sh
+cp ./tests/e2e/.env.e2e .env
+```
+
+> [!IMPORTANT]
+> There are some non-public environment variables that you still have to add to the `.env` file. Ask on slack for the values (cc @vit, @andrew, @hanno gödecke).
+
+2. Create a new android build like you usually would:
+
+```sh
+npm run android
+```
+
+3. We need to modify the app entry to point to the one for the tests. Therefore rename `./index.js` to `./appIndex.js` temporarily.
+
+4. Create a new `./index.js` with the following content:
+```js
+require('./src/libs/E2E/reactNativeLaunchingTest');
+```
+
+5. In `./src/libs/E2E/reactNativeLaunchingTest.ts` change the main app import to the new `./appIndex.js` file:
+```diff
+- import '../../../index';
++ import '../../../appIndex';
+```
+
+6. You can now run the tests. This command will invoke the test runner:
+
+```sh
+npm run test:e2e:dev
+```
+
+### Running two release builds
+
+The e2e tests are meant to detect performance regressions. For that we need to compare two builds against each other. On the CI system this is e.g. the latest release build (baseline) VS the currently merged PR (compare).
+
+You need to build the two apps first. Note that the two apps will be installed on the same device at the same time, so both apps have a different package name. Therefor, we have special build types for the e2e tests.
+
+1. Create a new android build for the baseline:
+
+> [!IMPORTANT]
+> There are some non-public environment variables that you still have to add to the `./tests/e2e/.env.e2e` and `./tests/e2e/.env.e2edelta` file. Ask on slack for the values (cc @vit, @andrew, @hanno gödecke).
+
+```sh
+npm run android-build-e2e
+```
+
+2. Create a new android build for the compare:
+
+```sh
+npm run android-build-e2edelta
+```
+
+3. Run the tests:
+
+```sh
+npm run test:e2e
+```
+
## Performance regression testing
diff --git a/tests/e2e/TestSpec.yml b/tests/e2e/TestSpec.yml
index acc5926e93a5..e0dcd2b9b66d 100644
--- a/tests/e2e/TestSpec.yml
+++ b/tests/e2e/TestSpec.yml
@@ -22,7 +22,7 @@ phases:
commands:
- cd zip
- npm install underscore ts-node typescript
- - npx ts-node e2e/testRunner.js -- --skipInstallDeps --buildMode "skip" --skipCheckout --mainAppPath app-e2eRelease.apk --deltaAppPath app-e2edeltaRelease.apk
+ - npx ts-node e2e/testRunner.js -- --mainAppPath app-e2eRelease.apk --deltaAppPath app-e2edeltaRelease.apk
artifacts:
- $WORKING_DIRECTORY
diff --git a/tests/e2e/compare/math.js b/tests/e2e/compare/math.ts
similarity index 83%
rename from tests/e2e/compare/math.js
rename to tests/e2e/compare/math.ts
index a87c58c4dff3..59a56dd3c842 100644
--- a/tests/e2e/compare/math.js
+++ b/tests/e2e/compare/math.ts
@@ -7,13 +7,8 @@
*
* Based on :: https://github.com/v8/v8/blob/master/test/benchmarks/csuite/compare-baseline.py
*
- * @param {Number} baselineMean
- * @param {Number} baselineStdev
- * @param {Number} currentMean
- * @param {Number} runs
- * @returns {Number}
*/
-const computeZ = (baselineMean, baselineStdev, currentMean, runs) => {
+const computeZ = (baselineMean: number, baselineStdev: number, currentMean: number, runs: number): number => {
if (baselineStdev === 0) {
return 1000;
}
@@ -26,10 +21,8 @@ const computeZ = (baselineMean, baselineStdev, currentMean, runs) => {
*
* Based on :: https://github.com/v8/v8/blob/master/test/benchmarks/csuite/compare-baseline.py
*
- * @param {Number} z
- * @returns {Number}
*/
-const computeProbability = (z) => {
+const computeProbability = (z: number): number => {
// p 0.005: two sided < 0.01
if (z > 2.575829) {
return 0;
diff --git a/tests/e2e/config.dev.js b/tests/e2e/config.dev.js
index 8d12fb5ce007..0e5d3dc01a95 100644
--- a/tests/e2e/config.dev.js
+++ b/tests/e2e/config.dev.js
@@ -7,4 +7,5 @@ export default {
MAIN_APP_PATH: appPath,
DELTA_APP_PATH: appPath,
RUNS: 8,
+ BOOT_COOL_DOWN: 5 * 1000,
};
diff --git a/tests/e2e/measure/math.js b/tests/e2e/measure/math.ts
similarity index 64%
rename from tests/e2e/measure/math.js
rename to tests/e2e/measure/math.ts
index 14f75a7f980e..e1c0cb981a0c 100644
--- a/tests/e2e/measure/math.js
+++ b/tests/e2e/measure/math.ts
@@ -1,6 +1,13 @@
-import _ from 'underscore';
+type Entries = number[];
-const filterOutliersViaIQR = (data) => {
+type Stats = {
+ mean: number;
+ stdev: number;
+ runs: number;
+ entries: Entries;
+};
+
+const filterOutliersViaIQR = (data: Entries): Entries => {
let q1;
let q3;
@@ -18,22 +25,17 @@ const filterOutliersViaIQR = (data) => {
const maxValue = q3 + iqr * 1.5;
const minValue = q1 - iqr * 1.5;
- return _.filter(values, (x) => x >= minValue && x <= maxValue);
+ return values.filter((x) => x >= minValue && x <= maxValue);
};
-const mean = (arr) => _.reduce(arr, (a, b) => a + b, 0) / arr.length;
+const mean = (arr: Entries): number => arr.reduce((a, b) => a + b, 0) / arr.length;
-const std = (arr) => {
+const std = (arr: Entries): number => {
const avg = mean(arr);
- return Math.sqrt(
- _.reduce(
- _.map(arr, (i) => (i - avg) ** 2),
- (a, b) => a + b,
- ) / arr.length,
- );
+ return Math.sqrt(arr.map((i) => (i - avg) ** 2).reduce((a, b) => a + b) / arr.length);
};
-const getStats = (entries) => {
+const getStats = (entries: Entries): Stats => {
const cleanedEntries = filterOutliersViaIQR(entries);
const meanDuration = mean(cleanedEntries);
const stdevDuration = std(cleanedEntries);
@@ -46,5 +48,4 @@ const getStats = (entries) => {
};
};
-// eslint-disable-next-line import/prefer-default-export
export default getStats;
diff --git a/tests/e2e/measure/writeTestStats.js b/tests/e2e/measure/writeTestStats.js
deleted file mode 100644
index 6de9dcc79db4..000000000000
--- a/tests/e2e/measure/writeTestStats.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import fs from 'fs';
-import config from '../config';
-
-/**
- * Writes the results of `getStats` to the {@link OUTPUT_FILE_CURRENT} file.
- *
- * @param {Object} stats
- * @param {string} stats.name - The name for the test, used in outputs.
- * @param {number} stats.mean - The average time for the test to run.
- * @param {number} stats.stdev - The standard deviation of the test.
- * @param {number} stats.entries - The data points
- * @param {number} stats.runs - The number of times the test was run.
- * @param {string} [path] - The path to write to. Defaults to {@link OUTPUT_FILE_CURRENT}.
- */
-export default (stats, path = config.OUTPUT_FILE_CURRENT) => {
- if (!stats.name || stats.mean == null || stats.stdev == null || !stats.entries || !stats.runs) {
- throw new Error(`Invalid stats object:\n${JSON.stringify(stats, null, 2)}\n\n`);
- }
-
- if (!fs.existsSync(path)) {
- fs.writeFileSync(path, '[]');
- }
-
- try {
- const content = JSON.parse(fs.readFileSync(path, 'utf8'));
- const line = `${JSON.stringify(content.concat([stats]))}\n`;
- fs.writeFileSync(path, line);
- } catch (error) {
- console.error(`Error writing ${path}`, error);
- throw error;
- }
-};
diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js
index cbdb22de6c63..77b2033dfece 100644
--- a/tests/e2e/testRunner.js
+++ b/tests/e2e/testRunner.js
@@ -21,7 +21,6 @@ import compare from './compare/compare';
import defaultConfig from './config';
import createServerInstance from './server';
import reversePort from './utils/androidReversePort';
-import execAsync from './utils/execAsync';
import installApp from './utils/installApp';
import killApp from './utils/killApp';
import launchApp from './utils/launchApp';
@@ -39,16 +38,6 @@ const getArg = (argName) => {
return args[argIndex + 1];
};
-let branch = 'main';
-if (args.includes('--branch')) {
- branch = getArg('--branch');
-}
-
-let label = branch;
-if (args.includes('--label')) {
- label = getArg('--label');
-}
-
let config = defaultConfig;
const setConfigPath = (configPathParam) => {
let configPath = configPathParam;
@@ -59,164 +48,40 @@ const setConfigPath = (configPathParam) => {
config = _.extend(defaultConfig, customConfig);
};
-const skipCheckout = args.includes('--skipCheckout');
-
-const skipInstallDeps = args.includes('--skipInstallDeps');
-
-// There are three build modes:
-// 1. full: rebuilds the full native app in (e2e) release mode
-// 2. js-only: only rebuilds the js bundle, and then re-packages
-// the existing native app with the new bundle. If there
-// is no existing native app, it will fallback to mode "full"
-// 3. skip: does not rebuild anything, and just runs the existing native app
-let buildMode = 'full';
-
-// When we are in dev mode we want to apply certain default params and configs
-const isDevMode = args.includes('--development');
-if (isDevMode) {
- setConfigPath('config.local.ts');
- buildMode = 'js-only';
-}
-
-if (args.includes('--buildMode')) {
- buildMode = getArg('--buildMode');
-}
-
if (args.includes('--config')) {
const configPath = getArg('--config');
setConfigPath(configPath);
}
-// Important set app path after correct config file has been set
-let mainAppPath = getArg('--mainAppPath') || config.MAIN_APP_PATH;
-let deltaAppPath = getArg('--deltaAppPath') || config.DELTA_APP_PATH;
-
-// Create some variables after the correct config file has been loaded
-const OUTPUT_FILE = `${config.OUTPUT_DIR}/${label}.json`;
-
-if (isDevMode) {
- Logger.note(`🟠 Running in development mode.`);
+// Important: set app path only after correct config file has been loaded
+const mainAppPath = getArg('--mainAppPath') || config.MAIN_APP_PATH;
+const deltaAppPath = getArg('--deltaAppPath') || config.DELTA_APP_PATH;
+// Check if files exists:
+if (!fs.existsSync(mainAppPath)) {
+ throw new Error(`Main app path does not exist: ${mainAppPath}`);
+}
+if (!fs.existsSync(deltaAppPath)) {
+ throw new Error(`Delta app path does not exist: ${deltaAppPath}`);
}
-if (isDevMode) {
- // On dev mode only delete any existing output file but keep the folder
- if (fs.existsSync(OUTPUT_FILE)) {
- fs.rmSync(OUTPUT_FILE);
- }
-} else {
- // On CI it is important to re-create the output dir, it has a different owner
- // therefore this process cannot write to it
- try {
- fs.rmSync(config.OUTPUT_DIR, {recursive: true, force: true});
+// On CI it is important to re-create the output dir, it has a different owner
+// therefore this process cannot write to it
+try {
+ fs.rmSync(config.OUTPUT_DIR, {recursive: true, force: true});
- fs.mkdirSync(config.OUTPUT_DIR);
- } catch (error) {
- // Do nothing
- console.error(error);
- }
+ fs.mkdirSync(config.OUTPUT_DIR);
+} catch (error) {
+ // Do nothing
+ console.error(error);
}
// START OF TEST CODE
const runTests = async () => {
- // check if using buildMode "js-only" or "none" is possible
- if (buildMode !== 'full') {
- const mainAppExists = fs.existsSync(mainAppPath);
- const deltaAppExists = fs.existsSync(deltaAppPath);
- if (!mainAppExists || !deltaAppExists) {
- Logger.warn(`Build mode "${buildMode}" is not possible, because the app does not exist. Falling back to build mode "full".`);
- Logger.note(`App path: ${mainAppPath}`);
-
- buildMode = 'full';
- }
- }
-
- // Build app
- if (buildMode === 'full') {
- Logger.log(`Test setup - building main branch`);
-
- if (!skipCheckout) {
- // Switch branch
- Logger.log(`Test setup - checkout main`);
- await execAsync(`git checkout main`);
- }
-
- if (!skipInstallDeps) {
- Logger.log(`Test setup - npm install`);
- await execAsync('npm i');
- }
-
- await execAsync('npm run android-build-e2e');
-
- if (branch != null && !skipCheckout) {
- // Switch branch
- Logger.log(`Test setup - checkout branch '${branch}'`);
- await execAsync(`git checkout ${branch}`);
- }
-
- if (!skipInstallDeps) {
- Logger.log(`Test setup - npm install`);
- await execAsync('npm i');
- }
-
- Logger.log(`Test setup '${branch}' - building delta branch`);
- await execAsync('npm run android-build-e2edelta');
- } else if (buildMode === 'js-only') {
- Logger.log(`Test setup '${branch}' - building js bundle`);
-
- if (!skipInstallDeps) {
- Logger.log(`Test setup '${branch}' - npm install`);
- await execAsync('npm i');
- }
-
- // Build a new JS bundle
- if (!skipCheckout) {
- // Switch branch
- Logger.log(`Test setup - checkout main`);
- await execAsync(`git checkout main`);
- }
-
- if (!skipInstallDeps) {
- Logger.log(`Test setup - npm install`);
- await execAsync('npm i');
- }
-
- const tempDir = `${config.OUTPUT_DIR}/temp`;
- let tempBundlePath = `${tempDir}/index.android.bundle`;
- await execAsync(`rm -rf ${tempDir} && mkdir ${tempDir}`);
- await execAsync(`npx react-native bundle --platform android --dev false --entry-file ${config.ENTRY_FILE} --bundle-output ${tempBundlePath}`, {E2E_TESTING: 'true'});
- // Repackage the existing native app with the new bundle
- let tempApkPath = `${tempDir}/app-release.apk`;
- await execAsync(`./scripts/android-repackage-app-bundle-and-sign.sh ${mainAppPath} ${tempBundlePath} ${tempApkPath}`);
- mainAppPath = tempApkPath;
-
- // Build a new JS bundle
- if (!skipCheckout) {
- // Switch branch
- Logger.log(`Test setup - checkout main`);
- await execAsync(`git checkout ${branch}`);
- }
-
- if (!skipInstallDeps) {
- Logger.log(`Test setup - npm install`);
- await execAsync('npm i');
- }
-
- tempBundlePath = `${tempDir}/index.android.bundle`;
- await execAsync(`rm -rf ${tempDir} && mkdir ${tempDir}`);
- await execAsync(`npx react-native bundle --platform android --dev false --entry-file ${config.ENTRY_FILE} --bundle-output ${tempBundlePath}`, {E2E_TESTING: 'true'});
- // Repackage the existing native app with the new bundle
- tempApkPath = `${tempDir}/app-release.apk`;
- await execAsync(`./scripts/android-repackage-app-bundle-and-sign.sh ${deltaAppPath} ${tempBundlePath} ${tempApkPath}`);
- deltaAppPath = tempApkPath;
- }
-
- let progressLog = Logger.progressInfo('Installing apps and reversing port');
-
+ Logger.info('Installing apps and reversing port');
await installApp('android', config.MAIN_APP_PACKAGE, mainAppPath);
await installApp('android', config.DELTA_APP_PACKAGE, deltaAppPath);
await reversePort();
- progressLog.done();
// Start the HTTP server
const server = createServerInstance();
@@ -251,162 +116,107 @@ const runTests = async () => {
results[testResult.branch][testResult.name] = (results[testResult.branch][testResult.name] || []).concat(result);
});
+ // Function to run a single test iteration
+ async function runTestIteration(appPackage, iterationText, launchArgs) {
+ Logger.info(iterationText);
+
+ // Making sure the app is really killed (e.g. if a prior test run crashed)
+ Logger.log('Killing', appPackage);
+ await killApp('android', appPackage);
+
+ Logger.log('Launching', appPackage);
+ await launchApp('android', appPackage, config.ACTIVITY_PATH, launchArgs);
+
+ await withFailTimeout(
+ new Promise((resolve) => {
+ const cleanup = server.addTestDoneListener(() => {
+ Logger.success(iterationText);
+ cleanup();
+ resolve();
+ });
+ }),
+ iterationText,
+ );
+
+ Logger.log('Killing', appPackage);
+ await killApp('android', appPackage);
+ }
+
// Run the tests
- const suites = _.values(config.TESTS_CONFIG);
- for (let suiteIndex = 0; suiteIndex < suites.length; suiteIndex++) {
- const suite = _.values(config.TESTS_CONFIG)[suiteIndex];
+ const tests = _.values(config.TESTS_CONFIG);
+ for (let testIndex = 0; testIndex < tests.length; testIndex++) {
+ const test = _.values(config.TESTS_CONFIG)[testIndex];
// check if we want to skip the test
if (args.includes('--includes')) {
const includes = args[args.indexOf('--includes') + 1];
// assume that "includes" is a regexp
- if (!suite.name.match(includes)) {
+ if (!test.name.match(includes)) {
// eslint-disable-next-line no-continue
continue;
}
}
- const coolDownLogs = Logger.progressInfo(`Cooling down for ${config.BOOT_COOL_DOWN / 1000}s`);
- coolDownLogs.updateText(`Cooling down for ${config.BOOT_COOL_DOWN / 1000}s`);
-
- // Having the cooldown right at the beginning should hopefully lower the chances of heat
+ // Having the cooldown right at the beginning lowers the chances of heat
// throttling from the previous run (which we have no control over and will be a
- // completely different AWS DF customer/app). It also gives the time to cool down between test suites.
+ // completely different AWS DF customer/app). It also gives the time to cool down between tests.
+ Logger.info(`Cooling down for ${config.BOOT_COOL_DOWN / 1000}s`);
await sleep(config.BOOT_COOL_DOWN);
- coolDownLogs.done();
-
- server.setTestConfig(suite);
-
- const warmupLogs = Logger.progressInfo(`Running warmup '${suite.name}'`);
-
- let progressText = `Warmup for suite '${suite.name}' [${suiteIndex + 1}/${suites.length}]\n`;
- warmupLogs.updateText(progressText);
-
- Logger.log('Killing main app');
- await killApp('android', config.MAIN_APP_PACKAGE);
- Logger.log('Launching main app');
- await launchApp('android', config.MAIN_APP_PACKAGE);
-
- await withFailTimeout(
- new Promise((resolve) => {
- const cleanup = server.addTestDoneListener(() => {
- Logger.log('Main warm up ready ✅');
- cleanup();
- resolve();
- });
- }),
- progressText,
- );
-
- Logger.log('Killing main app');
- await killApp('android', config.MAIN_APP_PACKAGE);
- Logger.log('Killing delta app');
- await killApp('android', config.DELTA_APP_PACKAGE);
- Logger.log('Launching delta app');
- await launchApp('android', config.DELTA_APP_PACKAGE);
+ server.setTestConfig(test);
- await withFailTimeout(
- new Promise((resolve) => {
- const cleanup = server.addTestDoneListener(() => {
- Logger.log('Delta warm up ready ✅');
- cleanup();
- resolve();
- });
- }),
- progressText,
- );
+ const warmupText = `Warmup for test '${test.name}' [${testIndex + 1}/${tests.length}]`;
- Logger.log('Killing delta app');
- await killApp('android', config.DELTA_APP_PACKAGE);
+ // Warmup the main app:
+ await runTestIteration(config.MAIN_APP_PACKAGE, `[MAIN] ${warmupText}`);
- warmupLogs.done();
+ // Warmup the delta app:
+ await runTestIteration(config.DELTA_APP_PACKAGE, `[DELTA] ${warmupText}`);
- // We run each test multiple time to average out the results
- const testLog = Logger.progressInfo('');
// For each test case we allow the test to fail three times before we stop the test run:
const errorCountRef = {
errorCount: 0,
allowedExceptions: 3,
};
- for (let i = 0; i < config.RUNS; i++) {
- progressText = `Suite '${suite.name}' [${suiteIndex + 1}/${suites.length}], iteration [${i + 1}/${config.RUNS}]\n`;
- testLog.updateText(progressText);
-
- Logger.log('Killing delta app');
- await killApp('android', config.DELTA_APP_PACKAGE);
-
- Logger.log('Killing main app');
- await killApp('android', config.MAIN_APP_PACKAGE);
-
- Logger.log('Starting main app');
- await launchApp('android', config.MAIN_APP_PACKAGE, config.ACTIVITY_PATH, {
- mockNetwork: true,
- });
+ // We run each test multiple time to average out the results
+ for (let testIteration = 0; testIteration < config.RUNS; testIteration++) {
const onError = (e) => {
- testLog.done();
errorCountRef.errorCount += 1;
- if (i === 0 || errorCountRef.errorCount === errorCountRef.allowedExceptions) {
+ if (testIteration === 0 || errorCountRef.errorCount === errorCountRef.allowedExceptions) {
+ Logger.error("There was an error running the test and we've reached the maximum number of allowed exceptions. Stopping the test run.");
// If the error happened on the first test run, the test is broken
// and we should not continue running it. Or if we have reached the
// maximum number of allowed exceptions, we should stop the test run.
throw e;
}
- console.error(e);
+ Logger.warn(`There was an error running the test. Continuing the test run. Error: ${e}`);
};
- // Wait for a test to finish by waiting on its done call to the http server
- try {
- await withFailTimeout(
- new Promise((resolve) => {
- const cleanup = server.addTestDoneListener(() => {
- Logger.log(`Test iteration ${i + 1} done!`);
- cleanup();
- resolve();
- });
- }),
- progressText,
- );
- } catch (e) {
- onError(e);
- }
-
- Logger.log('Killing main app');
- await killApp('android', config.MAIN_APP_PACKAGE);
-
- Logger.log('Starting delta app');
- await launchApp('android', config.DELTA_APP_PACKAGE, config.ACTIVITY_PATH, {
+ const launchArgs = {
mockNetwork: true,
- });
+ };
- // Wait for a test to finish by waiting on its done call to the http server
+ const iterationText = `Test '${test.name}' [${testIndex + 1}/${tests.length}], iteration [${testIteration + 1}/${config.RUNS}]`;
+ const mainIterationText = `[MAIN] ${iterationText}`;
+ const deltaIterationText = `[DELTA] ${iterationText}`;
try {
- await withFailTimeout(
- new Promise((resolve) => {
- const cleanup = server.addTestDoneListener(() => {
- Logger.log(`Test iteration ${i + 1} done!`);
- cleanup();
- resolve();
- });
- }),
- progressText,
- );
+ // Run the test on the main app:
+ await runTestIteration(config.MAIN_APP_PACKAGE, mainIterationText, launchArgs);
+
+ // Run the test on the delta app:
+ await runTestIteration(config.DELTA_APP_PACKAGE, deltaIterationText, launchArgs);
} catch (e) {
onError(e);
}
}
- testLog.done();
}
// Calculate statistics and write them to our work file
- progressLog = Logger.progressInfo('Calculating statics and writing results');
-
+ Logger.info('Calculating statics and writing results');
compare(results.main, results.delta, `${config.OUTPUT_DIR}/output.md`);
- progressLog.done();
-
await server.stop();
};
diff --git a/tests/e2e/utils/execAsync.js b/tests/e2e/utils/execAsync.ts
similarity index 65%
rename from tests/e2e/utils/execAsync.js
rename to tests/e2e/utils/execAsync.ts
index 9abc41105f7e..9ec8ca892a50 100644
--- a/tests/e2e/utils/execAsync.js
+++ b/tests/e2e/utils/execAsync.ts
@@ -1,22 +1,24 @@
import {exec} from 'child_process';
+import type {ChildProcess} from 'child_process';
import * as Logger from './logger';
+type PromiseWithAbort = Promise & {
+ abort?: () => void;
+};
+
/**
* Executes a command none-blocking by wrapping it in a promise.
* In addition to the promise it returns an abort function.
- * @param {string} command
- * @param {object} env environment variables
- * @returns {Promise}
*/
-export default (command, env = {}) => {
- let childProcess;
- const promise = new Promise((resolve, reject) => {
- const finalEnv = {
+export default (command: string, env: NodeJS.ProcessEnv = {}): PromiseWithAbort => {
+ let childProcess: ChildProcess;
+ const promise: PromiseWithAbort = new Promise((resolve, reject) => {
+ const finalEnv: NodeJS.ProcessEnv = {
...process.env,
...env,
};
- Logger.important(command);
+ Logger.note(command);
childProcess = exec(
command,
@@ -29,11 +31,11 @@ export default (command, env = {}) => {
if (error && error.killed) {
resolve();
} else {
- Logger.error(`failed with error: ${error}`);
+ Logger.error(`failed with error: ${error.message}`);
reject(error);
}
} else {
- Logger.note(stdout);
+ Logger.writeToLogFile(stdout);
resolve(stdout);
}
},
@@ -46,3 +48,5 @@ export default (command, env = {}) => {
return promise;
};
+
+export type {PromiseWithAbort};
diff --git a/tests/e2e/utils/killApp.js b/tests/e2e/utils/killApp.ts
similarity index 61%
rename from tests/e2e/utils/killApp.js
rename to tests/e2e/utils/killApp.ts
index 32cc75d3ef8a..f2d7bb56112f 100644
--- a/tests/e2e/utils/killApp.js
+++ b/tests/e2e/utils/killApp.ts
@@ -1,11 +1,14 @@
import config from '../config';
import execAsync from './execAsync';
+import type {PromiseWithAbort} from './execAsync';
-export default function (platform = 'android', packageName = config.APP_PACKAGE) {
+const killApp = function (platform = 'android', packageName = config.MAIN_APP_PACKAGE): PromiseWithAbort {
if (platform !== 'android') {
throw new Error(`killApp() missing implementation for platform: ${platform}`);
}
// Use adb to kill the app
return execAsync(`adb shell am force-stop ${packageName}`);
-}
+};
+
+export default killApp;
diff --git a/tests/e2e/utils/logger.js b/tests/e2e/utils/logger.js
index 5a7046d8c8b0..6a39b4c10328 100644
--- a/tests/e2e/utils/logger.js
+++ b/tests/e2e/utils/logger.js
@@ -1,28 +1,17 @@
import fs from 'fs';
import path from 'path';
+import _ from 'underscore';
import CONFIG from '../config';
-let isVerbose = true;
-const setLogLevelVerbose = (value) => {
- isVerbose = value;
-};
-
-// On CI systems when using .progressInfo, the current line won't reset but a new line gets added
-// Which can flood the logs. You can increase this rate to mitigate this effect.
-const LOGGER_PROGRESS_REFRESH_RATE = process.env.LOGGER_PROGRESS_REFRESH_RATE || 250;
const COLOR_DIM = '\x1b[2m';
const COLOR_RESET = '\x1b[0m';
const COLOR_YELLOW = '\x1b[33m';
const COLOR_RED = '\x1b[31m';
-const COLOR_BLUE = '\x1b[34m';
const COLOR_GREEN = '\x1b[32m';
-const log = (...args) => {
- if (isVerbose) {
- console.debug(...args);
- }
+const getDateString = () => `[${Date()}] `;
- // Write to log file
+const writeToLogFile = (...args) => {
if (!fs.existsSync(CONFIG.LOG_FILE)) {
// Check that the directory exists
const logDir = path.dirname(CONFIG.LOG_FILE);
@@ -32,71 +21,49 @@ const log = (...args) => {
fs.writeFileSync(CONFIG.LOG_FILE, '');
}
- const time = new Date();
- const timeStr = `${time.getHours()}:${time.getMinutes()}:${time.getSeconds()} ${time.getMilliseconds()}`;
- fs.appendFileSync(CONFIG.LOG_FILE, `[${timeStr}] ${args.join(' ')}\n`);
+ fs.appendFileSync(
+ CONFIG.LOG_FILE,
+ `${_.map(args, (arg) => {
+ if (typeof arg === 'string') {
+ // Remove color codes from arg, because they are not supported in log files
+ // eslint-disable-next-line no-control-regex
+ return arg.replace(/\x1b\[\d+m/g, '');
+ }
+ return arg;
+ })
+ .join(' ')
+ .trim()}\n`,
+ );
};
-const info = (...args) => {
- log('> ', ...args);
+const log = (...args) => {
+ const argsWithTime = [getDateString(), ...args];
+ console.debug(...argsWithTime);
+ writeToLogFile(...argsWithTime);
};
-const important = (...args) => {
- const lines = [`🟦 ${COLOR_BLUE}`, ...args, `${COLOR_RESET}\n`];
- log(...lines);
+const info = (...args) => {
+ log('▶️', ...args);
};
const success = (...args) => {
- const lines = [`🟦 ${COLOR_GREEN}`, ...args, `${COLOR_RESET}\n`];
+ const lines = ['✅', COLOR_GREEN, ...args, COLOR_RESET];
log(...lines);
};
const warn = (...args) => {
- const lines = [`\n${COLOR_YELLOW}⚠️`, ...args, `${COLOR_RESET}\n`];
+ const lines = ['⚠️', COLOR_YELLOW, ...args, COLOR_RESET];
log(...lines);
};
const note = (...args) => {
- const lines = [`${COLOR_DIM}`, ...args, `${COLOR_RESET}\n`];
+ const lines = [COLOR_DIM, ...args, COLOR_RESET];
log(...lines);
};
const error = (...args) => {
- const lines = [`\n🔴 ${COLOR_RED}`, ...args, `${COLOR_RESET}\n`];
+ const lines = ['🔴', COLOR_RED, ...args, COLOR_RESET];
log(...lines);
};
-const progressInfo = (textParam) => {
- let text = textParam || '';
- const getTexts = () => [`🕛 ${text}`, `🕔 ${text}`, `🕗 ${text}`, `🕙 ${text}`];
- log(textParam);
-
- const startTime = Date.now();
- let i = 0;
- const timer = setInterval(() => {
- process.stdout.write(`\r${getTexts()[i++]}`);
- // eslint-disable-next-line no-bitwise
- i &= 3;
- }, Number(LOGGER_PROGRESS_REFRESH_RATE));
-
- const getTimeText = () => {
- const timeInSeconds = Math.round((Date.now() - startTime) / 1000).toFixed(0);
- return `(${COLOR_DIM}took: ${timeInSeconds}s${COLOR_RESET})`;
- };
- return {
- updateText: (newText) => {
- text = newText;
- log(newText);
- },
- done: () => {
- clearInterval(timer);
- success(`\r✅ ${text} ${getTimeText()}\n`);
- },
- error: () => {
- clearInterval(timer);
- error(`\r❌ ${text} ${getTimeText()}\n`);
- },
- };
-};
-
-export {log, info, warn, note, error, success, important, progressInfo, setLogLevelVerbose};
+export {log, info, warn, note, error, success, writeToLogFile};
diff --git a/tests/e2e/utils/withFailTimeout.js b/tests/e2e/utils/withFailTimeout.js
index 3a314cd23562..d7ac50a64e00 100644
--- a/tests/e2e/utils/withFailTimeout.js
+++ b/tests/e2e/utils/withFailTimeout.js
@@ -5,7 +5,7 @@ const TIMEOUT = process.env.INTERACTION_TIMEOUT || CONFIG.INTERACTION_TIMEOUT;
const withFailTimeout = (promise, name) =>
new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
- reject(new Error(`[${name}] Interaction timed out after ${(TIMEOUT / 1000).toFixed(0)}s`));
+ reject(new Error(`"${name}": Interaction timed out after ${(TIMEOUT / 1000).toFixed(0)}s`));
}, Number(TIMEOUT));
promise
diff --git a/tests/perf-test/ReportActionsList.perf-test.js b/tests/perf-test/ReportActionsList.perf-test.js
index 34b127c217e4..c760b81b2373 100644
--- a/tests/perf-test/ReportActionsList.perf-test.js
+++ b/tests/perf-test/ReportActionsList.perf-test.js
@@ -43,6 +43,8 @@ jest.mock('@react-navigation/native', () => {
};
});
+jest.mock('../../src/components/ConfirmedRoute.tsx');
+
beforeAll(() =>
Onyx.init({
keys: ONYXKEYS,
diff --git a/tests/perf-test/ReportScreen.perf-test.js b/tests/perf-test/ReportScreen.perf-test.js
index e86d0bf4fa09..bc127ff8a1f1 100644
--- a/tests/perf-test/ReportScreen.perf-test.js
+++ b/tests/perf-test/ReportScreen.perf-test.js
@@ -31,6 +31,8 @@ jest.mock('react-native-reanimated', () => ({
useAnimatedRef: jest.fn,
}));
+jest.mock('../../src/components/ConfirmedRoute.tsx');
+
jest.mock('../../src/components/withNavigationFocus', () => (Component) => {
function WithNavigationFocus(props) {
return (
diff --git a/tests/perf-test/SelectionList.perf-test.js b/tests/perf-test/SelectionList.perf-test.js
index 9decc4361612..a109f92a1501 100644
--- a/tests/perf-test/SelectionList.perf-test.js
+++ b/tests/perf-test/SelectionList.perf-test.js
@@ -2,6 +2,7 @@ import {fireEvent} from '@testing-library/react-native';
import React, {useState} from 'react';
import {measurePerformance} from 'reassure';
import _ from 'underscore';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import SelectionList from '../../src/components/SelectionList';
import variables from '../../src/styles/variables';
@@ -86,6 +87,7 @@ function SelectionListWrapper(args) {
sections={sections}
onSelectRow={onSelectRow}
initiallyFocusedOptionKey="item-0"
+ ListItem={RadioListItem}
// eslint-disable-next-line react/jsx-props-no-spreading
{...args}
/>
diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js
index 6a57218fab23..6051f04f570e 100644
--- a/tests/ui/UnreadIndicatorsTest.js
+++ b/tests/ui/UnreadIndicatorsTest.js
@@ -30,6 +30,7 @@ jest.setTimeout(30000);
jest.mock('../../src/libs/Notification/LocalNotification');
jest.mock('../../src/components/Icon/Expensicons');
+jest.mock('../../src/components/ConfirmedRoute.tsx');
// Needed for: https://stackoverflow.com/questions/76903168/mocking-libraries-in-jest
jest.mock('react-native/Libraries/LogBox/LogBox', () => ({
@@ -228,6 +229,7 @@ function signInAndGetAppWithUnreadChat() {
lastVisibleActionCreated: reportAction9CreatedDate,
lastMessageText: 'Test',
participantAccountIDs: [USER_B_ACCOUNT_ID],
+ lastActorAccountID: USER_B_ACCOUNT_ID,
type: CONST.REPORT.TYPE.CHAT,
});
const createdReportActionID = NumberUtils.rand64();
@@ -387,6 +389,7 @@ describe('Unread Indicators', () => {
lastReadTime: '',
lastVisibleActionCreated: DateUtils.getDBTime(utcToZonedTime(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, 'UTC').valueOf()),
lastMessageText: 'Comment 1',
+ lastActorAccountID: USER_C_ACCOUNT_ID,
participantAccountIDs: [USER_C_ACCOUNT_ID],
type: CONST.REPORT.TYPE.CHAT,
},
diff --git a/tests/unit/LocaleCompareTest.js b/tests/unit/LocaleCompareTest.js
new file mode 100644
index 000000000000..3c709675f31d
--- /dev/null
+++ b/tests/unit/LocaleCompareTest.js
@@ -0,0 +1,51 @@
+import Onyx from 'react-native-onyx';
+import localeCompare from '@libs/LocaleCompare';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+describe('localeCompare', () => {
+ beforeAll(() => {
+ Onyx.init({
+ keys: {NVP_PREFERRED_LOCALE: ONYXKEYS.NVP_PREFERRED_LOCALE},
+ initialKeyStates: {[ONYXKEYS.NVP_PREFERRED_LOCALE]: CONST.LOCALES.DEFAULT},
+ });
+ return waitForBatchedUpdates();
+ });
+
+ afterEach(() => Onyx.clear());
+
+ it('should return -1 for descending comparison', () => {
+ const result = localeCompare('Da Vinci', 'Tesla');
+
+ expect(result).toBe(-1);
+ });
+
+ it('should return -1 for ascending comparison', () => {
+ const result = localeCompare('Zidane', 'Messi');
+
+ expect(result).toBe(1);
+ });
+
+ it('should return 0 for equal strings', () => {
+ const result = localeCompare('Cat', 'Cat');
+
+ expect(result).toBe(0);
+ });
+
+ it('should discard sensitivity differences', () => {
+ const result = localeCompare('apple', 'Apple');
+
+ expect(result).toBe(0);
+ });
+
+ it('distinguishes spanish diacritic characters', async () => {
+ await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES);
+
+ const input = ['zorro', 'árbol', 'jalapeño', 'jalapeno', 'nino', 'niño'];
+
+ input.sort(localeCompare);
+
+ expect(input).toEqual(['árbol', 'jalapeno', 'jalapeño', 'nino', 'niño', 'zorro']);
+ });
+});
diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts
index 6cf8b37aadab..622881bc7979 100644
--- a/tests/unit/NextStepUtilsTest.ts
+++ b/tests/unit/NextStepUtilsTest.ts
@@ -340,7 +340,7 @@ describe('libs/NextStepUtils', () => {
return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
submitsTo: currentUserAccountID,
- isPreventSelfApprovalEnabled: true,
+ preventSelfApprovalEnabled: true,
}).then(() => {
const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN);
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 00f1307ab59f..7244b7830a29 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -2063,7 +2063,7 @@ describe('OptionsListUtils', () => {
const emptySearch = '';
const wrongSearch = 'bla bla';
- const policyTaxRatesWithDefault = {
+ const taxRatesWithDefault = {
name: 'Tax',
defaultExternalID: 'CODE1',
defaultValue: '0%',
@@ -2170,34 +2170,15 @@ describe('OptionsListUtils', () => {
},
];
- const result = OptionsListUtils.getFilteredOptions({}, {}, [], emptySearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, policyTaxRatesWithDefault);
+ const result = OptionsListUtils.getFilteredOptions({}, {}, [], emptySearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
- expect(result.policyTaxRatesOptions).toStrictEqual(resultList);
+ expect(result.taxRatesOptions).toStrictEqual(resultList);
- const searchResult = OptionsListUtils.getFilteredOptions({}, {}, [], search, [], [], false, false, false, {}, [], false, {}, [], false, false, true, policyTaxRatesWithDefault);
- expect(searchResult.policyTaxRatesOptions).toStrictEqual(searchResultList);
+ const searchResult = OptionsListUtils.getFilteredOptions({}, {}, [], search, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
+ expect(searchResult.taxRatesOptions).toStrictEqual(searchResultList);
- const wrongSearchResult = OptionsListUtils.getFilteredOptions(
- {},
- {},
- [],
- wrongSearch,
- [],
- [],
- false,
- false,
- false,
- {},
- [],
- false,
- {},
- [],
- false,
- false,
- true,
- policyTaxRatesWithDefault,
- );
- expect(wrongSearchResult.policyTaxRatesOptions).toStrictEqual(wrongSearchResultList);
+ const wrongSearchResult = OptionsListUtils.getFilteredOptions({}, {}, [], wrongSearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
+ expect(wrongSearchResult.taxRatesOptions).toStrictEqual(wrongSearchResultList);
});
it('formatMemberForList()', () => {
diff --git a/tests/unit/PaymentUtilsTest.js b/tests/unit/PaymentUtilsTest.js
deleted file mode 100644
index 209e062b764e..000000000000
--- a/tests/unit/PaymentUtilsTest.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import CONST from '../../src/CONST';
-import * as paymentUtils from '../../src/libs/PaymentUtils';
-
-describe('PaymentUtils', () => {
- it('Test rounding wallet transfer instant fee', () => {
- expect(paymentUtils.calculateWalletTransferBalanceFee(2100, CONST.WALLET.TRANSFER_METHOD_TYPE.INSTANT)).toBe(32);
- });
-});
diff --git a/tests/unit/PaymentUtilsTest.ts b/tests/unit/PaymentUtilsTest.ts
new file mode 100644
index 000000000000..66f6929e0089
--- /dev/null
+++ b/tests/unit/PaymentUtilsTest.ts
@@ -0,0 +1,8 @@
+import CONST from '@src/CONST';
+import {calculateWalletTransferBalanceFee} from '@src/libs/PaymentUtils';
+
+describe('PaymentUtils', () => {
+ it('Test rounding wallet transfer instant fee', () => {
+ expect(calculateWalletTransferBalanceFee(2100, CONST.WALLET.TRANSFER_METHOD_TYPE.INSTANT)).toBe(32);
+ });
+});
diff --git a/tests/unit/ReportActionsUtilsTest.js b/tests/unit/ReportActionsUtilsTest.ts
similarity index 56%
rename from tests/unit/ReportActionsUtilsTest.js
rename to tests/unit/ReportActionsUtilsTest.ts
index 19a89d1c892c..f28e2f2c3f14 100644
--- a/tests/unit/ReportActionsUtilsTest.js
+++ b/tests/unit/ReportActionsUtilsTest.ts
@@ -2,6 +2,7 @@ import Onyx from 'react-native-onyx';
import CONST from '../../src/CONST';
import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils';
import ONYXKEYS from '../../src/ONYXKEYS';
+import type {Report, ReportAction} from '../../src/types/onyx';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
@@ -10,7 +11,6 @@ describe('ReportActionsUtils', () => {
beforeAll(() =>
Onyx.init({
keys: ONYXKEYS,
- registerStorageEventListener: () => {},
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
}),
);
@@ -36,11 +36,19 @@ describe('ReportActionsUtils', () => {
created: '2022-11-09 22:27:01.825',
reportActionID: '8401445780099176',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2022-11-09 22:27:01.600',
reportActionID: '6401435781022176',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
// These reportActions were created in the same millisecond so should appear ordered by reportActionID
@@ -48,16 +56,28 @@ describe('ReportActionsUtils', () => {
created: '2022-11-09 22:26:48.789',
reportActionID: '2962390724708756',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2022-11-09 22:26:48.789',
reportActionID: '1609646094152486',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2022-11-09 22:26:48.789',
reportActionID: '1661970171066218',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
],
[
@@ -65,26 +85,46 @@ describe('ReportActionsUtils', () => {
created: '2022-11-09 22:26:48.789',
reportActionID: '1609646094152486',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2022-11-09 22:26:48.789',
reportActionID: '1661970171066218',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2022-11-09 22:26:48.789',
reportActionID: '2962390724708756',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2022-11-09 22:27:01.600',
reportActionID: '6401435781022176',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2022-11-09 22:27:01.825',
reportActionID: '8401445780099176',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
],
],
@@ -95,16 +135,28 @@ describe('ReportActionsUtils', () => {
created: '2023-01-10 22:25:47.132',
reportActionID: '3',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2023-01-10 22:25:47.132',
reportActionID: '2',
actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2023-01-10 22:25:47.132',
reportActionID: '1',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
],
[
@@ -113,16 +165,28 @@ describe('ReportActionsUtils', () => {
created: '2023-01-10 22:25:47.132',
reportActionID: '2',
actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2023-01-10 22:25:47.132',
reportActionID: '1',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
{
created: '2023-01-10 22:25:47.132',
reportActionID: '3',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
},
],
],
@@ -141,48 +205,100 @@ describe('ReportActionsUtils', () => {
describe('getSortedReportActionsForDisplay', () => {
it('should filter out non-whitelisted actions', () => {
- const input = [
+ const input: ReportAction[] = [
{
created: '2022-11-13 22:27:01.825',
reportActionID: '8401445780099176',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'updated the Approval Mode from "Submit and Approve" to "Submit and Close"'}],
+ 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,
- message: [{html: 'Waiting for the bank account'}],
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ 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,
- message: [{html: 'I have changed the task'}],
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [{html: 'I have changed the task', type: 'Action type', text: 'Action text'}],
},
];
@@ -193,48 +309,100 @@ describe('ReportActionsUtils', () => {
describe('getSortedReportActionsForDisplay with marked the first reportAction', () => {
it('should filter out non-whitelisted actions', () => {
- const input = [
+ const input: ReportAction[] = [
{
created: '2022-11-13 22:27:01.825',
reportActionID: '8401445780099176',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'updated the Approval Mode from "Submit and Approve" to "Submit and Close"'}],
+ 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,
- message: [{html: 'Waiting for the bank account'}],
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ 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,
- message: [{html: 'I have changed the task'}],
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [{html: 'I have changed the task', type: 'Action type', text: 'Action text'}],
},
];
@@ -251,36 +419,89 @@ describe('ReportActionsUtils', () => {
});
it('should filter out closed actions', () => {
- const input = [
+ const input: ReportAction[] = [
{
created: '2022-11-13 22:27:01.825',
reportActionID: '8401445780099176',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'Hello world'}],
+ 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,
- message: [{html: 'Hello world'}],
+ 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: '1661970171066218',
actionName: CONST.REPORT.ACTIONS.TYPE.CLOSED,
- message: [{html: 'Hello world'}],
+ originalMessage: {
+ policyName: 'default', // change to const
+ reason: 'default', // change to const
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
},
];
const result = ReportActionsUtils.getSortedReportActionsForDisplay(input);
@@ -289,25 +510,43 @@ describe('ReportActionsUtils', () => {
});
it('should filter out deleted, non-pending comments', () => {
- const input = [
+ const input: ReportAction[] = [
{
created: '2022-11-13 22:27:01.825',
reportActionID: '8401445780099176',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
- message: [{html: 'Hello world'}],
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [
+ {
+ html: 'Hello world',
+ type: 'Action type',
+ text: 'Action text',
+ },
+ ],
},
{
created: '2022-11-12 22:27:01.825',
reportActionID: '8401445780099175',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
- message: [{html: ''}],
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [{html: '', type: 'Action type', text: 'Action text'}],
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
},
{
created: '2022-11-11 22:27:01.825',
reportActionID: '8401445780099174',
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
- message: [{html: ''}],
+ originalMessage: {
+ html: 'Hello world',
+ whisperedTo: [],
+ },
+ message: [{html: '', type: 'Action type', text: 'Action text'}],
},
];
const result = ReportActionsUtils.getSortedReportActionsForDisplay(input);
@@ -318,42 +557,35 @@ describe('ReportActionsUtils', () => {
describe('getLastVisibleAction', () => {
it('should return the last visible action for a report', () => {
- const report = {
- ...LHNTestUtils.getFakeReport(['email1@test.com', 'email2@test.com'], 3, true),
- reportID: 1,
+ const report: Report = {
+ ...LHNTestUtils.getFakeReport([8401445480599174, 9401445480599174], 3, true),
+ reportID: '1',
};
- const action = {
- ...LHNTestUtils.getFakeReportAction('email1@test.com', 3, true),
+ const action: ReportAction = {
+ ...LHNTestUtils.getFakeReportAction('email1@test.com', 3),
created: '2023-08-01 16:00:00',
reportActionID: 'action1',
actionName: 'ADDCOMMENT',
originalMessage: {
- policyName: 'Vikings Policy',
- reason: 'policyDeleted',
- },
- message: {
- policyName: 'Vikings Policy',
- reason: 'policyDeleted',
+ html: 'Hello world',
+ whisperedTo: [],
},
};
- const action2 = {
- ...LHNTestUtils.getFakeReportAction('email2@test.com', 3, true),
+ const action2: ReportAction = {
+ ...LHNTestUtils.getFakeReportAction('email2@test.com', 3),
created: '2023-08-01 18:00:00',
reportActionID: 'action2',
actionName: 'ADDCOMMENT',
originalMessage: {
- policyName: 'Vikings Policy',
- reason: 'policyDeleted',
- },
- message: {
- policyName: 'Vikings Policy',
- reason: 'policyDeleted',
+ html: 'Hello world',
+ whisperedTo: [],
},
};
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
.then(() =>
+ // @ts-expect-error Preset necessary values
Onyx.multiSet({
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`]: {[action.reportActionID]: action, [action2.reportActionID]: action2},
@@ -361,7 +593,7 @@ describe('ReportActionsUtils', () => {
)
.then(
() =>
- new Promise((resolve) => {
+ new Promise((resolve) => {
const connectionID = Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
waitForCollectionCallback: true,
diff --git a/tests/unit/ViolationUtilsTest.js b/tests/unit/ViolationUtilsTest.js
index 4f03fe0a42fa..ff86b5fc6753 100644
--- a/tests/unit/ViolationUtilsTest.js
+++ b/tests/unit/ViolationUtilsTest.js
@@ -6,25 +6,21 @@ import ONYXKEYS from '@src/ONYXKEYS';
const categoryOutOfPolicyViolation = {
name: 'categoryOutOfPolicy',
type: 'violation',
- userMessage: '',
};
const missingCategoryViolation = {
name: 'missingCategory',
type: 'violation',
- userMessage: '',
};
const tagOutOfPolicyViolation = {
name: 'tagOutOfPolicy',
type: 'violation',
- userMessage: '',
};
const missingTagViolation = {
name: 'missingTag',
type: 'violation',
- userMessage: '',
};
describe('getViolationsOnyxData', () => {
@@ -56,8 +52,8 @@ describe('getViolationsOnyxData', () => {
it('should handle multiple violations', () => {
transactionViolations = [
- {name: 'duplicatedTransaction', type: 'violation', userMessage: ''},
- {name: 'receiptRequired', type: 'violation', userMessage: ''},
+ {name: 'duplicatedTransaction', type: 'violation'},
+ {name: 'receiptRequired', type: 'violation'},
];
const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories);
expect(result.value).toEqual(expect.arrayContaining(transactionViolations));
@@ -90,8 +86,8 @@ describe('getViolationsOnyxData', () => {
it('should add categoryOutOfPolicy violation to existing violations if they exist', () => {
transaction.category = 'Bananas';
transactionViolations = [
- {name: 'duplicatedTransaction', type: 'violation', userMessage: ''},
- {name: 'receiptRequired', type: 'violation', userMessage: ''},
+ {name: 'duplicatedTransaction', type: 'violation'},
+ {name: 'receiptRequired', type: 'violation'},
];
const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories);
@@ -102,8 +98,8 @@ describe('getViolationsOnyxData', () => {
it('should add missingCategory violation to existing violations if they exist', () => {
transaction.category = undefined;
transactionViolations = [
- {name: 'duplicatedTransaction', type: 'violation', userMessage: ''},
- {name: 'receiptRequired', type: 'violation', userMessage: ''},
+ {name: 'duplicatedTransaction', type: 'violation'},
+ {name: 'receiptRequired', type: 'violation'},
];
const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories);
@@ -157,7 +153,7 @@ describe('getViolationsOnyxData', () => {
const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories);
- expect(result.value).toEqual(expect.arrayContaining([{...missingTagViolation, data: {tagName: 'Meals'}}]));
+ expect(result.value).toEqual(expect.arrayContaining([{...missingTagViolation}]));
});
it('should add a tagOutOfPolicy violation when policy requires tags and tag is not in the policy', () => {
@@ -165,31 +161,31 @@ describe('getViolationsOnyxData', () => {
const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories);
- expect(result.value).toEqual(expect.arrayContaining([tagOutOfPolicyViolation]));
+ expect(result.value).toEqual([]);
});
it('should add tagOutOfPolicy violation to existing violations if transaction has tag that is not in the policy', () => {
transaction.tag = 'Bananas';
transactionViolations = [
- {name: 'duplicatedTransaction', type: 'violation', userMessage: ''},
- {name: 'receiptRequired', type: 'violation', userMessage: ''},
+ {name: 'duplicatedTransaction', type: 'violation'},
+ {name: 'receiptRequired', type: 'violation'},
];
const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories);
- expect(result.value).toEqual(expect.arrayContaining([{...tagOutOfPolicyViolation, data: {tagName: 'Meals'}}, ...transactionViolations]));
+ expect(result.value).toEqual(expect.arrayContaining([{...tagOutOfPolicyViolation}, ...transactionViolations]));
});
it('should add missingTag violation to existing violations if transaction does not have a tag', () => {
transaction.tag = undefined;
transactionViolations = [
- {name: 'duplicatedTransaction', type: 'violation', userMessage: ''},
- {name: 'receiptRequired', type: 'violation', userMessage: ''},
+ {name: 'duplicatedTransaction', type: 'violation'},
+ {name: 'receiptRequired', type: 'violation'},
];
const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories);
- expect(result.value).toEqual(expect.arrayContaining([{...missingTagViolation, data: {tagName: 'Meals'}}, ...transactionViolations]));
+ expect(result.value).toEqual(expect.arrayContaining([{...missingTagViolation}, ...transactionViolations]));
});
});
diff --git a/tests/unit/isStagingDeployLockedTest.js b/tests/unit/isStagingDeployLockedTest.ts
similarity index 100%
rename from tests/unit/isStagingDeployLockedTest.js
rename to tests/unit/isStagingDeployLockedTest.ts
diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js
index 3e40063dd040..d44a63d51821 100644
--- a/tests/utils/LHNTestUtils.js
+++ b/tests/utils/LHNTestUtils.js
@@ -259,7 +259,7 @@ function getFakePolicy(id = 1, name = 'Workspace-Test-001') {
enabled: true,
},
autoReportingOffset: 1,
- isPreventSelfApprovalEnabled: true,
+ preventSelfApprovalEnabled: true,
submitsTo: 123456,
defaultBillable: false,
disabledFields: {defaultBillable: true, reimbursable: false},
diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts
index 7ecf152122d3..ba5108d49481 100644
--- a/tests/utils/collections/policies.ts
+++ b/tests/utils/collections/policies.ts
@@ -14,7 +14,7 @@ export default function createRandomPolicy(index: number): Policy {
enabled: randBoolean(),
},
autoReportingOffset: 1,
- isPreventSelfApprovalEnabled: randBoolean(),
+ preventSelfApprovalEnabled: randBoolean(),
submitsTo: index,
outputCurrency: randCurrencyCode(),
role: rand(Object.values(CONST.POLICY.ROLE)),