diff --git a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts
index ecf242f00cc2..08519c40413b 100644
--- a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts
+++ b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts
@@ -21,7 +21,27 @@ async function run() {
status: 'completed',
event: isProductionDeploy ? 'release' : 'push',
})
- ).data.workflow_runs;
+ ).data.workflow_runs
+ // Note: we filter out cancelled runs instead of looking only for success runs
+ // because if a build fails on even one platform, then it will have the status 'failure'
+ .filter((workflowRun) => workflowRun.conclusion !== 'cancelled');
+
+ // Find the most recent deploy workflow for which at least one of the build jobs finished successfully.
+ let lastSuccessfulDeploy = completedDeploys.shift();
+ while (
+ lastSuccessfulDeploy &&
+ !(
+ await GithubUtils.octokit.actions.listJobsForWorkflowRun({
+ owner: github.context.repo.owner,
+ repo: github.context.repo.repo,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ run_id: lastSuccessfulDeploy.id,
+ filter: 'latest',
+ })
+ ).data.jobs.some((job) => job.name.startsWith('Build and deploy') && job.conclusion === 'success')
+ ) {
+ lastSuccessfulDeploy = completedDeploys.shift();
+ }
const priorTag = completedDeploys[0].head_branch;
console.log(`Looking for PRs deployed to ${deployEnv} between ${priorTag} and ${inputTag}`);
diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js
index 6b956f17be25..cfe512076ecd 100644
--- a/.github/actions/javascript/getDeployPullRequestList/index.js
+++ b/.github/actions/javascript/getDeployPullRequestList/index.js
@@ -11515,7 +11515,22 @@ async function run() {
workflow_id: 'platformDeploy.yml',
status: 'completed',
event: isProductionDeploy ? 'release' : 'push',
- })).data.workflow_runs;
+ })).data.workflow_runs
+ // Note: we filter out cancelled runs instead of looking only for success runs
+ // because if a build fails on even one platform, then it will have the status 'failure'
+ .filter((workflowRun) => workflowRun.conclusion !== 'cancelled');
+ // Find the most recent deploy workflow for which at least one of the build jobs finished successfully.
+ let lastSuccessfulDeploy = completedDeploys.shift();
+ while (lastSuccessfulDeploy &&
+ !(await GithubUtils_1.default.octokit.actions.listJobsForWorkflowRun({
+ owner: github.context.repo.owner,
+ repo: github.context.repo.repo,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ run_id: lastSuccessfulDeploy.id,
+ filter: 'latest',
+ })).data.jobs.some((job) => job.name.startsWith('Build and deploy') && job.conclusion === 'success')) {
+ lastSuccessfulDeploy = completedDeploys.shift();
+ }
const priorTag = completedDeploys[0].head_branch;
console.log(`Looking for PRs deployed to ${deployEnv} between ${priorTag} and ${inputTag}`);
const prList = await GitUtils_1.default.getPullRequestsMergedBetween(priorTag ?? '', inputTag);
diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index 7e7d55ac5d2e..ffce73644263 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -184,6 +184,9 @@ jobs:
- name: Copy e2e code into zip folder
run: cp tests/e2e/dist/index.js zip/testRunner.ts
+
+ - name: Copy profiler binaries into zip folder
+ run: cp -r node_modules/@perf-profiler/android/cpp-profiler/bin zip/bin
- name: Zip everything in the zip directory up
run: zip -qr App.zip ./zip
diff --git a/Gemfile.lock b/Gemfile.lock
index 3780235053ad..64f4d81c9e76 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -10,8 +10,8 @@ GEM
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
- addressable (2.8.6)
- public_suffix (>= 2.0.2, < 6.0)
+ addressable (2.8.7)
+ public_suffix (>= 2.0.2, < 7.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
@@ -20,17 +20,17 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
- aws-partitions (1.944.0)
- aws-sdk-core (3.197.0)
+ aws-partitions (1.948.0)
+ aws-sdk-core (3.199.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
- aws-sdk-kms (1.85.0)
- aws-sdk-core (~> 3, >= 3.197.0)
+ aws-sdk-kms (1.87.0)
+ aws-sdk-core (~> 3, >= 3.199.0)
aws-sigv4 (~> 1.1)
- aws-sdk-s3 (1.152.3)
- aws-sdk-core (~> 3, >= 3.197.0)
+ aws-sdk-s3 (1.154.0)
+ aws-sdk-core (~> 3, >= 3.199.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
@@ -119,7 +119,7 @@ GEM
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.3.1)
- fastlane (2.221.0)
+ fastlane (2.221.1)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index ea7e6bdfce45..4d15eb49cf2a 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -107,8 +107,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009000203
- versionName "9.0.2-3"
+ versionCode 1009000302
+ versionName "9.0.3-2"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/assets/images/computer.svg b/assets/images/computer.svg
new file mode 100644
index 000000000000..9c2628245eb1
--- /dev/null
+++ b/assets/images/computer.svg
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets/images/integrationicons/sage-intacct-icon-square.svg b/assets/images/integrationicons/sage-intacct-icon-square.svg
new file mode 100644
index 000000000000..33d86259a2d1
--- /dev/null
+++ b/assets/images/integrationicons/sage-intacct-icon-square.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/articles/new-expensify/expensify-card/Enable-Expensify-Card-notifications.md b/docs/articles/new-expensify/expensify-card/Enable-Expensify-Card-notifications.md
new file mode 100644
index 000000000000..4bb56b1cc54c
--- /dev/null
+++ b/docs/articles/new-expensify/expensify-card/Enable-Expensify-Card-notifications.md
@@ -0,0 +1,57 @@
+---
+title: Enable Expensify Card notifications
+description: Allow notifications from Expensify
+---
+
+
+The Expensify mobile app sends you real-time notifications for spending activity on your Expensify Visa® Commercial Card, including
+- Purchase notifications, including declined payments
+- Fraudulent activity alerts
+- Requests for purchases that require a SmartScanned receipt
+
+There are two steps to enable Expensify Card notifications. You’ll first enable alerts on your workspace, then you’ll enable notifications on your device.
+
+# Step 1: Enable alerts on your workspace
+
+{% include selector.html values="desktop, mobile" %}
+
+{% include option.html value="desktop" %}
+1. From your Expensify Chat inbox, click the dropdown on the logo or avatar that is in the top left corner.
+2. Select the workspace you want to update the notification settings for.
+3. Click the workspace chat in your inbox (it will be the chat that has your workspace’s name as the chat title).
+4. Click the header at the top of the chat.
+5. Click **Settings**.
+6. Click **Notify me about new messages** and select **Immediately**.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. From your Expensify Chat inbox, tap the dropdown on the logo or avatar that is in the top left corner.
+2. Select the workspace you want to update the notification settings for.
+3. Tap the workspace chat in your inbox (it will be the chat that has your workspace’s name as the chat title).
+4. Tap the header at the top of the chat.
+5. Tap **Settings**.
+6. Tap **Notify me about new messages** and select **Immediately**.
+{% include end-option.html %}
+
+{% include end-selector.html %}
+
+# Step 2: Enable notifications on your device
+
+**iPhone**
+
+1. Go to your device settings.
+2. Find and tap **New Expensify**.
+3. Tap **Notifications** and enable notifications.
+4. Customize your alerts. Depending on your phone model, you may have extra options to customize the types of notifications you receive.
+
+**Android**
+
+1. Go to your device settings.
+2. Tap **Notifications** and select **Apps notifications**.
+3. Find and tap **New Expensify**.
+4. Enable notifications.
+5. Customize your alerts. Depending on your phone model, you may have extra options to customize the types of notifications you receive.
+
+You will now receive real-time spend notifications to your mobile device.
+
+
diff --git a/docs/assets/images/ExpensifyHelp-Invoice-1.png b/docs/assets/images/ExpensifyHelp-Invoice-1.png
new file mode 100644
index 000000000000..e4a042afef82
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Invoice-1.png differ
diff --git a/docs/assets/images/ExpensifyHelp-QBO-1.png b/docs/assets/images/ExpensifyHelp-QBO-1.png
index 7a8af4c9859e..2aa80e954f1b 100644
Binary files a/docs/assets/images/ExpensifyHelp-QBO-1.png and b/docs/assets/images/ExpensifyHelp-QBO-1.png differ
diff --git a/docs/assets/images/ExpensifyHelp-QBO-2.png b/docs/assets/images/ExpensifyHelp-QBO-2.png
index f7679d00582d..23419b86b6aa 100644
Binary files a/docs/assets/images/ExpensifyHelp-QBO-2.png and b/docs/assets/images/ExpensifyHelp-QBO-2.png differ
diff --git a/docs/assets/images/ExpensifyHelp-QBO-3.png b/docs/assets/images/ExpensifyHelp-QBO-3.png
index 0277c7e21ecb..c612cb760d58 100644
Binary files a/docs/assets/images/ExpensifyHelp-QBO-3.png and b/docs/assets/images/ExpensifyHelp-QBO-3.png differ
diff --git a/docs/assets/images/ExpensifyHelp-QBO-4.png b/docs/assets/images/ExpensifyHelp-QBO-4.png
new file mode 100644
index 000000000000..7fbc99503f2e
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-QBO-4.png differ
diff --git a/docs/assets/images/ExpensifyHelp-QBO-5.png b/docs/assets/images/ExpensifyHelp-QBO-5.png
new file mode 100644
index 000000000000..600a5903c05f
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-QBO-5.png differ
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index b7d3334c902f..af9e798d2343 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -239,35 +239,27 @@ platform :ios do
}
)
- begin
- upload_to_testflight(
- api_key_path: "./ios/ios-fastlane-json-key.json",
- distribute_external: true,
- notify_external_testers: true,
- changelog: "Thank you for beta testing New Expensify, this version includes bug fixes and improvements.",
- groups: ["Beta"],
- demo_account_required: true,
- beta_app_review_info: {
- contact_email: ENV["APPLE_CONTACT_EMAIL"],
- contact_first_name: "Andrew",
- contact_last_name: "Gable",
- contact_phone: ENV["APPLE_CONTACT_PHONE"],
- demo_account_name: ENV["APPLE_DEMO_EMAIL"],
- demo_account_password: ENV["APPLE_DEMO_PASSWORD"],
- notes: "1. In the Expensify app, enter the email 'appletest.expensify@proton.me'. This will trigger a sign-in link to be sent to 'appletest.expensify@proton.me'
- 2. Navigate to https://account.proton.me/login, log into Proton Mail using 'appletest.expensify@proton.me' as email and the password associated with 'appletest.expensify@proton.me', provided above
- 3. Once logged into Proton Mail, navigate to your inbox and locate the email triggered in step 1. The email subject should be 'Your magic sign-in link for Expensify'
- 4. Open the email and copy the 6-digit sign-in code provided within
- 5. Return to the Expensify app and enter the copied 6-digit code in the designated login field"
- }
- )
- rescue Exception => e
- if e.message.include? "Another build is in review"
- UI.important("Another build is already in external beta review. Skipping external beta review submission")
- else
- raise
- end
- end
+ upload_to_testflight(
+ api_key_path: "./ios/ios-fastlane-json-key.json",
+ distribute_external: true,
+ notify_external_testers: true,
+ changelog: "Thank you for beta testing New Expensify, this version includes bug fixes and improvements.",
+ groups: ["Beta"],
+ demo_account_required: true,
+ beta_app_review_info: {
+ contact_email: ENV["APPLE_CONTACT_EMAIL"],
+ contact_first_name: "Andrew",
+ contact_last_name: "Gable",
+ contact_phone: ENV["APPLE_CONTACT_PHONE"],
+ demo_account_name: ENV["APPLE_DEMO_EMAIL"],
+ demo_account_password: ENV["APPLE_DEMO_PASSWORD"],
+ notes: "1. In the Expensify app, enter the email 'appletest.expensify@proton.me'. This will trigger a sign-in link to be sent to 'appletest.expensify@proton.me'
+ 2. Navigate to https://account.proton.me/login, log into Proton Mail using 'appletest.expensify@proton.me' as email and the password associated with 'appletest.expensify@proton.me', provided above
+ 3. Once logged into Proton Mail, navigate to your inbox and locate the email triggered in step 1. The email subject should be 'Your magic sign-in link for Expensify'
+ 4. Open the email and copy the 6-digit sign-in code provided within
+ 5. Return to the Expensify app and enter the copied 6-digit code in the designated login field"
+ }
+ )
upload_symbols_to_crashlytics(
app_id: "1:921154746561:ios:216bd10ccc947659027c40",
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 1653edce72b6..57a582d2a22b 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 9.0.2
+ 9.0.3
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.2.3
+ 9.0.3.2
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 8341a5d96c13..1ba368709984 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 9.0.2
+ 9.0.3
CFBundleSignature
????
CFBundleVersion
- 9.0.2.3
+ 9.0.3.2
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index d9fcba7e3c9d..bb9a344b3314 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 9.0.2
+ 9.0.3
CFBundleVersion
- 9.0.2.3
+ 9.0.3.2
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 3dedaa6b034e..61770b6737df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.2-3",
+ "version": "9.0.3-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.2-3",
+ "version": "9.0.3-2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -102,7 +102,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.53",
+ "react-native-onyx": "2.0.54",
"react-native-pager-view": "6.2.3",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -155,8 +155,8 @@
"@octokit/core": "4.0.4",
"@octokit/plugin-paginate-rest": "3.1.0",
"@octokit/plugin-throttling": "4.1.0",
- "@perf-profiler/profiler": "^0.10.9",
- "@perf-profiler/reporter": "^0.8.1",
+ "@perf-profiler/profiler": "^0.10.10",
+ "@perf-profiler/reporter": "^0.9.0",
"@perf-profiler/types": "^0.8.0",
"@react-native-community/eslint-config": "3.2.0",
"@react-native/babel-preset": "^0.73.21",
@@ -186,6 +186,7 @@
"@types/react-beautiful-dnd": "^13.1.4",
"@types/react-collapse": "^5.0.1",
"@types/react-dom": "^18.2.4",
+ "@types/react-is": "^18.3.0",
"@types/react-test-renderer": "^18.0.0",
"@types/semver": "^7.5.4",
"@types/setimmediate": "^1.0.2",
@@ -235,6 +236,7 @@
"portfinder": "^1.0.28",
"prettier": "^2.8.8",
"pusher-js-mock": "^0.3.3",
+ "react-is": "^18.3.1",
"react-native-clean-project": "^4.0.0-alpha4.0",
"react-test-renderer": "18.2.0",
"reassure": "^0.10.1",
@@ -7877,13 +7879,13 @@
}
},
"node_modules/@perf-profiler/android": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/@perf-profiler/android/-/android-0.12.0.tgz",
- "integrity": "sha512-wLI3D63drtqw3p7aKci+LCtN/ZipLJQvcw8cfmhwxqqRxTraFa8lDz5CNvNsqtCI7Zl0N9VRtnDMOj4e1W1yMQ==",
+ "version": "0.12.1",
+ "resolved": "https://registry.npmjs.org/@perf-profiler/android/-/android-0.12.1.tgz",
+ "integrity": "sha512-t4E2tfj9UdJw5JjhFPLMzrsu3NkKSyiZyeIyd70HX9d3anWqNK47XuQV+qkDPMjWaoU+CTlj1SuNnIOqEkCpSA==",
"dev": true,
"dependencies": {
"@perf-profiler/logger": "^0.3.3",
- "@perf-profiler/profiler": "^0.10.9",
+ "@perf-profiler/profiler": "^0.10.10",
"@perf-profiler/types": "^0.8.0",
"commander": "^12.0.0",
"lodash": "^4.17.21"
@@ -7902,24 +7904,24 @@
}
},
"node_modules/@perf-profiler/ios": {
- "version": "0.3.1",
- "resolved": "https://registry.npmjs.org/@perf-profiler/ios/-/ios-0.3.1.tgz",
- "integrity": "sha512-zRAgxLuCHzo47SYynljf+Aplh2K4DMwJ4dqIU30P8uPHiV5yHjE83eH+sTD6I7jUnUvZ8qAO1dhvp6ATJEpP/Q==",
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@perf-profiler/ios/-/ios-0.3.2.tgz",
+ "integrity": "sha512-2jYyHXFO3xe5BdvU1Ttt+Uw2nAf10B3/mcx4FauJwSdJ+nlOAKIvxmZDvMcipCZZ63uc+HWsYndhziJZVQ7VUw==",
"dev": true,
"dependencies": {
- "@perf-profiler/ios-instruments": "^0.3.1",
+ "@perf-profiler/ios-instruments": "^0.3.2",
"@perf-profiler/logger": "^0.3.3",
"@perf-profiler/types": "^0.8.0"
}
},
"node_modules/@perf-profiler/ios-instruments": {
- "version": "0.3.1",
- "resolved": "https://registry.npmjs.org/@perf-profiler/ios-instruments/-/ios-instruments-0.3.1.tgz",
- "integrity": "sha512-6ZiN9QTmIT8N37SslzjYNk+4+FX0X4IVuM/KiJF/DVgs056CT3MRDF8FFKF17BHsDJBi2a25QkegU8+AQdh+Qg==",
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@perf-profiler/ios-instruments/-/ios-instruments-0.3.2.tgz",
+ "integrity": "sha512-uox5arQscpRuGWfzBrTpsn6eJq0ErdjPlU0FMbN4Cv5akQC11ejKWmgV6y4FR/0YIET9uiiXMtnwyEBgUunYGQ==",
"dev": true,
"dependencies": {
"@perf-profiler/logger": "^0.3.3",
- "@perf-profiler/profiler": "^0.10.9",
+ "@perf-profiler/profiler": "^0.10.10",
"@perf-profiler/types": "^0.8.0",
"commander": "^12.0.0",
"fast-xml-parser": "^4.2.7"
@@ -7960,20 +7962,20 @@
}
},
"node_modules/@perf-profiler/profiler": {
- "version": "0.10.9",
- "resolved": "https://registry.npmjs.org/@perf-profiler/profiler/-/profiler-0.10.9.tgz",
- "integrity": "sha512-jhkFyqsrmkI9gCYmK7+R1e+vjWGw2a2YnqruRAUk71saOkLLvRSWKnT0MiGMqzi0aQj//ojeW9viDJgxQB86zg==",
+ "version": "0.10.10",
+ "resolved": "https://registry.npmjs.org/@perf-profiler/profiler/-/profiler-0.10.10.tgz",
+ "integrity": "sha512-kvVC6VQ7pBdthcWEcLTua+iDj0ZkcmYYL9gXHa9Dl7jYkZI4cOeslJZ1vuGfIcC168JwAVrB8UYhgoSgss/MWQ==",
"dev": true,
"dependencies": {
- "@perf-profiler/android": "^0.12.0",
- "@perf-profiler/ios": "^0.3.1",
+ "@perf-profiler/android": "^0.12.1",
+ "@perf-profiler/ios": "^0.3.2",
"@perf-profiler/types": "^0.8.0"
}
},
"node_modules/@perf-profiler/reporter": {
- "version": "0.8.1",
- "resolved": "https://registry.npmjs.org/@perf-profiler/reporter/-/reporter-0.8.1.tgz",
- "integrity": "sha512-lZp17uMMLAV4nuDO0JbajbPCyOoD4/ugnZVxsOEEueRo8mxB26TS3R7ANtMZYjHrpQbJry0CgfTIPxflBgtq4A==",
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@perf-profiler/reporter/-/reporter-0.9.0.tgz",
+ "integrity": "sha512-wJt6ZRVM/cL+8rv9gFYgl8ZIra0uKdesfcfvsvhmrPXtxgC0O4ZdHF9hJDMtcCiHuHb8ptVq/BmEEW84CnvRIw==",
"dev": true,
"dependencies": {
"@perf-profiler/types": "^0.8.0",
@@ -9963,6 +9965,11 @@
"react": "*"
}
},
+ "node_modules/@react-navigation/core/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
"node_modules/@react-navigation/devtools": {
"version": "6.0.10",
"dev": true,
@@ -18001,6 +18008,15 @@
"@types/react": "*"
}
},
+ "node_modules/@types/react-is": {
+ "version": "18.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.3.0.tgz",
+ "integrity": "sha512-KZJpHUkAdzyKj/kUHJDc6N7KyidftICufJfOFpiG6haL/BDQNQt5i4n1XDUL/nDZAtGLHDSWRYpLzKTAKSvX6w==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/react-native": {
"version": "0.73.0",
"deprecated": "This is a stub types definition. react-native provides its own type definitions, so you do not need this installed.",
@@ -20448,6 +20464,12 @@
"node": ">= 6"
}
},
+ "node_modules/babel-plugin-react-compiler/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true
+ },
"node_modules/babel-plugin-react-compiler/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -27965,6 +27987,11 @@
"react-is": "^16.7.0"
}
},
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
"node_modules/hosted-git-info": {
"version": "4.1.0",
"dev": true,
@@ -36156,10 +36183,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/pretty-format/node_modules/react-is": {
- "version": "18.2.0",
- "license": "MIT"
- },
"node_modules/pretty-hrtime": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
@@ -36238,6 +36261,11 @@
"react-is": "^16.13.1"
}
},
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
"node_modules/propagate": {
"version": "2.0.1",
"license": "MIT",
@@ -36895,8 +36923,9 @@
}
},
"node_modules/react-is": {
- "version": "16.13.1",
- "license": "MIT"
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/react-map-gl": {
"version": "7.1.3",
@@ -37248,9 +37277,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "2.0.53",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.53.tgz",
- "integrity": "sha512-ObNk5MhLOAVkLgE0NCI04CEO3qaP5ZG+NY1Kn3UnxcHlhyLlDQb10EOiDWSLwNR2s4K3kK+ge7Xmo6N0VdMyyA==",
+ "version": "2.0.54",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.54.tgz",
+ "integrity": "sha512-cANbs0KuiwHAIUC0HY7DGNXbFMHH4ZWbTci+qhHhuNNf4aNIP0/ncJ4W8a3VCgFVtfobIFAX5ouT40dEcgBOIQ==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
diff --git a/package.json b/package.json
index 14bc9d32373c..2d9480d1ee20 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.2-3",
+ "version": "9.0.3-2",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -155,7 +155,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.53",
+ "react-native-onyx": "2.0.54",
"react-native-pager-view": "6.2.3",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -208,8 +208,8 @@
"@octokit/core": "4.0.4",
"@octokit/plugin-paginate-rest": "3.1.0",
"@octokit/plugin-throttling": "4.1.0",
- "@perf-profiler/profiler": "^0.10.9",
- "@perf-profiler/reporter": "^0.8.1",
+ "@perf-profiler/profiler": "^0.10.10",
+ "@perf-profiler/reporter": "^0.9.0",
"@perf-profiler/types": "^0.8.0",
"@react-native-community/eslint-config": "3.2.0",
"@react-native/babel-preset": "^0.73.21",
@@ -239,6 +239,7 @@
"@types/react-beautiful-dnd": "^13.1.4",
"@types/react-collapse": "^5.0.1",
"@types/react-dom": "^18.2.4",
+ "@types/react-is": "^18.3.0",
"@types/react-test-renderer": "^18.0.0",
"@types/semver": "^7.5.4",
"@types/setimmediate": "^1.0.2",
@@ -288,6 +289,7 @@
"portfinder": "^1.0.28",
"prettier": "^2.8.8",
"pusher-js-mock": "^0.3.3",
+ "react-is": "^18.3.1",
"react-native-clean-project": "^4.0.0-alpha4.0",
"react-test-renderer": "18.2.0",
"reassure": "^0.10.1",
diff --git a/patches/@perf-profiler+android+0.12.0.patch b/patches/@perf-profiler+android+0.12.0.patch
deleted file mode 100644
index f6ecbce9b481..000000000000
--- a/patches/@perf-profiler+android+0.12.0.patch
+++ /dev/null
@@ -1,54 +0,0 @@
-diff --git a/node_modules/@perf-profiler/android/dist/src/commands.js b/node_modules/@perf-profiler/android/dist/src/commands.js
-old mode 100755
-new mode 100644
-diff --git a/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js b/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js
-index 77b9ee0..59aeed9 100644
---- a/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js
-+++ b/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js
-@@ -134,7 +134,20 @@ class UnixProfiler {
- }
- const subProcessesStats = (0, getCpuStatsByProcess_1.processOutput)(cpu, pid);
- const ram = (0, pollRamUsage_1.processOutput)(ramStr, this.getRAMPageSize());
-- const { frameTimes, interval: atraceInterval } = frameTimeParser.getFrameTimes(atrace, pid);
-+
-+ let output;
-+ try {
-+ output = frameTimeParser.getFrameTimes(atrace, pid);
-+ } catch (e) {
-+ console.error(e);
-+ }
-+
-+ if (!output) {
-+ return;
-+ }
-+
-+ const { frameTimes, interval: atraceInterval } = output;
-+
- if (!initialTime) {
- initialTime = timestamp;
- }
-diff --git a/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts b/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts
-index d6983c1..ccacf09 100644
---- a/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts
-+++ b/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts
-@@ -136,7 +136,19 @@ export abstract class UnixProfiler implements Profiler {
- const subProcessesStats = processOutput(cpu, pid);
-
- const ram = processRamOutput(ramStr, this.getRAMPageSize());
-- const { frameTimes, interval: atraceInterval } = frameTimeParser.getFrameTimes(atrace, pid);
-+
-+ let output;
-+ try {
-+ output = frameTimeParser.getFrameTimes(atrace, pid);
-+ } catch (e) {
-+ console.error(e);
-+ }
-+
-+ if (!output) {
-+ return;
-+ }
-+
-+ const { frameTimes, interval: atraceInterval } = output;
-
- if (!initialTime) {
- initialTime = timestamp;
diff --git a/patches/@perf-profiler+android+0.12.1.patch b/patches/@perf-profiler+android+0.12.1.patch
new file mode 100644
index 000000000000..e6e4a90d6ab4
--- /dev/null
+++ b/patches/@perf-profiler+android+0.12.1.patch
@@ -0,0 +1,26 @@
+diff --git a/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js b/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js
+index 59aeed9..ee1d8a6 100644
+--- a/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js
++++ b/node_modules/@perf-profiler/android/dist/src/commands/platforms/UnixProfiler.js
+@@ -28,7 +28,7 @@ exports.CppProfilerName = `BAMPerfProfiler`;
+ // into the Flipper plugin directory
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+-const binaryFolder = global.Flipper
++const binaryFolder = (global.Flipper || process.env.AWS)
+ ? `${__dirname}/bin`
+ : `${__dirname}/../../..${__dirname.includes("dist") ? "/.." : ""}/cpp-profiler/bin`;
+ class UnixProfiler {
+diff --git a/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts b/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts
+index ccacf09..1eea659 100644
+--- a/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts
++++ b/node_modules/@perf-profiler/android/src/commands/platforms/UnixProfiler.ts
+@@ -26,7 +26,7 @@ export const CppProfilerName = `BAMPerfProfiler`;
+ // into the Flipper plugin directory
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+-const binaryFolder = global.Flipper
++const binaryFolder = (global.Flipper || process.env.AWS)
+ ? `${__dirname}/bin`
+ : `${__dirname}/../../..${__dirname.includes("dist") ? "/.." : ""}/cpp-profiler/bin`;
+
diff --git a/patches/@perf-profiler+reporter+0.8.1.patch b/patches/@perf-profiler+reporter+0.8.1.patch
deleted file mode 100644
index 2c918b4049c2..000000000000
--- a/patches/@perf-profiler+reporter+0.8.1.patch
+++ /dev/null
@@ -1,25 +0,0 @@
-diff --git a/node_modules/@perf-profiler/reporter/dist/src/index.d.ts b/node_modules/@perf-profiler/reporter/dist/src/index.d.ts
-index 2f84d84..14ae688 100644
---- a/node_modules/@perf-profiler/reporter/dist/src/index.d.ts
-+++ b/node_modules/@perf-profiler/reporter/dist/src/index.d.ts
-@@ -4,4 +4,6 @@ export * from "./reporting/Report";
- export * from "./utils/sanitizeProcessName";
- export * from "./utils/round";
- export * from "./reporting/cpu";
-+export * from "./reporting/ram";
-+export * from "./reporting/fps";
- export { canComputeHighCpuUsage } from "./reporting/highCpu";
-diff --git a/node_modules/@perf-profiler/reporter/dist/src/index.js b/node_modules/@perf-profiler/reporter/dist/src/index.js
-index 4b50e3a..780963a 100644
---- a/node_modules/@perf-profiler/reporter/dist/src/index.js
-+++ b/node_modules/@perf-profiler/reporter/dist/src/index.js
-@@ -21,6 +21,8 @@ __exportStar(require("./reporting/Report"), exports);
- __exportStar(require("./utils/sanitizeProcessName"), exports);
- __exportStar(require("./utils/round"), exports);
- __exportStar(require("./reporting/cpu"), exports);
-+__exportStar(require("./reporting/fps"), exports);
-+__exportStar(require("./reporting/ram"), exports);
- var highCpu_1 = require("./reporting/highCpu");
- Object.defineProperty(exports, "canComputeHighCpuUsage", { enumerable: true, get: function () { return highCpu_1.canComputeHighCpuUsage; } });
- //# sourceMappingURL=index.js.map
-\ No newline at end of file
diff --git a/patches/react-native-reanimated+3.8.1+003+fix-strict-mode.patch b/patches/react-native-reanimated+3.8.1+003+fix-strict-mode.patch
index e36d2dd365c0..ccc208062d10 100644
--- a/patches/react-native-reanimated+3.8.1+003+fix-strict-mode.patch
+++ b/patches/react-native-reanimated+3.8.1+003+fix-strict-mode.patch
@@ -1,3 +1,16 @@
+diff --git a/node_modules/react-native-reanimated/Common/cpp/Fabric/ReanimatedCommitMarker.cpp b/node_modules/react-native-reanimated/Common/cpp/Fabric/ReanimatedCommitMarker.cpp
+index 3404e89..b545cb6 100644
+--- a/node_modules/react-native-reanimated/Common/cpp/Fabric/ReanimatedCommitMarker.cpp
++++ b/node_modules/react-native-reanimated/Common/cpp/Fabric/ReanimatedCommitMarker.cpp
+@@ -9,7 +9,7 @@ namespace reanimated {
+ thread_local bool ReanimatedCommitMarker::reanimatedCommitFlag_{false};
+
+ ReanimatedCommitMarker::ReanimatedCommitMarker() {
+- react_native_assert(reanimatedCommitFlag_ != true);
++ // react_native_assert(reanimatedCommitFlag_ != true);
+ reanimatedCommitFlag_ = true;
+ }
+
diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/UpdateProps.js b/node_modules/react-native-reanimated/lib/module/reanimated2/UpdateProps.js
index e69c581..78b7034 100644
--- a/node_modules/react-native-reanimated/lib/module/reanimated2/UpdateProps.js
diff --git a/src/CONST.ts b/src/CONST.ts
index 0297f7bc0d5a..233b35e6ac4b 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -601,6 +601,9 @@ const CONST = {
ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/',
LIST_OF_RESTRICTED_BUSINESSES: 'https://community.expensify.com/discussion/6191/list-of-restricted-businesses',
TRAVEL_TERMS_URL: `${USE_EXPENSIFY_URL}/travelterms`,
+ EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT: 'https://www.expensify.com/tools/integrations/downloadPackage',
+ EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT_FILE_NAME: 'ExpensifyPackageForSageIntacct',
+ HOW_TO_CONNECT_TO_SAGE_INTACCT: 'https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct#how-to-connect-to-sage-intacct',
PRICING: `https://www.expensify.com/pricing`,
// Use Environment.getEnvironmentURL to get the complete URL with port number
@@ -1793,6 +1796,13 @@ const CONST = {
QBO: 'quickbooksOnline',
XERO: 'xero',
NETSUITE: 'netsuite',
+ SAGE_INTACCT: 'intacct',
+ },
+ NAME_USER_FRIENDLY: {
+ netsuite: 'NetSuite',
+ quickbooksOnline: 'Quickbooks Online',
+ xero: 'Xero',
+ intacct: 'Sage Intacct',
},
SYNC_STAGE_NAME: {
STARTING_IMPORT_QBO: 'startingImportQBO',
@@ -1840,6 +1850,12 @@ const CONST = {
NETSUITE_SYNC_UPDATE_DATA: 'netSuiteSyncUpdateConnectionData',
NETSUITE_SYNC_NETSUITE_REIMBURSED_REPORTS: 'netSuiteSyncNetSuiteReimbursedReports',
NETSUITE_SYNC_EXPENSIFY_REIMBURSED_REPORTS: 'netSuiteSyncExpensifyReimbursedReports',
+ SAGE_INTACCT_SYNC_CHECK_CONNECTION: 'intacctCheckConnection',
+ SAGE_INTACCT_SYNC_IMPORT_TITLE: 'intacctImportTitle',
+ SAGE_INTACCT_SYNC_IMPORT_DATA: 'intacctImportData',
+ SAGE_INTACCT_SYNC_IMPORT_EMPLOYEES: 'intacctImportEmployees',
+ SAGE_INTACCT_SYNC_IMPORT_DIMENSIONS: 'intacctImportDimensions',
+ SAGE_INTACCT_SYNC_IMPORT_SYNC_REIMBURSED_REPORTS: 'intacctImportSyncBillPayments',
},
SYNC_STAGE_TIMEOUT_MINUTES: 20,
},
@@ -1916,6 +1932,15 @@ const CONST = {
MONTHLY: 'monthly',
FIXED: 'fixed',
},
+ STEP_NAMES: ['1', '2', '3', '4', '5', '6'],
+ STEP: {
+ ASSIGNEE: 'Assignee',
+ CARD_TYPE: 'CardType',
+ LIMIT_TYPE: 'LimitType',
+ LIMIT: 'Limit',
+ CARD_NAME: 'CardName',
+ CONFIRMATION: 'Confirmation',
+ },
},
AVATAR_ROW_SIZE: {
DEFAULT: 4,
diff --git a/src/Expensify.tsx b/src/Expensify.tsx
index 458f1e3c5d24..bfe4db13d9c4 100644
--- a/src/Expensify.tsx
+++ b/src/Expensify.tsx
@@ -1,7 +1,7 @@
import {Audio} from 'expo-av';
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import type {NativeEventSubscription} from 'react-native';
-import {AppState, Linking} from 'react-native';
+import {AppState, Linking, NativeModules} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import Onyx, {withOnyx} from 'react-native-onyx';
import ConfirmModal from './components/ConfirmModal';
@@ -77,9 +77,12 @@ type ExpensifyOnyxProps = {
type ExpensifyProps = ExpensifyOnyxProps;
-type SplashScreenHiddenContextType = {isSplashHidden?: boolean};
+// HybridApp needs access to SetStateAction in order to properly hide SplashScreen when React Native was booted before.
+type SplashScreenHiddenContextType = {isSplashHidden?: boolean; setIsSplashHidden: React.Dispatch>};
-const SplashScreenHiddenContext = React.createContext({});
+const SplashScreenHiddenContext = React.createContext({
+ setIsSplashHidden: () => {},
+});
function Expensify({
isCheckingPublicRoom = true,
@@ -109,16 +112,6 @@ function Expensify({
const isAuthenticated = useMemo(() => !!(session?.authToken ?? null), [session]);
const autoAuthState = useMemo(() => session?.autoAuthState ?? '', [session]);
- const isAuthenticatedRef = useRef(false);
- isAuthenticatedRef.current = isAuthenticated;
-
- const contextValue = useMemo(
- () => ({
- isSplashHidden,
- }),
- [isSplashHidden],
- );
-
const shouldInit = isNavigationReady && hasAttemptedToOpenPublicRoom;
const shouldHideSplash = shouldInit && !isSplashHidden;
@@ -142,6 +135,14 @@ function Expensify({
Performance.markEnd(CONST.TIMING.SIDEBAR_LOADED);
}, []);
+ const contextValue = useMemo(
+ () => ({
+ isSplashHidden,
+ setIsSplashHidden,
+ }),
+ [isSplashHidden, setIsSplashHidden],
+ );
+
useLayoutEffect(() => {
// Initialize this client as being an active client
ActiveClientManager.init();
@@ -198,8 +199,7 @@ function Expensify({
// Open chat report from a deep link (only mobile native)
Linking.addEventListener('url', (state) => {
- // We need to pass 'isAuthenticated' to avoid loading a non-existing profile page twice
- Report.openReportFromDeepLink(state.url, !isAuthenticatedRef.current);
+ Report.openReportFromDeepLink(state.url);
});
return () => {
@@ -263,8 +263,8 @@ function Expensify({
/>
)}
-
- {shouldHideSplash && }
+ {/* HybridApp has own middleware to hide SplashScreen */}
+ {!NativeModules.HybridAppModule && shouldHideSplash && }
);
}
diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts
index eea357322075..0b4a86c99247 100644
--- a/src/NAVIGATORS.ts
+++ b/src/NAVIGATORS.ts
@@ -10,5 +10,6 @@ export default {
ONBOARDING_MODAL_NAVIGATOR: 'OnboardingModalNavigator',
FEATURE_TRANING_MODAL_NAVIGATOR: 'FeatureTrainingModalNavigator',
WELCOME_VIDEO_MODAL_NAVIGATOR: 'WelcomeVideoModalNavigator',
+ EXPLANATION_MODAL_NAVIGATOR: 'ExplanationModalNavigator',
FULL_SCREEN_NAVIGATOR: 'FullScreenNavigator',
} as const;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 2cb615ae0af8..6f94a23acad8 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -115,6 +115,9 @@ const ONYXKEYS = {
/** This NVP contains information about whether the onboarding flow was completed or not */
NVP_ONBOARDING: 'nvp_onboarding',
+ /** This NVP contains data associated with HybridApp */
+ NVP_TRYNEWDOT: 'nvp_tryNewDot',
+
/** Contains the user preference for the LHN priority mode */
NVP_PRIORITY_MODE: 'nvp_priorityMode',
@@ -154,6 +157,8 @@ const ONYXKEYS = {
/** Whether the user has dismissed the hold educational interstitial */
NVP_DISMISSED_HOLD_USE_EXPLANATION: 'nvp_dismissedHoldUseExplanation',
+ /** Whether the user has seen HybridApp explanation modal */
+ NVP_SEEN_NEW_USER_MODAL: 'nvp_seen_new_user_modal',
/** Store the state of the subscription */
NVP_PRIVATE_SUBSCRIPTION: 'nvp_private_subscription',
@@ -348,6 +353,9 @@ const ONYXKEYS = {
/** Stores info during review duplicates flow */
REVIEW_DUPLICATES: 'reviewDuplicates',
+ /** Stores the information about the state of issuing a new card */
+ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard',
+
/** Collection Keys */
COLLECTION: {
DOWNLOAD: 'download_',
@@ -516,6 +524,10 @@ const ONYXKEYS = {
NEW_CHAT_NAME_FORM_DRAFT: 'newChatNameFormDraft',
SUBSCRIPTION_SIZE_FORM: 'subscriptionSizeForm',
SUBSCRIPTION_SIZE_FORM_DRAFT: 'subscriptionSizeFormDraft',
+ ISSUE_NEW_EXPENSIFY_CARD_FORM: 'issueNewExpensifyCardForm',
+ ISSUE_NEW_EXPENSIFY_CARD_FORM_DRAFT: 'issueNewExpensifyCardFormDraft',
+ SAGE_INTACCT_CREDENTIALS_FORM: 'sageIntacctCredentialsForm',
+ SAGE_INTACCT_CREDENTIALS_FORM_DRAFT: 'sageIntacctCredentialsFormDraft',
},
} as const;
@@ -576,6 +588,8 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm;
[ONYXKEYS.FORMS.NEW_CHAT_NAME_FORM]: FormTypes.NewChatNameForm;
[ONYXKEYS.FORMS.SUBSCRIPTION_SIZE_FORM]: FormTypes.SubscriptionSizeForm;
+ [ONYXKEYS.FORMS.ISSUE_NEW_EXPENSIFY_CARD_FORM]: FormTypes.IssueNewExpensifyCardForm;
+ [ONYXKEYS.FORMS.SAGE_INTACCT_CREDENTIALS_FORM]: FormTypes.SageIntactCredentialsForm;
};
type OnyxFormDraftValuesMapping = {
@@ -631,6 +645,9 @@ type OnyxValuesMapping = {
// NVP_ONBOARDING is an array for old users.
[ONYXKEYS.NVP_ONBOARDING]: Onboarding | [];
+ // ONYXKEYS.NVP_TRYNEWDOT is HybridApp onboarding data
+ [ONYXKEYS.NVP_TRYNEWDOT]: OnyxTypes.TryNewDot;
+
[ONYXKEYS.ACTIVE_CLIENTS]: string[];
[ONYXKEYS.DEVICE_ID]: string;
[ONYXKEYS.IS_SIDEBAR_LOADED]: boolean;
@@ -673,6 +690,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[];
[ONYXKEYS.NVP_INTRO_SELECTED]: OnyxTypes.IntroSelected;
[ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES]: OnyxTypes.LastSelectedDistanceRates;
+ [ONYXKEYS.NVP_SEEN_NEW_USER_MODAL]: boolean;
[ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED]: boolean;
[ONYXKEYS.PLAID_DATA]: OnyxTypes.PlaidData;
[ONYXKEYS.IS_PLAID_DISABLED]: boolean;
@@ -734,6 +752,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction;
[ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings;
[ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates;
+ [ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard;
[ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string;
[ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: string;
[ONYXKEYS.NVP_BILLING_FUND_ID]: number;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 5c8cfdcc8a68..33eb78dc300d 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -783,6 +783,17 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/reportFields',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields` as const,
},
+ // TODO: uncomment after development is done
+ // WORKSPACE_EXPENSIFY_CARD: {
+ // route: 'settings/workspaces/:policyID/expensify-card',
+ // getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const,
+ // },
+ // WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: {
+ // route: 'settings/workspaces/:policyID/expensify-card/issues-new',
+ // getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/issue-new` as const,
+ // },
+ // TODO: remove after development is done - this one is for testing purposes
+ WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: 'settings/workspaces/expensify-card/issue-new',
WORKSPACE_DISTANCE_RATES: {
route: 'settings/workspaces/:policyID/distance-rates',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const,
@@ -825,6 +836,7 @@ const ROUTES = {
ONBOARDING_WORK: 'onboarding/work',
ONBOARDING_PURPOSE: 'onboarding/purpose',
WELCOME_VIDEO_ROOT: 'onboarding/welcome-video',
+ EXPLANATION_MODAL_ROOT: 'onboarding/explanation',
TRANSACTION_RECEIPT: {
route: 'r/:reportID/transaction/:transactionID/receipt',
@@ -928,6 +940,18 @@ const ROUTES = {
route: 'restricted-action/workspace/:policyID',
getRoute: (policyID: string) => `restricted-action/workspace/${policyID}` as const,
},
+ POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES: {
+ route: 'settings/workspaces/:policyID/accounting/sage-intacct/prerequisites',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/prerequisites` as const,
+ },
+ POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS: {
+ route: 'settings/workspaces/:policyID/accounting/sage-intacct/enter-credentials',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/enter-credentials` as const,
+ },
+ POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS: {
+ route: 'settings/workspaces/:policyID/accounting/sage-intacct/existing-connections',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/existing-connections` as const,
+ },
} as const;
/**
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 5c5fc6c31092..1807c9bb0bab 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -273,6 +273,9 @@ const SCREENS = {
XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector',
XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Policy_Accounting_Xero_Export_Bank_Account_Select',
NETSUITE_SUBSIDIARY_SELECTOR: 'Policy_Accounting_Net_Suite_Subsidiary_Selector',
+ SAGE_INTACCT_PREREQUISITES: 'Policy_Accounting_Sage_Intacct_Prerequisites',
+ ENTER_SAGE_INTACCT_CREDENTIALS: 'Policy_Enter_Sage_Intacct_Credentials',
+ EXISTING_SAGE_INTACCT_CONNECTIONS: 'Policy_Existing_Sage_Intacct_Connections',
},
INITIAL: 'Workspace_Initial',
PROFILE: 'Workspace_Profile',
@@ -281,6 +284,8 @@ const SCREENS = {
RATE_AND_UNIT: 'Workspace_RateAndUnit',
RATE_AND_UNIT_RATE: 'Workspace_RateAndUnit_Rate',
RATE_AND_UNIT_UNIT: 'Workspace_RateAndUnit_Unit',
+ EXPENSIFY_CARD: 'Workspace_ExpensifyCard',
+ EXPENSIFY_CARD_ISSUE_NEW: 'Workspace_ExpensifyCard_New',
BILLS: 'Workspace_Bills',
INVOICES: 'Workspace_Invoices',
TRAVEL: 'Workspace_Travel',
@@ -362,6 +367,10 @@ const SCREENS = {
ROOT: 'Welcome_Video_Root',
},
+ EXPLANATION_MODAL: {
+ ROOT: 'Explanation_Modal_Root',
+ },
+
I_KNOW_A_TEACHER: 'I_Know_A_Teacher',
INTRO_SCHOOL_PRINCIPAL: 'Intro_School_Principal',
I_AM_A_TEACHER: 'I_Am_A_Teacher',
diff --git a/src/components/AccountingConnectionConfirmationModal.tsx b/src/components/AccountingConnectionConfirmationModal.tsx
new file mode 100644
index 000000000000..c472f215b6df
--- /dev/null
+++ b/src/components/AccountingConnectionConfirmationModal.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import useLocalize from '@hooks/useLocalize';
+import type {ConnectionName} from '@src/types/onyx/Policy';
+import ConfirmModal from './ConfirmModal';
+
+type AccountingConnectionConfirmationModalProps = {
+ integrationToConnect: ConnectionName;
+ onConfirm: () => void;
+ onCancel: () => void;
+};
+
+function AccountingConnectionConfirmationModal({integrationToConnect, onCancel, onConfirm}: AccountingConnectionConfirmationModalProps) {
+ const {translate} = useLocalize();
+
+ return (
+
+ );
+}
+
+AccountingConnectionConfirmationModal.displayName = 'AccountingConnectionConfirmationModal';
+export default AccountingConnectionConfirmationModal;
diff --git a/src/components/ConnectToNetSuiteButton/index.tsx b/src/components/ConnectToNetSuiteButton/index.tsx
new file mode 100644
index 000000000000..fc948503a127
--- /dev/null
+++ b/src/components/ConnectToNetSuiteButton/index.tsx
@@ -0,0 +1,54 @@
+import React, {useState} from 'react';
+import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal';
+import Button from '@components/Button';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {removePolicyConnection} from '@libs/actions/connections';
+import Navigation from '@libs/Navigation/Navigation';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type {ConnectToNetSuiteButtonProps} from './types';
+
+function ConnectToNetSuiteButton({policyID, shouldDisconnectIntegrationBeforeConnecting, integrationToDisconnect}: ConnectToNetSuiteButtonProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+
+ const [isDisconnectModalOpen, setIsDisconnectModalOpen] = useState(false);
+
+ return (
+ <>
+ {
+ if (shouldDisconnectIntegrationBeforeConnecting && integrationToDisconnect) {
+ setIsDisconnectModalOpen(true);
+ return;
+ }
+
+ // TODO: Will be updated to new token input page
+ Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID));
+ }}
+ text={translate('workspace.accounting.setup')}
+ style={styles.justifyContentCenter}
+ small
+ isDisabled={isOffline}
+ />
+ {shouldDisconnectIntegrationBeforeConnecting && isDisconnectModalOpen && integrationToDisconnect && (
+ {
+ removePolicyConnection(policyID, integrationToDisconnect);
+
+ // TODO: Will be updated to new token input page
+ Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID));
+ setIsDisconnectModalOpen(false);
+ }}
+ integrationToConnect={CONST.POLICY.CONNECTIONS.NAME.NETSUITE}
+ onCancel={() => setIsDisconnectModalOpen(false)}
+ />
+ )}
+ >
+ );
+}
+
+export default ConnectToNetSuiteButton;
diff --git a/src/components/ConnectToNetSuiteButton/types.ts b/src/components/ConnectToNetSuiteButton/types.ts
new file mode 100644
index 000000000000..0e66b7b15eb6
--- /dev/null
+++ b/src/components/ConnectToNetSuiteButton/types.ts
@@ -0,0 +1,10 @@
+import type {PolicyConnectionName} from '@src/types/onyx/Policy';
+
+type ConnectToNetSuiteButtonProps = {
+ policyID: string;
+ shouldDisconnectIntegrationBeforeConnecting?: boolean;
+ integrationToDisconnect?: PolicyConnectionName;
+};
+
+// eslint-disable-next-line import/prefer-default-export
+export type {ConnectToNetSuiteButtonProps};
diff --git a/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx b/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx
index fbca4fbb5dae..bd9b623bcfb4 100644
--- a/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx
+++ b/src/components/ConnectToQuickbooksOnlineButton/index.native.tsx
@@ -2,9 +2,9 @@ import React, {useRef, useState} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import {WebView} from 'react-native-webview';
+import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import Button from '@components/Button';
-import ConfirmModal from '@components/ConfirmModal';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Modal from '@components/Modal';
@@ -57,19 +57,14 @@ function ConnectToQuickbooksOnlineButton({
isDisabled={isOffline}
/>
{shouldDisconnectIntegrationBeforeConnecting && integrationToDisconnect && isDisconnectModalOpen && (
- {
removePolicyConnection(policyID, integrationToDisconnect);
setIsDisconnectModalOpen(false);
setWebViewOpen(true);
}}
- isVisible
+ integrationToConnect={CONST.POLICY.CONNECTIONS.NAME.QBO}
onCancel={() => setIsDisconnectModalOpen(false)}
- prompt={translate('workspace.accounting.disconnectPrompt', CONST.POLICY.CONNECTIONS.NAME.QBO)}
- confirmText={translate('workspace.accounting.disconnect')}
- cancelText={translate('common.cancel')}
- danger
/>
)}
{isWebViewOpen && (
diff --git a/src/components/ConnectToQuickbooksOnlineButton/index.tsx b/src/components/ConnectToQuickbooksOnlineButton/index.tsx
index 10c358ad79c0..71f1fba91187 100644
--- a/src/components/ConnectToQuickbooksOnlineButton/index.tsx
+++ b/src/components/ConnectToQuickbooksOnlineButton/index.tsx
@@ -1,6 +1,6 @@
import React, {useState} from 'react';
+import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal';
import Button from '@components/Button';
-import ConfirmModal from '@components/ConfirmModal';
import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -38,19 +38,14 @@ function ConnectToQuickbooksOnlineButton({policyID, shouldDisconnectIntegrationB
small
/>
{shouldDisconnectIntegrationBeforeConnecting && integrationToDisconnect && isDisconnectModalOpen && (
- {
removePolicyConnection(policyID, integrationToDisconnect);
Link.openLink(getQuickBooksOnlineSetupLink(policyID), environmentURL);
setIsDisconnectModalOpen(false);
}}
+ integrationToConnect={CONST.POLICY.CONNECTIONS.NAME.QBO}
onCancel={() => setIsDisconnectModalOpen(false)}
- prompt={translate('workspace.accounting.disconnectPrompt', CONST.POLICY.CONNECTIONS.NAME.QBO)}
- confirmText={translate('workspace.accounting.disconnect')}
- cancelText={translate('common.cancel')}
- danger
/>
)}
>
diff --git a/src/components/ConnectToSageIntacctButton/index.tsx b/src/components/ConnectToSageIntacctButton/index.tsx
new file mode 100644
index 000000000000..460647838ec3
--- /dev/null
+++ b/src/components/ConnectToSageIntacctButton/index.tsx
@@ -0,0 +1,128 @@
+import React, {useRef, useState} from 'react';
+import type {View} from 'react-native';
+import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal';
+import Button from '@components/Button';
+import * as Expensicons from '@components/Icon/Expensicons';
+import PopoverMenu from '@components/PopoverMenu';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import {removePolicyConnection} from '@libs/actions/connections';
+import {getPoliciesConnectedToSageIntacct} from '@libs/actions/Policy/Policy';
+import Navigation from '@libs/Navigation/Navigation';
+import type {AnchorPosition} from '@styles/index';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type {PolicyConnectionName} from '@src/types/onyx/Policy';
+
+type ConnectToSageIntacctButtonProps = {
+ policyID: string;
+ shouldDisconnectIntegrationBeforeConnecting?: boolean;
+ integrationToDisconnect?: PolicyConnectionName;
+};
+
+function ConnectToSageIntacctButton({policyID, shouldDisconnectIntegrationBeforeConnecting, integrationToDisconnect}: ConnectToSageIntacctButtonProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+
+ const [isDisconnectModalOpen, setIsDisconnectModalOpen] = useState(false);
+
+ const hasPoliciesConnectedToSageIntacct = !!getPoliciesConnectedToSageIntacct().length;
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const [isReuseConnectionsPopoverOpen, setIsReuseConnectionsPopoverOpen] = useState(false);
+ const [reuseConnectionPopoverPosition, setReuseConnectionPopoverPosition] = useState({horizontal: 0, vertical: 0});
+ const threeDotsMenuContainerRef = useRef(null);
+ const connectionOptions = [
+ {
+ icon: Expensicons.LinkCopy,
+ text: translate('workspace.intacct.createNewConnection'),
+ onSelected: () => {
+ Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.getRoute(policyID));
+ setIsReuseConnectionsPopoverOpen(false);
+ },
+ },
+ {
+ icon: Expensicons.Copy,
+ text: translate('workspace.intacct.reuseExistingConnection'),
+ onSelected: () => {
+ Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.getRoute(policyID));
+ setIsReuseConnectionsPopoverOpen(false);
+ },
+ },
+ ];
+
+ return (
+ <>
+ {
+ if (shouldDisconnectIntegrationBeforeConnecting && integrationToDisconnect) {
+ setIsDisconnectModalOpen(true);
+ return;
+ }
+ if (!hasPoliciesConnectedToSageIntacct) {
+ Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.getRoute(policyID));
+ return;
+ }
+ if (!isSmallScreenWidth) {
+ threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => {
+ setReuseConnectionPopoverPosition({
+ horizontal: x + width,
+ vertical: y + height,
+ });
+ });
+ }
+ setIsReuseConnectionsPopoverOpen(true);
+ }}
+ text={translate('workspace.accounting.setup')}
+ style={styles.justifyContentCenter}
+ small
+ isDisabled={isOffline}
+ ref={threeDotsMenuContainerRef}
+ />
+ {
+ setIsReuseConnectionsPopoverOpen(false);
+ }}
+ withoutOverlay
+ menuItems={connectionOptions}
+ onItemSelected={(item) => {
+ if (!item?.onSelected) {
+ return;
+ }
+ item.onSelected();
+ }}
+ anchorPosition={reuseConnectionPopoverPosition}
+ anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}}
+ anchorRef={threeDotsMenuContainerRef}
+ />
+ {shouldDisconnectIntegrationBeforeConnecting && isDisconnectModalOpen && integrationToDisconnect && (
+ {
+ removePolicyConnection(policyID, integrationToDisconnect);
+ setIsDisconnectModalOpen(false);
+ if (!hasPoliciesConnectedToSageIntacct) {
+ Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.getRoute(policyID));
+ return;
+ }
+ if (!isSmallScreenWidth) {
+ threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => {
+ setReuseConnectionPopoverPosition({
+ horizontal: x + width,
+ vertical: y + height,
+ });
+ });
+ }
+ setIsReuseConnectionsPopoverOpen(true);
+ }}
+ integrationToConnect={CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT}
+ onCancel={() => setIsDisconnectModalOpen(false)}
+ />
+ )}
+ >
+ );
+}
+
+export default ConnectToSageIntacctButton;
diff --git a/src/components/ConnectToXeroButton/index.native.tsx b/src/components/ConnectToXeroButton/index.native.tsx
index 11cafe2bdfbc..15fe201f2ac9 100644
--- a/src/components/ConnectToXeroButton/index.native.tsx
+++ b/src/components/ConnectToXeroButton/index.native.tsx
@@ -2,9 +2,9 @@ import React, {useRef, useState} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import {WebView} from 'react-native-webview';
+import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import Button from '@components/Button';
-import ConfirmModal from '@components/ConfirmModal';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Modal from '@components/Modal';
@@ -52,19 +52,14 @@ function ConnectToXeroButton({policyID, session, shouldDisconnectIntegrationBefo
isDisabled={isOffline}
/>
{shouldDisconnectIntegrationBeforeConnecting && isDisconnectModalOpen && integrationToDisconnect && (
- {
removePolicyConnection(policyID, integrationToDisconnect);
setIsDisconnectModalOpen(false);
setWebViewOpen(true);
}}
- isVisible
+ integrationToConnect={CONST.POLICY.CONNECTIONS.NAME.XERO}
onCancel={() => setIsDisconnectModalOpen(false)}
- prompt={translate('workspace.accounting.disconnectPrompt', CONST.POLICY.CONNECTIONS.NAME.XERO)}
- confirmText={translate('workspace.accounting.disconnect')}
- cancelText={translate('common.cancel')}
- danger
/>
)}
{shouldDisconnectIntegrationBeforeConnecting && isDisconnectModalOpen && integrationToDisconnect && (
- {
removePolicyConnection(policyID, integrationToDisconnect);
Link.openLink(getXeroSetupLink(policyID), environmentURL);
setIsDisconnectModalOpen(false);
}}
+ integrationToConnect={CONST.POLICY.CONNECTIONS.NAME.XERO}
onCancel={() => setIsDisconnectModalOpen(false)}
- prompt={translate('workspace.accounting.disconnectPrompt', CONST.POLICY.CONNECTIONS.NAME.XERO)}
- confirmText={translate('workspace.accounting.disconnect')}
- cancelText={translate('common.cancel')}
- danger
/>
)}
>
diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx
new file mode 100644
index 000000000000..ef49297078d5
--- /dev/null
+++ b/src/components/ExplanationModal.tsx
@@ -0,0 +1,41 @@
+import React, {useCallback} from 'react';
+import useLocalize from '@hooks/useLocalize';
+import Navigation from '@libs/Navigation/Navigation';
+import variables from '@styles/variables';
+import * as Welcome from '@userActions/Welcome';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import FeatureTrainingModal from './FeatureTrainingModal';
+
+function ExplanationModal() {
+ const {translate} = useLocalize();
+
+ const onConfirm = useCallback(() => {
+ Welcome.completeHybridAppOnboarding();
+
+ // We need to check if standard NewDot onboarding is completed.
+ Welcome.isOnboardingFlowCompleted({
+ onNotCompleted: () => {
+ setTimeout(() => {
+ Navigation.isNavigationReady().then(() => {
+ Navigation.navigate(ROUTES.ONBOARDING_ROOT);
+ });
+ }, variables.welcomeVideoDelay);
+ },
+ });
+ }, []);
+
+ return (
+
+ );
+}
+
+ExplanationModal.displayName = 'ExplanationModal';
+export default ExplanationModal;
diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx
index 88099b6d078b..221a582af75d 100644
--- a/src/components/FeatureTrainingModal.tsx
+++ b/src/components/FeatureTrainingModal.tsx
@@ -51,6 +51,9 @@ type FeatureTrainingModalProps = {
/** Describe what is showing */
description?: string;
+ /** Secondary description rendered with additional space */
+ secondaryDescription?: string;
+
/** Whether to show `Don't show me this again` option */
shouldShowDismissModalOption?: boolean;
@@ -73,6 +76,7 @@ function FeatureTrainingModal({
videoAspectRatio: videoAspectRatioProp,
title = '',
description = '',
+ secondaryDescription = '',
shouldShowDismissModalOption = false,
confirmText = '',
onConfirm = () => {},
@@ -199,6 +203,7 @@ function FeatureTrainingModal({
{title}
{description}
+ {secondaryDescription.length > 0 && {secondaryDescription} }
)}
{shouldShowDismissModalOption && (
diff --git a/src/components/HybridAppMiddleware.tsx b/src/components/HybridAppMiddleware.tsx
new file mode 100644
index 000000000000..5c6934f4fc3d
--- /dev/null
+++ b/src/components/HybridAppMiddleware.tsx
@@ -0,0 +1,108 @@
+import {useNavigation} from '@react-navigation/native';
+import type {StackNavigationProp} from '@react-navigation/stack';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import {NativeModules} from 'react-native';
+import useSplashScreen from '@hooks/useSplashScreen';
+import BootSplash from '@libs/BootSplash';
+import Log from '@libs/Log';
+import Navigation from '@libs/Navigation/Navigation';
+import type {RootStackParamList} from '@libs/Navigation/types';
+import * as Welcome from '@userActions/Welcome';
+import CONST from '@src/CONST';
+import type {Route} from '@src/ROUTES';
+
+type HybridAppMiddlewareProps = {
+ children: React.ReactNode;
+};
+
+type HybridAppMiddlewareContextType = {
+ navigateToExitUrl: (exitUrl: Route) => void;
+ showSplashScreenOnNextStart: () => void;
+};
+const HybridAppMiddlewareContext = React.createContext({
+ navigateToExitUrl: () => {},
+ showSplashScreenOnNextStart: () => {},
+});
+
+/*
+ * HybridAppMiddleware is responsible for handling BootSplash visibility correctly.
+ * It is crucial to make transitions between OldDot and NewDot look smooth.
+ */
+function HybridAppMiddleware(props: HybridAppMiddlewareProps) {
+ const {isSplashHidden, setIsSplashHidden} = useSplashScreen();
+ const [startedTransition, setStartedTransition] = useState(false);
+ const [finishedTransition, setFinishedTransition] = useState(false);
+ const navigation = useNavigation>();
+
+ /*
+ * Handles navigation during transition from OldDot. For ordinary NewDot app it is just pure navigation.
+ */
+ const navigateToExitUrl = useCallback((exitUrl: Route) => {
+ if (NativeModules.HybridAppModule) {
+ setStartedTransition(true);
+ Log.info(`[HybridApp] Started transition to ${exitUrl}`, true);
+ }
+
+ Navigation.navigate(exitUrl);
+ }, []);
+
+ /**
+ * This function only affects iOS. If during a single app lifecycle we frequently transition from OldDot to NewDot,
+ * we need to artificially show the bootsplash because the app is only booted once.
+ */
+ const showSplashScreenOnNextStart = useCallback(() => {
+ setIsSplashHidden(false);
+ setStartedTransition(false);
+ setFinishedTransition(false);
+ }, [setIsSplashHidden]);
+
+ useEffect(() => {
+ if (!finishedTransition || isSplashHidden) {
+ return;
+ }
+
+ Log.info('[HybridApp] Finished transtion', true);
+ BootSplash.hide().then(() => {
+ setIsSplashHidden(true);
+ Log.info('[HybridApp] Handling onboarding flow', true);
+ Welcome.handleHybridAppOnboarding();
+ });
+ }, [finishedTransition, isSplashHidden, setIsSplashHidden]);
+
+ useEffect(() => {
+ if (!startedTransition) {
+ return;
+ }
+
+ // On iOS, the transitionEnd event doesn't trigger some times. As such, we need to set a timeout.
+ const timeout = setTimeout(() => {
+ setFinishedTransition(true);
+ }, CONST.SCREEN_TRANSITION_END_TIMEOUT);
+
+ const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => {
+ clearTimeout(timeout);
+ setFinishedTransition(true);
+ });
+
+ return () => {
+ clearTimeout(timeout);
+ unsubscribeTransitionEnd();
+ };
+ }, [navigation, startedTransition]);
+
+ const contextValue = useMemo(
+ () => ({
+ navigateToExitUrl,
+ showSplashScreenOnNextStart,
+ }),
+ [navigateToExitUrl, showSplashScreenOnNextStart],
+ );
+
+ return {props.children} ;
+}
+
+HybridAppMiddleware.displayName = 'HybridAppMiddleware';
+
+export default HybridAppMiddleware;
+export type {HybridAppMiddlewareContextType};
+export {HybridAppMiddlewareContext};
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 3b6d51e786a3..a0d7a5cb8883 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -100,6 +100,7 @@ import Inbox from '@assets/images/inbox.svg';
import Info from '@assets/images/info.svg';
import NetSuiteSquare from '@assets/images/integrationicons/netsuite-icon-square.svg';
import QBOSquare from '@assets/images/integrationicons/qbo-icon-square.svg';
+import SageIntacctSquare from '@assets/images/integrationicons/sage-intacct-icon-square.svg';
import XeroSquare from '@assets/images/integrationicons/xero-icon-square.svg';
import InvoiceGeneric from '@assets/images/invoice-generic.svg';
import Invoice from '@assets/images/invoice.svg';
@@ -349,6 +350,7 @@ export {
Workflows,
Workspace,
XeroSquare,
+ SageIntacctSquare as IntacctSquare,
Zoom,
Twitter,
Youtube,
diff --git a/src/components/LottieAnimations/index.tsx b/src/components/LottieAnimations/index.tsx
index 657fe79b401f..477ce02cd740 100644
--- a/src/components/LottieAnimations/index.tsx
+++ b/src/components/LottieAnimations/index.tsx
@@ -71,6 +71,7 @@ const DotLottieAnimations = {
file: require('@assets/animations/Desk.lottie'),
w: 200,
h: 120,
+ backgroundColor: colors.blue700,
},
Plane: {
file: require('@assets/animations/Plane.lottie'),
diff --git a/src/components/OnboardingWelcomeVideo.tsx b/src/components/OnboardingWelcomeVideo.tsx
index c4378a258d5d..47444d133166 100644
--- a/src/components/OnboardingWelcomeVideo.tsx
+++ b/src/components/OnboardingWelcomeVideo.tsx
@@ -17,5 +17,4 @@ function OnboardingWelcomeVideo() {
}
OnboardingWelcomeVideo.displayName = 'OnboardingWelcomeVideo';
-
export default OnboardingWelcomeVideo;
diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
index da5ce1e43085..377007d40c54 100644
--- a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
+++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
@@ -1,5 +1,5 @@
import type {ForwardedRef} from 'react';
-import React, {forwardRef, useCallback, useEffect, useMemo} from 'react';
+import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'react';
import type {GestureResponderEvent, View} from 'react-native';
// eslint-disable-next-line no-restricted-imports
import {Pressable} from 'react-native';
@@ -45,6 +45,7 @@ function GenericPressable(
const {isExecuting, singleExecution} = useSingleExecution();
const isScreenReaderActive = Accessibility.useScreenReaderStatus();
const [hitSlop, onLayout] = Accessibility.useAutoHitSlop();
+ const [isHovered, setIsHovered] = useState(false);
const isDisabled = useMemo(() => {
let shouldBeDisabledByScreenReader = false;
@@ -153,7 +154,7 @@ function GenericPressable(
StyleUtils.parseStyleFromFunction(style, state),
isScreenReaderActive && StyleUtils.parseStyleFromFunction(screenReaderActiveStyle, state),
state.focused && StyleUtils.parseStyleFromFunction(focusStyle, state),
- state.hovered && StyleUtils.parseStyleFromFunction(hoverStyle, state),
+ (state.hovered || isHovered) && StyleUtils.parseStyleFromFunction(hoverStyle, state),
state.pressed && StyleUtils.parseStyleFromFunction(pressStyle, state),
isDisabled && [StyleUtils.parseStyleFromFunction(disabledStyle, state), styles.noSelect],
]}
@@ -170,8 +171,23 @@ function GenericPressable(
accessible={accessible}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
+ onHoverOut={(event) => {
+ if (event?.type === 'pointerenter' || event?.type === 'mouseenter') {
+ return;
+ }
+ setIsHovered(false);
+ if (rest.onHoverOut) {
+ rest.onHoverOut(event);
+ }
+ }}
+ onHoverIn={(event) => {
+ setIsHovered(true);
+ if (rest.onHoverIn) {
+ rest.onHoverIn(event);
+ }
+ }}
>
- {(state) => (typeof children === 'function' ? children({...state, isScreenReaderActive, isDisabled}) : children)}
+ {(state) => (typeof children === 'function' ? children({...state, isScreenReaderActive, hovered: state.hovered || isHovered, isDisabled}) : children)}
);
}
diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx
index 5ebf9b4766d6..1d5d65d9874d 100644
--- a/src/components/ScreenWrapper.tsx
+++ b/src/components/ScreenWrapper.tsx
@@ -131,7 +131,7 @@ function ScreenWrapper(
) {
/**
* We are only passing navigation as prop from
- * ReportScreenWrapper -> ReportScreen -> ScreenWrapper
+ * ReportScreen -> ScreenWrapper
*
* so in other places where ScreenWrapper is used, we need to
* fallback to useNavigation.
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index d4f790de69b4..617c70a1d224 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -540,14 +540,17 @@ function BaseSelectionList(
const prevTextInputValue = usePrevious(textInputValue);
const prevSelectedOptionsLength = usePrevious(flattenedSections.selectedOptions.length);
+ const prevAllOptionsLength = usePrevious(flattenedSections.allOptions.length);
useEffect(() => {
// Avoid changing focus if the textInputValue remains unchanged.
if ((prevTextInputValue === textInputValue && flattenedSections.selectedOptions.length === prevSelectedOptionsLength) || flattenedSections.allOptions.length === 0) {
return;
}
- // Remove the focus if the search input is empty or selected options length is changed else focus on the first non disabled item
- const newSelectedIndex = textInputValue === '' || flattenedSections.selectedOptions.length !== prevSelectedOptionsLength ? -1 : 0;
+ // Remove the focus if the search input is empty or selected options length is changed (and allOptions length remains the same)
+ // else focus on the first non disabled item
+ const newSelectedIndex =
+ textInputValue === '' || (flattenedSections.selectedOptions.length !== prevSelectedOptionsLength && prevAllOptionsLength === flattenedSections.allOptions.length) ? -1 : 0;
// reseting the currrent page to 1 when the user types something
setCurrentPage(1);
@@ -561,6 +564,7 @@ function BaseSelectionList(
textInputValue,
updateAndScrollToFocusedIndex,
prevSelectedOptionsLength,
+ prevAllOptionsLength,
]);
useEffect(
diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx
index 452af82fb78f..0adc7ee21fd1 100644
--- a/src/components/SelectionList/Search/TransactionListItemRow.tsx
+++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx
@@ -111,7 +111,7 @@ function MerchantCell({transactionItem, showTooltip, isLargeScreenWidth}: Transa
const description = TransactionUtils.getDescription(transactionItem);
let merchant = transactionItem.shouldShowMerchant ? transactionItem.formattedMerchant : description;
- if (TransactionUtils.hasReceipt(transactionItem) && TransactionUtils.isReceiptBeingScanned(transactionItem)) {
+ if (TransactionUtils.hasReceipt(transactionItem) && TransactionUtils.isReceiptBeingScanned(transactionItem) && transactionItem.shouldShowMerchant) {
merchant = translate('iou.receiptStatusTitle');
}
diff --git a/src/components/withPrepareCentralPaneScreen/index.native.tsx b/src/components/withPrepareCentralPaneScreen/index.native.tsx
index 4ebf7448c3be..84ba31cd63fd 100644
--- a/src/components/withPrepareCentralPaneScreen/index.native.tsx
+++ b/src/components/withPrepareCentralPaneScreen/index.native.tsx
@@ -1,27 +1,10 @@
-import type {ComponentType, ForwardedRef, RefAttributes} from 'react';
-import React from 'react';
-import getComponentDisplayName from '@libs/getComponentDisplayName';
-import FreezeWrapper from '@libs/Navigation/FreezeWrapper';
+import type React from 'react';
+import freezeScreenWithLazyLoading from '@libs/freezeScreenWithLazyLoading';
/**
- * This HOC is dependent on the platform. On native platforms, screens that aren't already displayed in the navigation stack should be frozen to prevent unnecessary rendering.
+ * This higher-order function is dependent on the platform. On native platforms, screens that aren't already displayed in the navigation stack should be frozen to prevent unnecessary rendering.
* It's handled this way only on mobile platforms because on the web, more than one screen is displayed in a wide layout, so these screens shouldn't be frozen.
*/
-export default function withPrepareCentralPaneScreen(
- WrappedComponent: ComponentType>,
-): (props: TProps & React.RefAttributes) => React.ReactElement | null {
- function WithPrepareCentralPaneScreen(props: TProps, ref: ForwardedRef) {
- return (
-
-
-
- );
- }
-
- WithPrepareCentralPaneScreen.displayName = `WithPrepareCentralPaneScreen(${getComponentDisplayName(WrappedComponent)})`;
- return React.forwardRef(WithPrepareCentralPaneScreen);
+export default function withPrepareCentralPaneScreen(lazyComponent: () => React.ComponentType) {
+ return freezeScreenWithLazyLoading(lazyComponent);
}
diff --git a/src/components/withPrepareCentralPaneScreen/index.tsx b/src/components/withPrepareCentralPaneScreen/index.tsx
index fe31b9fa7ecc..f53368188b3d 100644
--- a/src/components/withPrepareCentralPaneScreen/index.tsx
+++ b/src/components/withPrepareCentralPaneScreen/index.tsx
@@ -1,9 +1,9 @@
-import type {ComponentType} from 'react';
+import type React from 'react';
/**
- * This HOC is dependent on the platform. On native platforms, screens that aren't already displayed in the navigation stack should be frozen to prevent unnecessary rendering.
+ * This higher-order function is dependent on the platform. On native platforms, screens that aren't already displayed in the navigation stack should be frozen to prevent unnecessary rendering.
* It's handled this way only on mobile platforms because on the web, more than one screen is displayed in a wide layout, so these screens shouldn't be frozen.
*/
-export default function withPrepareCentralPaneScreen(WrappedComponent: ComponentType) {
- return WrappedComponent;
+export default function withPrepareCentralPaneScreen(lazyComponent: () => React.ComponentType) {
+ return lazyComponent;
}
diff --git a/src/hooks/useHybridAppMiddleware.ts b/src/hooks/useHybridAppMiddleware.ts
new file mode 100644
index 000000000000..18ebd9730630
--- /dev/null
+++ b/src/hooks/useHybridAppMiddleware.ts
@@ -0,0 +1,11 @@
+import {useContext} from 'react';
+import {HybridAppMiddlewareContext} from '@components/HybridAppMiddleware';
+
+type SplashScreenHiddenContextType = {isSplashHidden: boolean};
+
+export default function useHybridAppMiddleware() {
+ const {navigateToExitUrl, showSplashScreenOnNextStart} = useContext(HybridAppMiddlewareContext);
+ return {navigateToExitUrl, showSplashScreenOnNextStart};
+}
+
+export type {SplashScreenHiddenContextType};
diff --git a/src/hooks/useIsSplashHidden.ts b/src/hooks/useIsSplashHidden.ts
deleted file mode 100644
index 7563d388416c..000000000000
--- a/src/hooks/useIsSplashHidden.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import {useContext} from 'react';
-import {SplashScreenHiddenContext} from '@src/Expensify';
-
-type SplashScreenHiddenContextType = {isSplashHidden: boolean};
-
-export default function useIsSplashHidden() {
- const {isSplashHidden} = useContext(SplashScreenHiddenContext) as SplashScreenHiddenContextType;
- return isSplashHidden;
-}
-
-export type {SplashScreenHiddenContextType};
diff --git a/src/hooks/useLastAccessedReportID.ts b/src/hooks/useLastAccessedReportID.ts
new file mode 100644
index 000000000000..16a4a6bc2a31
--- /dev/null
+++ b/src/hooks/useLastAccessedReportID.ts
@@ -0,0 +1,148 @@
+import {useCallback, useSyncExternalStore} from 'react';
+import type {OnyxCollection} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
+import {getPolicyEmployeeListByIdWithoutCurrentUser} from '@libs/PolicyUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy, Report, ReportMetadata} from '@src/types/onyx';
+import useActiveWorkspace from './useActiveWorkspace';
+import usePermissions from './usePermissions';
+
+/*
+ * This hook is used to get the lastAccessedReportID.
+ * This is a piece of data that's derived from a lot of frequently-changing Onyx values: (reports, reportMetadata, policies, etc...)
+ * We don't want any component that needs access to the lastAccessedReportID to have to re-render any time any of those values change, just when the lastAccessedReportID changes.
+ * So we have a custom implementation in this file that leverages useSyncExternalStore to connect to a "store" of multiple Onyx values, and re-render only when the one derived value changes.
+ */
+
+const subscribers: Array<() => void> = [];
+
+let reports: OnyxCollection = {};
+let reportMetadata: OnyxCollection = {};
+let policies: OnyxCollection = {};
+let accountID: number | undefined;
+let isFirstTimeNewExpensifyUser = false;
+
+let reportsConnection: number;
+let reportMetadataConnection: number;
+let policiesConnection: number;
+let accountIDConnection: number;
+let isFirstTimeNewExpensifyUserConnection: number;
+
+function notifySubscribers() {
+ subscribers.forEach((subscriber) => subscriber());
+}
+
+function subscribeToOnyxData() {
+ // eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs
+ reportsConnection = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ reports = value;
+ notifySubscribers();
+ },
+ });
+ // eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs
+ reportMetadataConnection = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_METADATA,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ reportMetadata = value;
+ notifySubscribers();
+ },
+ });
+ // eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs
+ policiesConnection = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.POLICY,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ policies = value;
+ notifySubscribers();
+ },
+ });
+ // eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs
+ accountIDConnection = Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (value) => {
+ accountID = value?.accountID;
+ notifySubscribers();
+ },
+ });
+ // eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs
+ isFirstTimeNewExpensifyUserConnection = Onyx.connect({
+ key: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER,
+ callback: (value) => {
+ isFirstTimeNewExpensifyUser = !!value;
+ notifySubscribers();
+ },
+ });
+}
+
+function unsubscribeFromOnyxData() {
+ if (reportsConnection) {
+ Onyx.disconnect(reportsConnection);
+ reportsConnection = 0;
+ }
+ if (reportMetadataConnection) {
+ Onyx.disconnect(reportMetadataConnection);
+ reportMetadataConnection = 0;
+ }
+ if (policiesConnection) {
+ Onyx.disconnect(policiesConnection);
+ policiesConnection = 0;
+ }
+ if (accountIDConnection) {
+ Onyx.disconnect(accountIDConnection);
+ accountIDConnection = 0;
+ }
+ if (isFirstTimeNewExpensifyUserConnection) {
+ Onyx.disconnect(isFirstTimeNewExpensifyUserConnection);
+ isFirstTimeNewExpensifyUserConnection = 0;
+ }
+}
+
+function removeSubscriber(subscriber: () => void) {
+ const subscriberIndex = subscribers.indexOf(subscriber);
+ if (subscriberIndex < 0) {
+ return;
+ }
+ subscribers.splice(subscriberIndex, 1);
+ if (subscribers.length === 0) {
+ unsubscribeFromOnyxData();
+ }
+}
+
+function addSubscriber(subscriber: () => void) {
+ subscribers.push(subscriber);
+ if (!reportsConnection) {
+ subscribeToOnyxData();
+ }
+ return () => removeSubscriber(subscriber);
+}
+
+/**
+ * Get the last accessed reportID.
+ */
+export default function useLastAccessedReportID(shouldOpenOnAdminRoom: boolean) {
+ const {canUseDefaultRooms} = usePermissions();
+ const {activeWorkspaceID} = useActiveWorkspace();
+
+ const getSnapshot = useCallback(() => {
+ const policyMemberAccountIDs = getPolicyEmployeeListByIdWithoutCurrentUser(policies, activeWorkspaceID, accountID);
+ return ReportUtils.findLastAccessedReport(
+ reports,
+ !canUseDefaultRooms,
+ policies,
+ isFirstTimeNewExpensifyUser,
+ shouldOpenOnAdminRoom,
+ reportMetadata,
+ activeWorkspaceID,
+ policyMemberAccountIDs,
+ )?.reportID;
+ }, [activeWorkspaceID, canUseDefaultRooms, shouldOpenOnAdminRoom]);
+
+ // We need access to all the data from these Onyx.connect calls, but we don't want to re-render the consuming component
+ // unless the derived value (lastAccessedReportID) changes. To address these, we'll wrap everything with useSyncExternalStore
+ return useSyncExternalStore(addSubscriber, getSnapshot);
+}
diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts
new file mode 100644
index 000000000000..44a82253b7c0
--- /dev/null
+++ b/src/hooks/usePaginatedReportActions.ts
@@ -0,0 +1,33 @@
+import {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+/**
+ * Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions.
+ */
+function usePaginatedReportActions(reportID?: string, reportActionID?: string) {
+ // Use `||` instead of `??` to handle empty string.
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const reportIDWithDefault = reportID || '-1';
+ const [sortedAllReportActions = []] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDWithDefault}`, {
+ canEvict: false,
+ selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true),
+ });
+
+ const reportActions = useMemo(() => {
+ if (!sortedAllReportActions.length) {
+ return [];
+ }
+ return ReportActionsUtils.getContinuousReportActionChain(sortedAllReportActions, reportActionID);
+ }, [reportActionID, sortedAllReportActions]);
+
+ const linkedAction = useMemo(() => sortedAllReportActions.find((obj) => String(obj.reportActionID) === String(reportActionID)), [reportActionID, sortedAllReportActions]);
+
+ return {
+ reportActions,
+ linkedAction,
+ };
+}
+
+export default usePaginatedReportActions;
diff --git a/src/hooks/useSplashScreen.ts b/src/hooks/useSplashScreen.ts
new file mode 100644
index 000000000000..8838ac1289c7
--- /dev/null
+++ b/src/hooks/useSplashScreen.ts
@@ -0,0 +1,11 @@
+import {useContext} from 'react';
+import {SplashScreenHiddenContext} from '@src/Expensify';
+
+type SplashScreenHiddenContextType = {isSplashHidden: boolean; setIsSplashHidden: React.Dispatch>};
+
+export default function useSplashScreen() {
+ const {isSplashHidden, setIsSplashHidden} = useContext(SplashScreenHiddenContext) as SplashScreenHiddenContextType;
+ return {isSplashHidden, setIsSplashHidden};
+}
+
+export type {SplashScreenHiddenContextType};
diff --git a/src/languages/en.ts b/src/languages/en.ts
index f72128a2afce..83e65fb36d0f 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -49,6 +49,7 @@ import type {
PaySomeoneParams,
ReimbursementRateParams,
RemovedTheRequestParams,
+ RemoveMembersWarningPrompt,
RenamedRoomActionParams,
ReportArchiveReasonsClosedParams,
ReportArchiveReasonsMergedParams,
@@ -339,6 +340,8 @@ export default {
shared: 'Shared',
drafts: 'Drafts',
finished: 'Finished',
+ companyID: 'Company ID',
+ userID: 'User ID',
disable: 'Disable',
},
location: {
@@ -1413,6 +1416,12 @@ export default {
notYou: ({user}: NotYouParams) => `Not ${user}?`,
},
onboarding: {
+ welcome: 'Welcome!',
+ explanationModal: {
+ title: 'Welcome to Expensify',
+ description: 'Request and send money is just as easy as sending a message. The new era of expensing is upon us.',
+ secondaryDescription: 'To switch back to Expensify Classic, just tap your profile picture > Go to Expensify Classic.',
+ },
welcomeVideo: {
title: 'Welcome to Expensify',
description: 'One app to handle all your business and personal spend in a chat. Built for your business, your team, and your friends.',
@@ -2060,12 +2069,11 @@ export default {
exportVendorBillDescription:
"We'll create an itemized vendor bill for each Expensify report and add it to the account below. If this period is closed, we'll post to the 1st of the next open period.",
account: 'Account',
- accountDescription: 'Choose where to post journal entry offsets.',
+ accountDescription: 'Choose where to post journal entries.',
accountsPayable: 'Accounts payable',
accountsPayableDescription: 'Choose where to create vendor bills.',
bankAccount: 'Bank account',
bankAccountDescription: 'Choose where to send checks from.',
- optionBelow: 'Choose an option below:',
companyCardsLocationEnabledDescription:
"QuickBooks Online doesn't support locations on vendor bill exports. As you have locations enabled on your workspace, this export option is unavailable.",
outOfPocketTaxEnabledDescription:
@@ -2075,7 +2083,7 @@ export default {
advancedConfig: {
advanced: 'Advanced',
autoSync: 'Auto-sync',
- autoSyncDescription: 'Sync QuickBooks Online and Expensify automatically, every day.',
+ autoSyncDescription: 'Expensify will automatically sync with QuickBooks Online every day.',
inviteEmployees: 'Invite employees',
inviteEmployeesDescription: 'Import Quickbooks Online employee records and invite employees to this workspace.',
createEntities: 'Auto-create entities',
@@ -2084,8 +2092,8 @@ export default {
reimbursedReportsDescription: 'Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the Quickbooks Online account below.',
qboBillPaymentAccount: 'QuickBooks bill payment account',
qboInvoiceCollectionAccount: 'QuickBooks invoice collections account',
- accountSelectDescription: "Choose a bank account for reimbursements and we'll create the payment in QuickBooks Online.",
- invoiceAccountSelectorDescription: 'Once an invoice is marked as paid in Expensify and exported to QuickBooks Online, it’ll appear against the account below.',
+ accountSelectDescription: "Choose where to pay bills from and we'll create the payment in QuickBooks Online.",
+ invoiceAccountSelectorDescription: "Choose where to receive invoice payments and we'll create the payment in QuickBooks Online.",
},
accounts: {
[CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD]: 'Debit card',
@@ -2101,8 +2109,8 @@ export default {
[`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Description`]:
"We'll create an itemized vendor bill for each Expensify report with the date of the last expense, and add it to the account below. If this period is closed, we'll post to the 1st of the next open period.",
- [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD}AccountDescription`]: 'Debit card transactions will export to the bank account below.',
- [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]: 'Credit card transactions will export to the bank account below.',
+ [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD}AccountDescription`]: 'Choose where to export debit card transactions.',
+ [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]: 'Choose where to export credit card transactions.',
[`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}AccountDescription`]: 'Choose a vendor to apply to all credit card transactions.',
[`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Error`]: 'Vendor bills are unavailable when locations are enabled. Please choose a different export option.',
@@ -2149,28 +2157,28 @@ export default {
advancedConfig: {
advanced: 'Advanced',
autoSync: 'Auto-sync',
- autoSyncDescription: 'Sync Xero and Expensify automatically, every day.',
+ autoSyncDescription: 'Expensify will automatically sync with Xero every day.',
purchaseBillStatusTitle: 'Purchase bill status',
reimbursedReports: 'Sync reimbursed reports',
reimbursedReportsDescription: 'Any time a report is paid using Expensify ACH, the corresponding bill payment will be created in the Xero account below.',
xeroBillPaymentAccount: 'Xero bill payment account',
xeroInvoiceCollectionAccount: 'Xero invoice collections account',
- invoiceAccountSelectorDescription: 'Once an invoice is marked as paid in Expensify and exported to Xero, it’ll appear against the account below.',
- xeroBillPaymentAccountDescription: "Choose a bank account for reimbursements and we'll create the payment in Xero.",
+ xeroBillPaymentAccountDescription: "Choose where to pay bills from and we'll create the payment in Xero.",
+ invoiceAccountSelectorDescription: "Choose where to receive invoice payments and we'll create the payment in Xero.",
},
exportDate: {
- label: 'Export date',
- description: 'Use this date when exporting purchase bills to Xero.',
+ label: 'Purchase bill date',
+ description: 'Use this date when exporting reports to Xero.',
values: {
- [CONST.QUICKBOOKS_EXPORT_DATE.LAST_EXPENSE]: {
+ [CONST.XERO_EXPORT_DATE.LAST_EXPENSE]: {
label: 'Date of last expense',
description: 'Date of the most recent expense on the report.',
},
- [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_EXPORTED]: {
+ [CONST.XERO_EXPORT_DATE.REPORT_EXPORTED]: {
label: 'Export date',
description: 'Date the report was exported to Xero.',
},
- [CONST.QUICKBOOKS_EXPORT_DATE.REPORT_SUBMITTED]: {
+ [CONST.XERO_EXPORT_DATE.REPORT_SUBMITTED]: {
label: 'Submitted date',
description: 'Date the report was submitted for approval.',
},
@@ -2197,6 +2205,17 @@ export default {
noSubsidiariesFound: 'No subsidiaries found',
noSubsidiariesFoundDescription: 'Add the subsidiary in NetSuite and sync the connection again.',
},
+ intacct: {
+ sageIntacctSetup: 'Sage Intacct setup',
+ prerequisitesTitle: 'Before you connect...',
+ downloadExpensifyPackage: 'Download the Expensify package for Sage Intacct',
+ followSteps: 'Follow the steps in our How-to: Connect to Sage Intacct instructions',
+ enterCredentials: 'Enter your Sage Intacct credentials',
+ createNewConnection: 'Create new connection',
+ reuseExistingConnection: 'Reuse existing connection',
+ existingConnections: 'Existing connections',
+ sageIntacctLastSync: (formattedDate: string) => `Sage Intacct - Last synced ${formattedDate}`,
+ },
type: {
free: 'Free',
control: 'Control',
@@ -2369,6 +2388,8 @@ export default {
people: {
genericFailureMessage: 'An error occurred removing a user from the workspace, please try again.',
removeMembersPrompt: 'Are you sure you want to remove these members?',
+ removeMembersWarningPrompt: ({memberName, ownerName}: RemoveMembersWarningPrompt) =>
+ `${memberName} is an approver in this workspace. When you unshare this workspace with them, we’ll replace them in the approval workflow with the workspace owner, ${ownerName}`,
removeMembersTitle: 'Remove members',
removeMemberButtonTitle: 'Remove from workspace',
removeMemberGroupButtonTitle: 'Remove from group',
@@ -2399,6 +2420,34 @@ export default {
benefit4: 'Customizable limits and spend controls',
addWorkEmail: 'Add work email address',
checkingDomain: "Hang tight! We're still working on enabling your Expensify Cards. Check back here in a few minutes.",
+ issueCard: 'Issue card',
+ issueNewCard: {
+ whoNeedsCard: 'Who needs a card?',
+ findMember: 'Find member',
+ chooseCardType: 'Choose a card type',
+ physicalCard: 'Physical card',
+ physicalCardDescription: 'Great for the frequent spender',
+ virtualCard: 'Virtual card',
+ virtualCardDescription: 'Instant and flexible',
+ chooseLimitType: 'Choose a limit type',
+ smartLimit: 'Smart Limit',
+ smartLimitDescription: 'Spend up to a certain amount before requiring approval',
+ monthly: 'Monthly',
+ monthlyDescription: 'Spend up to a certain amount per month',
+ fixedAmount: 'Fixed amount',
+ fixedAmountDescription: 'Spend up to a certain amount once',
+ setLimit: 'Set a limit',
+ giveItName: 'Give it a name',
+ giveItNameInstruction: 'Make it unique enough to tell apart from the other. Specific use cases are even better!',
+ cardName: 'Card name',
+ letsDoubleCheck: 'Let’s double check that everything looks right.',
+ willBeReady: 'This card will be ready to use immediately.',
+ cardholder: 'Cardholder',
+ cardType: 'Card type',
+ limit: 'Limit',
+ limitType: 'Limit type',
+ name: 'Name',
+ },
},
reimburse: {
captureReceipts: 'Capture receipts',
@@ -2426,6 +2475,7 @@ export default {
qbo: 'Quickbooks Online',
xero: 'Xero',
netsuite: 'NetSuite',
+ intacct: 'Sage Intacct',
setup: 'Connect',
lastSync: 'Last synced just now',
import: 'Import',
@@ -2435,22 +2485,19 @@ export default {
syncNow: 'Sync now',
disconnect: 'Disconnect',
disconnectTitle: (integration?: ConnectionName): string => {
- switch (integration) {
- case CONST.POLICY.CONNECTIONS.NAME.QBO:
- return 'Disconnect QuickBooks Online';
- case CONST.POLICY.CONNECTIONS.NAME.XERO:
- return 'Disconnect Xero';
- default: {
- return 'Disconnect integration';
- }
- }
+ const integrationName = integration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] : 'integration';
+ return `Disconnect ${integrationName}`;
},
+ connectTitle: (integrationToConnect: ConnectionName): string => `Connect ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'accounting integration'}`,
+
syncError: (integration?: ConnectionName): string => {
switch (integration) {
case CONST.POLICY.CONNECTIONS.NAME.QBO:
return "Can't connect to QuickBooks Online.";
case CONST.POLICY.CONNECTIONS.NAME.XERO:
return "Can't connect to Xero.";
+ case CONST.POLICY.CONNECTIONS.NAME.NETSUITE:
+ return "Can't connect to NetSuite.";
default: {
return "Can't connect to integration.";
}
@@ -2469,25 +2516,17 @@ export default {
[CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE]: 'Not imported',
[CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Imported as report fields',
},
- disconnectPrompt: (integrationToConnect?: ConnectionName, currentIntegration?: ConnectionName): string => {
- switch (integrationToConnect) {
- case CONST.POLICY.CONNECTIONS.NAME.QBO:
- return 'Are you sure you want to disconnect Xero to set up QuickBooks Online?';
- case CONST.POLICY.CONNECTIONS.NAME.XERO:
- return 'Are you sure you want to disconnect QuickBooks Online to set up Xero?';
- default: {
- switch (currentIntegration) {
- case CONST.POLICY.CONNECTIONS.NAME.QBO:
- return 'Are you sure you want to disconnect QuickBooks Online?';
- case CONST.POLICY.CONNECTIONS.NAME.XERO:
- return 'Are you sure you want to disconnect Xero?';
- default: {
- return 'Are you sure you want to disconnect this integration?';
- }
- }
- }
- }
+ disconnectPrompt: (currentIntegration?: ConnectionName): string => {
+ const integrationName =
+ currentIntegration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration]
+ ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration]
+ : 'this integration';
+ return `Are you sure you want to disconnect ${integrationName}?`;
},
+ connectPrompt: (integrationToConnect: ConnectionName): string =>
+ `Are you sure you want to connect ${
+ CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'this accounting integration'
+ }? This will remove any existing acounting connections.`,
enterCredentials: 'Enter your credentials',
connections: {
syncStageName: (stage: PolicyConnectionSyncStage) => {
@@ -2495,6 +2534,8 @@ export default {
case 'quickbooksOnlineImportCustomers':
return 'Importing customers';
case 'quickbooksOnlineImportEmployees':
+ case 'netSuiteSyncImportEmployees':
+ case 'intacctImportEmployees':
return 'Importing employees';
case 'quickbooksOnlineImportAccounts':
return 'Importing accounts';
@@ -2505,6 +2546,7 @@ export default {
case 'quickbooksOnlineImportProcessing':
return 'Processing imported data';
case 'quickbooksOnlineSyncBillPayments':
+ case 'intacctImportSyncBillPayments':
return 'Syncing reimbursed reports and bill payments';
case 'quickbooksOnlineSyncTaxCodes':
return 'Importing tax codes';
@@ -2519,6 +2561,8 @@ export default {
case 'quickbooksOnlineSyncTitle':
return 'Syncing QuickBooks Online data';
case 'quickbooksOnlineSyncLoadData':
+ case 'xeroSyncStep':
+ case 'intacctImportData':
return 'Loading data';
case 'quickbooksOnlineSyncApplyCategories':
return 'Updating categories';
@@ -2548,8 +2592,6 @@ export default {
return 'Checking Xero connection';
case 'xeroSyncTitle':
return 'Syncing Xero data';
- case 'xeroSyncStep':
- return 'Loading data';
case 'netSuiteSyncConnection':
return 'Initializing connection to NetSuite';
case 'netSuiteSyncCustomers':
@@ -2568,8 +2610,6 @@ export default {
return 'Syncing currencies';
case 'netSuiteSyncCategories':
return 'Syncing categories';
- case 'netSuiteSyncImportEmployees':
- return 'Importing employees';
case 'netSuiteSyncReportFields':
return 'Importing data as Expensify report fields';
case 'netSuiteSyncTags':
@@ -2580,6 +2620,10 @@ export default {
return 'Marking Expensify reports as reimbursed';
case 'netSuiteSyncExpensifyReimbursedReports':
return 'Marking NetSuite bills and invoices as paid';
+ case 'intacctCheckConnection':
+ return 'Checking Sage Intacct connection';
+ case 'intacctImportTitle':
+ return 'Importing Sage Intacct data';
default: {
return `Translation missing for stage: ${stage}`;
}
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 341df02d9b7f..4889c32715ef 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -49,6 +49,7 @@ import type {
PaySomeoneParams,
ReimbursementRateParams,
RemovedTheRequestParams,
+ RemoveMembersWarningPrompt,
RenamedRoomActionParams,
ReportArchiveReasonsClosedParams,
ReportArchiveReasonsMergedParams,
@@ -330,6 +331,8 @@ export default {
shared: 'Compartidos',
drafts: 'Borradores',
finished: 'Finalizados',
+ companyID: 'Empresa ID',
+ userID: 'Usuario ID',
disable: 'Deshabilitar',
},
connectionComplete: {
@@ -1414,6 +1417,12 @@ export default {
notYou: ({user}: NotYouParams) => `¿No eres ${user}?`,
},
onboarding: {
+ welcome: '¡Bienvenido!',
+ explanationModal: {
+ title: 'Bienvenido a Expensify',
+ description: 'Recibir pagos es tan fácil como mandar un mensaje',
+ secondaryDescription: 'Para volver a Expensify Classic, simplemente haz click en tu foto de perfil > Ir a Expensify Classic.',
+ },
welcomeVideo: {
title: 'Bienvenido a Expensify',
description: 'Una aplicación para gestionar todos tus gastos de empresa y personales en un chat. Pensada para tu empresa, tu equipo y tus amigos.',
@@ -2071,7 +2080,7 @@ export default {
exportInvoicesDescription: 'Usa esta cuenta al exportar facturas a QuickBooks Online.',
exportCompanyCardsDescription: 'Establece cómo se exportan las compras con tarjeta de empresa a QuickBooks Online.',
account: 'Cuenta',
- accountDescription: 'Elige dónde contabilizar las compensaciones de entradas a los asientos contables.',
+ accountDescription: 'Elige dónde contabilizar los asientos contables.',
vendor: 'Proveedor',
defaultVendor: 'Proveedor predeterminado',
defaultVendorDescription: 'Establece un proveedor predeterminado que se aplicará a todas las transacciones con tarjeta de crédito al momento de exportarlas.',
@@ -2079,7 +2088,6 @@ export default {
accountsPayableDescription: 'Elige dónde crear las facturas de proveedores.',
bankAccount: 'Cuenta bancaria',
bankAccountDescription: 'Elige desde dónde enviar los cheques.',
- optionBelow: 'Elija una opción a continuación:',
companyCardsLocationEnabledDescription:
'QuickBooks Online no permite lugares en las exportaciones de facturas de proveedores. Como tienes activadas los lugares en tu espacio de trabajo, esta opción de exportación no está disponible.',
exportPreferredExporterNote:
@@ -2101,7 +2109,7 @@ export default {
advancedConfig: {
advanced: 'Avanzado',
autoSync: 'Autosincronización',
- autoSyncDescription: 'Sincroniza QuickBooks Online y Expensify automáticamente, todos los días.',
+ autoSyncDescription: 'Expensify se sincronizará automáticamente con QuickBooks Online todos los días.',
inviteEmployees: 'Invitar empleados',
inviteEmployeesDescription: 'Importe los registros de los empleados de Quickbooks Online e invítelos a este espacio de trabajo.',
createEntities: 'Crear entidades automáticamente',
@@ -2111,8 +2119,8 @@ export default {
'Cada vez que se pague un informe utilizando Expensify ACH, se creará el correspondiente pago de la factura en la cuenta de Quickbooks Online indicadas a continuación.',
qboBillPaymentAccount: 'Cuenta de pago de las facturas de QuickBooks',
qboInvoiceCollectionAccount: 'Cuenta de cobro de las facturas QuickBooks',
- accountSelectDescription: 'Elige una cuenta bancaria para los reembolsos y crearemos el pago en QuickBooks Online.',
- invoiceAccountSelectorDescription: 'Una vez que una factura se marca como pagada en Expensify y se exporta a QuickBooks Online, aparecerá contra la cuenta a continuación.',
+ accountSelectDescription: 'Elige desde dónde pagar las facturas y crearemos el pago en QuickBooks Online.',
+ invoiceAccountSelectorDescription: 'Elige dónde recibir los pagos de facturas y crearemos el pago en QuickBooks Online.',
},
accounts: {
[CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD]: 'Tarjeta de débito',
@@ -2128,10 +2136,8 @@ export default {
[`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Description`]:
'Crearemos una factura de proveedor desglosada para cada informe de Expensify con la fecha del último gasto, y la añadiremos a la cuenta a continuación. Si este periodo está cerrado, lo contabilizaremos en el día 1 del siguiente periodo abierto.',
- [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD}AccountDescription`]:
- 'Las transacciones con tarjeta de débito se exportarán a la cuenta bancaria que aparece a continuación.',
- [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]:
- 'Las transacciones con tarjeta de crédito se exportarán a la cuenta bancaria que aparece a continuación.',
+ [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD}AccountDescription`]: 'Elige dónde exportar las transacciones con tarjeta de débito.',
+ [`${CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}AccountDescription`]: 'Elige dónde exportar las transacciones con tarjeta de crédito.',
[`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}AccountDescription`]: 'Selecciona el proveedor que se aplicará a todas las transacciones con tarjeta de crédito.',
[`${CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}Error`]:
@@ -2183,19 +2189,19 @@ export default {
advancedConfig: {
advanced: 'Avanzado',
autoSync: 'Autosincronización',
- autoSyncDescription: 'Sincroniza Xero y Expensify automáticamente, todos los días.',
+ autoSyncDescription: 'Expensify se sincronizará automáticamente con Xero todos los días.',
purchaseBillStatusTitle: 'Estado de la factura de compra',
reimbursedReports: 'Sincronizar informes reembolsados',
reimbursedReportsDescription:
'Cada vez que se pague un informe utilizando Expensify ACH, se creará el correspondiente pago de la factura en la cuenta de Xero indicadas a continuación.',
xeroBillPaymentAccount: 'Cuenta de pago de las facturas de Xero',
xeroInvoiceCollectionAccount: 'Cuenta de cobro de las facturas Xero',
- invoiceAccountSelectorDescription: 'Una vez que una factura se marca como pagada en Expensify y se exporta a Xero, aparecerá contra la cuenta a continuación.',
- xeroBillPaymentAccountDescription: 'Elige una cuenta bancaria para los reembolsos y crearemos el pago en Xero.',
+ xeroBillPaymentAccountDescription: 'Elige desde dónde pagar las facturas y crearemos el pago en Xero.',
+ invoiceAccountSelectorDescription: 'Elige dónde recibir los pagos de facturas y crearemos el pago en Xero.',
},
exportDate: {
- label: 'Fecha de exportación',
- description: 'Usa esta fecha al exportar facturas de compra a Xero.',
+ label: 'Fecha de la factura de compra',
+ description: 'Usa esta fecha al exportar el informe a Xero.',
values: {
[CONST.XERO_EXPORT_DATE.LAST_EXPENSE]: {
label: 'Fecha del último gasto',
@@ -2232,6 +2238,17 @@ export default {
noSubsidiariesFound: 'No se ha encontrado subsidiarias',
noSubsidiariesFoundDescription: 'Añade la subsidiaria en NetSuite y sincroniza de nuevo la conexión.',
},
+ intacct: {
+ sageIntacctSetup: 'Sage Intacct configuración',
+ prerequisitesTitle: 'Antes de conectar...',
+ downloadExpensifyPackage: 'Descargar el paquete Expensify para Sage Intacct',
+ followSteps: 'Siga los pasos de nuestras instrucciones Cómo: Instrucciones para conectarse a Sage Intacct',
+ enterCredentials: 'Introduzca sus credenciales de Sage Intacct',
+ createNewConnection: 'Crear una nueva conexión',
+ reuseExistingConnection: 'Reutilizar la conexión existente',
+ existingConnections: 'Conexiones existentes',
+ sageIntacctLastSync: (formattedDate: string) => `Sage Intacct - Última sincronización ${formattedDate}`,
+ },
type: {
free: 'Gratis',
control: 'Control',
@@ -2404,6 +2421,8 @@ export default {
people: {
genericFailureMessage: 'Se ha producido un error al intentar eliminar a un miembro del espacio de trabajo. Por favor, inténtalo más tarde.',
removeMembersPrompt: '¿Estás seguro de que deseas eliminar a estos miembros?',
+ removeMembersWarningPrompt: ({memberName, ownerName}: RemoveMembersWarningPrompt) =>
+ `${memberName} es un aprobador en este espacio de trabajo. Cuando lo elimine de este espacio de trabajo, los sustituiremos en el flujo de trabajo de aprobación por el propietario del espacio de trabajo, ${ownerName}`,
removeMembersTitle: 'Eliminar miembros',
removeMemberButtonTitle: 'Quitar del espacio de trabajo',
removeMemberGroupButtonTitle: 'Quitar del grupo',
@@ -2429,6 +2448,7 @@ export default {
qbo: 'Quickbooks Online',
xero: 'Xero',
netsuite: 'NetSuite',
+ intacct: 'Sage Intacct',
setup: 'Configurar',
lastSync: 'Recién sincronizado',
import: 'Importar',
@@ -2437,23 +2457,19 @@ export default {
other: 'Otras integraciones',
syncNow: 'Sincronizar ahora',
disconnect: 'Desconectar',
- disconnectTitle: (currentIntegration?: ConnectionName): string => {
- switch (currentIntegration) {
- case CONST.POLICY.CONNECTIONS.NAME.QBO:
- return 'Desconectar QuickBooks Online';
- case CONST.POLICY.CONNECTIONS.NAME.XERO:
- return 'Desconectar Xero';
- default: {
- return 'Desconectar integración';
- }
- }
+ disconnectTitle: (integration?: ConnectionName): string => {
+ const integrationName = integration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration] : 'integración';
+ return `Desconectar ${integrationName}`;
},
+ connectTitle: (integrationToConnect: ConnectionName): string => `Conectar ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'accounting integration'}`,
syncError: (integration?: ConnectionName): string => {
switch (integration) {
case CONST.POLICY.CONNECTIONS.NAME.QBO:
return 'No se puede conectar a QuickBooks Online.';
case CONST.POLICY.CONNECTIONS.NAME.XERO:
- return 'No se puede conectar a Xero';
+ return 'No se puede conectar a Xero.';
+ case CONST.POLICY.CONNECTIONS.NAME.NETSUITE:
+ return 'No se puede conectar a NetSuite.';
default: {
return 'No se ha podido conectar a la integración.';
}
@@ -2472,25 +2488,15 @@ export default {
[CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE]: 'No importado',
[CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]: 'Importado como campos de informe',
},
- disconnectPrompt: (integrationToConnect?: ConnectionName, currentIntegration?: ConnectionName): string => {
- switch (integrationToConnect) {
- case CONST.POLICY.CONNECTIONS.NAME.QBO:
- return '¿Estás seguro de que quieres desconectar Xero para configurar QuickBooks Online?';
- case CONST.POLICY.CONNECTIONS.NAME.XERO:
- return '¿Estás seguro de que quieres desconectar QuickBooks Online para configurar Xero?';
- default: {
- switch (currentIntegration) {
- case CONST.POLICY.CONNECTIONS.NAME.QBO:
- return '¿Estás seguro de que quieres desconectar QuickBooks Online?';
- case CONST.POLICY.CONNECTIONS.NAME.XERO:
- return '¿Estás seguro de que quieres desconectar Xero?';
- default: {
- return '¿Estás seguro de que quieres desconectar integración?';
- }
- }
- }
- }
+ disconnectPrompt: (currentIntegration?: ConnectionName): string => {
+ const integrationName =
+ currentIntegration && CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[currentIntegration] : 'integración';
+ return `¿Estás seguro de que quieres desconectar ${integrationName}?`;
},
+ connectPrompt: (integrationToConnect: ConnectionName): string =>
+ `¿Estás seguro de que quieres conectar a ${
+ CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? 'esta integración contable'
+ }? Esto eliminará cualquier conexión contable existente.`,
enterCredentials: 'Ingresa tus credenciales',
connections: {
syncStageName: (stage: PolicyConnectionSyncStage) => {
@@ -2498,6 +2504,8 @@ export default {
case 'quickbooksOnlineImportCustomers':
return 'Importando clientes';
case 'quickbooksOnlineImportEmployees':
+ case 'netSuiteSyncImportEmployees':
+ case 'intacctImportEmployees':
return 'Importando empleados';
case 'quickbooksOnlineImportAccounts':
return 'Importando cuentas';
@@ -2508,6 +2516,7 @@ export default {
case 'quickbooksOnlineImportProcessing':
return 'Procesando datos importados';
case 'quickbooksOnlineSyncBillPayments':
+ case 'intacctImportSyncBillPayments':
return 'Sincronizando reportes reembolsados y facturas pagadas';
case 'quickbooksOnlineSyncTaxCodes':
return 'Importando tipos de impuestos';
@@ -2522,6 +2531,8 @@ export default {
case 'quickbooksOnlineSyncTitle':
return 'Sincronizando datos desde QuickBooks Online';
case 'quickbooksOnlineSyncLoadData':
+ case 'xeroSyncStep':
+ case 'intacctImportData':
return 'Cargando datos';
case 'quickbooksOnlineSyncApplyCategories':
return 'Actualizando categorías';
@@ -2551,8 +2562,6 @@ export default {
return 'Comprobando la conexión a Xero';
case 'xeroSyncTitle':
return 'Sincronizando los datos de Xero';
- case 'xeroSyncStep':
- return 'Cargando datos';
case 'netSuiteSyncConnection':
return 'Iniciando conexión a NetSuite';
case 'netSuiteSyncCustomers':
@@ -2571,8 +2580,6 @@ export default {
return 'Sincronizando divisas';
case 'netSuiteSyncCategories':
return 'Sincronizando categorías';
- case 'netSuiteSyncImportEmployees':
- return 'Importando empleados';
case 'netSuiteSyncReportFields':
return 'Importando datos como campos de informe de Expensify';
case 'netSuiteSyncTags':
@@ -2583,6 +2590,12 @@ export default {
return 'Marcando informes de Expensify como reembolsados';
case 'netSuiteSyncExpensifyReimbursedReports':
return 'Marcando facturas y recibos de NetSuite como pagados';
+ case 'intacctCheckConnection':
+ return 'Comprobando la conexión a Sage Intacct';
+ case 'intacctImportDimensions':
+ return 'Importando dimensiones';
+ case 'intacctImportTitle':
+ return 'Importando datos desde Sage Intacct';
default: {
return `Translation missing for stage: ${stage}`;
}
@@ -2603,6 +2616,34 @@ export default {
benefit4: 'Límites personalizables',
addWorkEmail: 'Añadir correo electrónico de trabajo',
checkingDomain: '¡Un momento! Estamos todavía trabajando para habilitar tu Tarjeta Expensify. Vuelve aquí en unos minutos.',
+ issueCard: 'Emitir tarjeta',
+ issueNewCard: {
+ whoNeedsCard: '¿Quién necesita una tarjeta?',
+ findMember: 'Buscar miembro',
+ chooseCardType: 'Elegir un tipo de tarjeta',
+ physicalCard: 'Tarjeta física',
+ physicalCardDescription: 'Ideal para los consumidores habituales',
+ virtualCard: 'Tarjeta virtual',
+ virtualCardDescription: 'Instantáneo y flexible',
+ chooseLimitType: 'Elegir un tipo de límite',
+ smartLimit: 'Límite inteligente',
+ smartLimitDescription: 'Gasta hasta una determinada cantidad antes de requerir aprobación',
+ monthly: 'Mensual',
+ monthlyDescription: 'Gasta hasta una determinada cantidad al mes',
+ fixedAmount: 'Cantidad fija',
+ fixedAmountDescription: 'Gasta hasta una determinada cantidad una vez',
+ setLimit: 'Establecer un límite',
+ giveItName: 'Dale un nombre',
+ giveItNameInstruction: 'Hazlo lo suficientemente único como para distinguirlo de los demás. Los casos de uso específicos son aún mejores.',
+ cardName: 'Nombre de la tarjeta',
+ letsDoubleCheck: 'Vuelve a comprobar que todo parece correcto. ',
+ willBeReady: 'Esta tarjeta estará lista para su uso inmediato.',
+ cardholder: 'Titular de la tarjeta',
+ cardType: 'Tipo de tarjeta',
+ limit: 'Limite',
+ limitType: 'Tipo de limite',
+ name: 'Nombre',
+ },
},
reimburse: {
captureReceipts: 'Captura recibos',
diff --git a/src/languages/types.ts b/src/languages/types.ts
index de9b1d2dadeb..c38fb4aadae5 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -298,6 +298,11 @@ type DistanceRateOperationsParams = {count: number};
type ReimbursementRateParams = {unit: Unit};
+type RemoveMembersWarningPrompt = {
+ memberName: string;
+ ownerName: string;
+};
+
export type {
AddressLineParams,
AdminCanceledRequestParams,
@@ -402,4 +407,5 @@ export type {
WelcomeNoteParams,
WelcomeToRoomParams,
ZipCodeExampleFormatParams,
+ RemoveMembersWarningPrompt,
};
diff --git a/src/libs/API/parameters/ConnectPolicyToSageIntacctParams.ts b/src/libs/API/parameters/ConnectPolicyToSageIntacctParams.ts
new file mode 100644
index 000000000000..3b5cfc973e4d
--- /dev/null
+++ b/src/libs/API/parameters/ConnectPolicyToSageIntacctParams.ts
@@ -0,0 +1,8 @@
+type ConnectPolicyToSageIntacctParams = {
+ policyID: string;
+ intacctCompanyID: string;
+ intacctUserID: string;
+ intacctPassword: string;
+};
+
+export default ConnectPolicyToSageIntacctParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index da4f1216016e..7cd9454bbcff 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -122,6 +122,7 @@ export type {default as AddMembersToWorkspaceParams} from './AddMembersToWorkspa
export type {default as DeleteMembersFromWorkspaceParams} from './DeleteMembersFromWorkspaceParams';
export type {default as OpenWorkspaceParams} from './OpenWorkspaceParams';
export type {default as OpenWorkspaceViewParams} from './OpenWorkspaceViewParams';
+export type {default as ConnectPolicyToSageIntacctParams} from './ConnectPolicyToSageIntacctParams';
export type {default as OpenWorkspaceReimburseViewParams} from './OpenWorkspaceReimburseViewParams';
export type {default as OpenWorkspaceInvitePageParams} from './OpenWorkspaceInvitePageParams';
export type {default as OpenWorkspaceMembersPageParams} from './OpenWorkspaceMembersPageParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index e3115a624680..644c77630c14 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -1,5 +1,6 @@
import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
+import type {EmptyObject} from '@src/types/utils/EmptyObject';
import type * as Parameters from './parameters';
import type SignInUserParams from './parameters/SignInUserParams';
import type UpdateBeneficialOwnersForBankAccountParams from './parameters/UpdateBeneficialOwnersForBankAccountParams';
@@ -141,6 +142,7 @@ const WRITE_COMMANDS = {
REOPEN_TASK: 'ReopenTask',
COMPLETE_TASK: 'CompleteTask',
COMPLETE_GUIDED_SETUP: 'CompleteGuidedSetup',
+ COMPLETE_HYBRID_APP_ONBOARDING: 'CompleteHybridAppOnboarding',
SET_NAME_VALUE_PAIR: 'SetNameValuePair',
SET_REPORT_FIELD: 'Report_SetFields',
DELETE_REPORT_FIELD: 'RemoveReportField',
@@ -227,6 +229,7 @@ const WRITE_COMMANDS = {
UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY: 'UpdateSubscriptionAddNewUsersAutomatically',
UPDATE_SUBSCRIPTION_SIZE: 'UpdateSubscriptionSize',
UPDATE_NETSUITE_SUBSIDIARY: 'UpdateNetSuiteSubsidiary',
+ CONNECT_POLICY_TO_SAGE_INTACCT: 'ConnectPolicyToSageIntacct',
} as const;
type WriteCommand = ValueOf;
@@ -361,6 +364,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.REOPEN_TASK]: Parameters.ReopenTaskParams;
[WRITE_COMMANDS.COMPLETE_TASK]: Parameters.CompleteTaskParams;
[WRITE_COMMANDS.COMPLETE_GUIDED_SETUP]: Parameters.CompleteGuidedSetupParams;
+ [WRITE_COMMANDS.COMPLETE_HYBRID_APP_ONBOARDING]: EmptyObject;
[WRITE_COMMANDS.SET_NAME_VALUE_PAIR]: Parameters.SetNameValuePairParams;
[WRITE_COMMANDS.SET_REPORT_FIELD]: Parameters.SetReportFieldParams;
[WRITE_COMMANDS.SET_REPORT_NAME]: Parameters.SetReportNameParams;
@@ -455,6 +459,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_SUBSCRIPTION_AUTO_RENEW]: Parameters.UpdateSubscriptionAutoRenewParams;
[WRITE_COMMANDS.UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY]: Parameters.UpdateSubscriptionAddNewUsersAutomaticallyParams;
[WRITE_COMMANDS.UPDATE_SUBSCRIPTION_SIZE]: Parameters.UpdateSubscriptionSizeParams;
+ [WRITE_COMMANDS.CONNECT_POLICY_TO_SAGE_INTACCT]: Parameters.ConnectPolicyToSageIntacctParams;
// Netsuite parameters
[WRITE_COMMANDS.UPDATE_NETSUITE_SUBSIDIARY]: Parameters.UpdateNetSuiteSubsidiaryParams;
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 467c4b27bc11..f538e5e719e2 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -137,7 +137,15 @@ function getLocalDateFromDatetime(locale: Locale, datetime?: string, currentSele
}
return res;
}
- const parsedDatetime = new Date(`${datetime}Z`);
+ let parsedDatetime;
+ try {
+ // in some cases we cannot add 'Z' to the date string
+ parsedDatetime = new Date(`${datetime}Z`);
+ parsedDatetime.toISOString(); // we need to call toISOString because it throws RangeError in case of an invalid date
+ } catch (e) {
+ parsedDatetime = new Date(datetime);
+ }
+
return utcToZonedTime(parsedDatetime, currentSelectedTimezone);
}
diff --git a/src/libs/E2E/reactNativeLaunchingTest.ts b/src/libs/E2E/reactNativeLaunchingTest.ts
index 46922091497c..f952998f0aad 100644
--- a/src/libs/E2E/reactNativeLaunchingTest.ts
+++ b/src/libs/E2E/reactNativeLaunchingTest.ts
@@ -66,7 +66,7 @@ E2EClient.getTestConfig()
branch: Config.E2E_BRANCH,
name: config.name,
error: `Test '${config.name}' not found`,
- isCritical: false,
+ isCritical: false,
});
}
diff --git a/src/libs/E2E/tests/appStartTimeTest.e2e.ts b/src/libs/E2E/tests/appStartTimeTest.e2e.ts
index 321fc3773d51..188dd65c85e9 100644
--- a/src/libs/E2E/tests/appStartTimeTest.e2e.ts
+++ b/src/libs/E2E/tests/appStartTimeTest.e2e.ts
@@ -26,7 +26,8 @@ const test = () => {
E2EClient.submitTestResults({
branch: Config.E2E_BRANCH,
name: `App start ${metric.name}`,
- duration: metric.duration,
+ metric: metric.duration,
+ unit: 'ms',
}),
),
)
diff --git a/src/libs/E2E/tests/chatOpeningTest.e2e.ts b/src/libs/E2E/tests/chatOpeningTest.e2e.ts
index 8e43c4ece564..8e2a0a81da7d 100644
--- a/src/libs/E2E/tests/chatOpeningTest.e2e.ts
+++ b/src/libs/E2E/tests/chatOpeningTest.e2e.ts
@@ -49,7 +49,8 @@ const test = (config: NativeConfig) => {
E2EClient.submitTestResults({
branch: Config.E2E_BRANCH,
name: 'Chat opening',
- duration: entry.duration,
+ metric: entry.duration,
+ unit: 'ms',
})
.then(() => {
console.debug('[E2E] Done with chat opening, exiting…');
@@ -64,7 +65,8 @@ const test = (config: NativeConfig) => {
E2EClient.submitTestResults({
branch: Config.E2E_BRANCH,
name: 'Chat TTI',
- duration: entry.duration,
+ metric: entry.duration,
+ unit: 'ms',
})
.then(() => {
console.debug('[E2E] Done with chat TTI tracking, exiting…');
diff --git a/src/libs/E2E/tests/linkingTest.e2e.ts b/src/libs/E2E/tests/linkingTest.e2e.ts
index a3449ce5010b..7e6c7ea697ba 100644
--- a/src/libs/E2E/tests/linkingTest.e2e.ts
+++ b/src/libs/E2E/tests/linkingTest.e2e.ts
@@ -75,7 +75,8 @@ const test = (config: NativeConfig) => {
E2EClient.submitTestResults({
branch: Config.E2E_BRANCH,
name: 'Comment linking',
- duration: entry.duration,
+ metric: entry.duration,
+ unit: 'ms',
});
switchReportResolve();
diff --git a/src/libs/E2E/tests/openChatFinderPageTest.e2e.ts b/src/libs/E2E/tests/openChatFinderPageTest.e2e.ts
index 4ac7995b914f..c6aead2d5336 100644
--- a/src/libs/E2E/tests/openChatFinderPageTest.e2e.ts
+++ b/src/libs/E2E/tests/openChatFinderPageTest.e2e.ts
@@ -44,7 +44,8 @@ const test = () => {
E2EClient.submitTestResults({
branch: Config.E2E_BRANCH,
name: 'Open Chat Finder Page TTI',
- duration: entry.duration,
+ metric: entry.duration,
+ unit: 'ms',
})
.then(() => {
openSearchPageResolve();
@@ -59,7 +60,8 @@ const test = () => {
E2EClient.submitTestResults({
branch: Config.E2E_BRANCH,
name: 'Load Search Options',
- duration: entry.duration,
+ metric: entry.duration,
+ unit: 'ms',
})
.then(() => {
loadSearchOptionsResolve();
diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.ts b/src/libs/E2E/tests/reportTypingTest.e2e.ts
index 817bda941611..9624d7ab992b 100644
--- a/src/libs/E2E/tests/reportTypingTest.e2e.ts
+++ b/src/libs/E2E/tests/reportTypingTest.e2e.ts
@@ -53,7 +53,8 @@ const test = (config: NativeConfig) => {
E2EClient.submitTestResults({
branch: Config.E2E_BRANCH,
name: 'Composer typing rerender count',
- renderCount: rerenderCount,
+ metric: rerenderCount,
+ unit: 'renders',
}).then(E2EClient.submitTestDone);
}, 3000);
})
diff --git a/src/libs/E2E/types.ts b/src/libs/E2E/types.ts
index fdbc01872cb3..8640c76e631e 100644
--- a/src/libs/E2E/types.ts
+++ b/src/libs/E2E/types.ts
@@ -33,6 +33,8 @@ type TestModule = {default: Test};
type Tests = Record, Test>;
+type Unit = 'ms' | 'MB' | '%' | 'renders' | 'FPS';
+
type TestResult = {
/** Name of the test */
name: string;
@@ -40,8 +42,8 @@ type TestResult = {
/** The branch where test were running */
branch?: string;
- /** Duration in milliseconds */
- duration?: number;
+ /** The numeric value of the measurement */
+ metric?: number;
/** Optional, if set indicates that the test run failed and has no valid results. */
error?: string;
@@ -52,8 +54,8 @@ type TestResult = {
*/
isCritical?: boolean;
- /** Render count */
- renderCount?: number;
+ /** The unit of the measurement */
+ unit?: Unit;
};
-export type {SigninParams, IsE2ETestSession, NetworkCacheMap, NetworkCacheEntry, TestConfig, TestResult, TestModule, Tests};
+export type {SigninParams, IsE2ETestSession, NetworkCacheMap, NetworkCacheEntry, TestConfig, TestResult, TestModule, Tests, Unit};
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index c9773f104393..4bf7e208590a 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -4,6 +4,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import Onyx, {withOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import OptionsListContextProvider from '@components/OptionListContextProvider';
+import useLastAccessedReportID from '@hooks/useLastAccessedReportID';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -48,6 +49,7 @@ import createCustomStackNavigator from './createCustomStackNavigator';
import defaultScreenOptions from './defaultScreenOptions';
import getRootNavigatorScreenOptions from './getRootNavigatorScreenOptions';
import BottomTabNavigator from './Navigators/BottomTabNavigator';
+import ExplanationModalNavigator from './Navigators/ExplanationModalNavigator';
import FeatureTrainingModalNavigator from './Navigators/FeatureTrainingModalNavigator';
import FullScreenNavigator from './Navigators/FullScreenNavigator';
import LeftModalNavigator from './Navigators/LeftModalNavigator';
@@ -76,16 +78,21 @@ const loadReportAvatar = () => require('../../../pages/Rep
const loadReceiptView = () => require('../../../pages/TransactionReceiptPage').default;
const loadWorkspaceJoinUser = () => require('@pages/workspace/WorkspaceJoinUserPage').default;
-function getCentralPaneScreenInitialParams(screenName: CentralPaneName): Partial> {
+function shouldOpenOnAdminRoom() {
const url = getCurrentUrl();
- const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined;
+ return url ? new URL(url).searchParams.get('openOnAdminRoom') === 'true' : false;
+}
+function getCentralPaneScreenInitialParams(screenName: CentralPaneName, lastAccessedReportID?: string): Partial> {
if (screenName === SCREENS.SEARCH.CENTRAL_PANE) {
return {sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, sortOrder: CONST.SEARCH.SORT_ORDER.DESC};
}
- if (screenName === SCREENS.REPORT && openOnAdminRoom === 'true') {
- return {openOnAdminRoom: true};
+ if (screenName === SCREENS.REPORT) {
+ return {
+ openOnAdminRoom: shouldOpenOnAdminRoom() ? true : undefined,
+ reportID: lastAccessedReportID,
+ };
}
return undefined;
@@ -191,6 +198,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
const StyleUtils = useStyleUtils();
const {isSmallScreenWidth} = useWindowDimensions();
const {shouldUseNarrowLayout} = useOnboardingLayout();
+ const lastAccessedReportID = useLastAccessedReportID(shouldOpenOnAdminRoom());
const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth, styles, StyleUtils);
const onboardingModalScreenOptions = useMemo(() => screenOptions.onboardingModalNavigator(shouldUseNarrowLayout), [screenOptions, shouldUseNarrowLayout]);
const onboardingScreenOptions = useMemo(
@@ -416,6 +424,11 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
options={screenOptions.fullScreen}
component={DesktopSignInRedirectPage}
/>
+
diff --git a/src/libs/Navigation/AppNavigator/CENTRAL_PANE_SCREENS.tsx b/src/libs/Navigation/AppNavigator/CENTRAL_PANE_SCREENS.tsx
index 3300fa323181..5bbe2046040a 100644
--- a/src/libs/Navigation/AppNavigator/CENTRAL_PANE_SCREENS.tsx
+++ b/src/libs/Navigation/AppNavigator/CENTRAL_PANE_SCREENS.tsx
@@ -6,17 +6,17 @@ import type ReactComponentModule from '@src/types/utils/ReactComponentModule';
type Screens = Partial React.ComponentType>>;
const CENTRAL_PANE_SCREENS = {
- [SCREENS.SETTINGS.WORKSPACES]: () => withPrepareCentralPaneScreen(require('../../../pages/workspace/WorkspacesListPage').default),
- [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => withPrepareCentralPaneScreen(require('../../../pages/settings/Preferences/PreferencesPage').default),
- [SCREENS.SETTINGS.SECURITY]: () => withPrepareCentralPaneScreen(require('../../../pages/settings/Security/SecuritySettingsPage').default),
- [SCREENS.SETTINGS.PROFILE.ROOT]: () => withPrepareCentralPaneScreen(require('../../../pages/settings/Profile/ProfilePage').default),
- [SCREENS.SETTINGS.WALLET.ROOT]: () => withPrepareCentralPaneScreen(require('../../../pages/settings/Wallet/WalletPage').default),
- [SCREENS.SETTINGS.ABOUT]: () => withPrepareCentralPaneScreen(require('../../../pages/settings/AboutPage/AboutPage').default),
- [SCREENS.SETTINGS.TROUBLESHOOT]: () => withPrepareCentralPaneScreen(require('../../../pages/settings/Troubleshoot/TroubleshootPage').default),
- [SCREENS.SETTINGS.SAVE_THE_WORLD]: () => withPrepareCentralPaneScreen(require('../../../pages/TeachersUnite/SaveTheWorldPage').default),
- [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: () => withPrepareCentralPaneScreen(require('../../../pages/settings/Subscription/SubscriptionSettingsPage').default),
- [SCREENS.SEARCH.CENTRAL_PANE]: () => withPrepareCentralPaneScreen(require('../../../pages/Search/SearchPage').default),
- [SCREENS.REPORT]: () => withPrepareCentralPaneScreen(require('./ReportScreenWrapper').default),
+ [SCREENS.SETTINGS.WORKSPACES]: withPrepareCentralPaneScreen(() => require('../../../pages/workspace/WorkspacesListPage').default),
+ [SCREENS.SETTINGS.PREFERENCES.ROOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Preferences/PreferencesPage').default),
+ [SCREENS.SETTINGS.SECURITY]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Security/SecuritySettingsPage').default),
+ [SCREENS.SETTINGS.PROFILE.ROOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Profile/ProfilePage').default),
+ [SCREENS.SETTINGS.WALLET.ROOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Wallet/WalletPage').default),
+ [SCREENS.SETTINGS.ABOUT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/AboutPage/AboutPage').default),
+ [SCREENS.SETTINGS.TROUBLESHOOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Troubleshoot/TroubleshootPage').default),
+ [SCREENS.SETTINGS.SAVE_THE_WORLD]: withPrepareCentralPaneScreen(() => require('../../../pages/TeachersUnite/SaveTheWorldPage').default),
+ [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Subscription/SubscriptionSettingsPage').default),
+ [SCREENS.SEARCH.CENTRAL_PANE]: withPrepareCentralPaneScreen(() => require('../../../pages/Search/SearchPage').default),
+ [SCREENS.REPORT]: withPrepareCentralPaneScreen(() => require('../../../pages/home/ReportScreen').default),
} satisfies Screens;
export default CENTRAL_PANE_SCREENS;
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 6e9782defe4f..81ecb85299da 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -319,12 +319,17 @@ const SettingsModalStackNavigator = createModalStackNavigator
require('../../../../pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: () => require('../../../../pages/workspace/accounting/netsuite/NetSuiteSubsidiarySelector').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES]: () => require('../../../../pages/workspace/accounting/intacct/IntacctPrerequisitesPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: () =>
+ require('../../../../pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS]: () => require('../../../../pages/workspace/accounting/intacct/ExistingConnectionsPage').default,
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default,
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default,
[SCREENS.WORKSPACE.TAX_EDIT]: () => require('../../../../pages/workspace/taxes/WorkspaceEditTaxPage').default,
[SCREENS.WORKSPACE.TAX_NAME]: () => require('../../../../pages/workspace/taxes/NamePage').default,
[SCREENS.WORKSPACE.TAX_VALUE]: () => require('../../../../pages/workspace/taxes/ValuePage').default,
[SCREENS.WORKSPACE.TAX_CREATE]: () => require('../../../../pages/workspace/taxes/WorkspaceCreateTaxPage').default,
+ [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW]: () => require('../../../../pages/workspace/card/issueNew/IssueNewCardPage').default,
[SCREENS.SETTINGS.SAVE_THE_WORLD]: () => require('../../../../pages/TeachersUnite/SaveTheWorldPage').default,
[SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_PAYMENT_CURRENCY]: () => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default,
[SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY]: () => require('../../../../pages/settings/Subscription/PaymentCard/ChangeBillingCurrency').default,
diff --git a/src/libs/Navigation/AppNavigator/Navigators/ExplanationModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/ExplanationModalNavigator.tsx
new file mode 100644
index 000000000000..f4136bb8783a
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/ExplanationModalNavigator.tsx
@@ -0,0 +1,28 @@
+import {createStackNavigator} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import NoDropZone from '@components/DragAndDrop/NoDropZone';
+import ExplanationModal from '@components/ExplanationModal';
+import type {ExplanationModalNavigatorParamList} from '@libs/Navigation/types';
+import SCREENS from '@src/SCREENS';
+
+const Stack = createStackNavigator();
+
+function ExplanationModalNavigator() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+ExplanationModalNavigator.displayName = 'ExplanationModalNavigator';
+
+export default ExplanationModalNavigator;
diff --git a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts
deleted file mode 100644
index 5306f6b55054..000000000000
--- a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import {useEffect} from 'react';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import {useOnyx} from 'react-native-onyx';
-import useActiveWorkspace from '@hooks/useActiveWorkspace';
-import usePermissions from '@hooks/usePermissions';
-import {getPolicyEmployeeListByIdWithoutCurrentUser} from '@libs/PolicyUtils';
-import * as ReportUtils from '@libs/ReportUtils';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {Policy, Report, ReportMetadata} from '@src/types/onyx';
-import type {ReportScreenWrapperProps} from './ReportScreenWrapper';
-
-type ReportScreenIDSetterProps = ReportScreenWrapperProps;
-
-/**
- * Get the most recently accessed report for the user
- */
-const getLastAccessedReportID = (
- reports: OnyxCollection,
- ignoreDefaultRooms: boolean,
- policies: OnyxCollection,
- isFirstTimeNewExpensifyUser: OnyxEntry,
- openOnAdminRoom: boolean,
- reportMetadata: OnyxCollection,
- policyID?: string,
- policyMemberAccountIDs?: number[],
-): string | undefined => {
- const lastReport = ReportUtils.findLastAccessedReport(
- reports,
- ignoreDefaultRooms,
- policies,
- !!isFirstTimeNewExpensifyUser,
- openOnAdminRoom,
- reportMetadata,
- policyID,
- policyMemberAccountIDs,
- );
- return lastReport?.reportID;
-};
-
-// This wrapper is responsible for opening the last accessed report if there is no reportID specified in the route params
-function ReportScreenIDSetter({route, navigation}: ReportScreenIDSetterProps) {
- const {canUseDefaultRooms} = usePermissions();
- const {activeWorkspaceID} = useActiveWorkspace();
-
- const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {allowStaleData: true});
- const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true});
- const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
- const [isFirstTimeNewExpensifyUser] = useOnyx(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, {initialValue: false});
- const [reportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA, {allowStaleData: true});
- const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID});
-
- useEffect(() => {
- // Don't update if there is a reportID in the params already
- if (route?.params?.reportID) {
- const reportActionID = route?.params?.reportActionID;
- const regexValidReportActionID = new RegExp(/^\d*$/);
- if (reportActionID && !regexValidReportActionID.test(reportActionID)) {
- navigation.setParams({reportActionID: ''});
- }
- return;
- }
-
- const policyMemberAccountIDs = getPolicyEmployeeListByIdWithoutCurrentUser(policies, activeWorkspaceID, accountID);
-
- // If there is no reportID in route, try to find last accessed and use it for setParams
- const reportID = getLastAccessedReportID(
- reports,
- !canUseDefaultRooms,
- policies,
- isFirstTimeNewExpensifyUser,
- !!reports?.params?.openOnAdminRoom,
- reportMetadata,
- activeWorkspaceID,
- policyMemberAccountIDs,
- );
-
- // It's possible that reports aren't fully loaded yet
- // in that case the reportID is undefined
- if (reportID) {
- navigation.setParams({reportID: String(reportID)});
- }
- }, [route, navigation, reports, canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, reportMetadata, activeWorkspaceID, personalDetails, accountID]);
-
- // The ReportScreen without the reportID set will display a skeleton
- // until the reportID is loaded and set in the route param
- return null;
-}
-
-ReportScreenIDSetter.displayName = 'ReportScreenIDSetter';
-
-export default ReportScreenIDSetter;
diff --git a/src/libs/Navigation/AppNavigator/ReportScreenWrapper.tsx b/src/libs/Navigation/AppNavigator/ReportScreenWrapper.tsx
deleted file mode 100644
index 692bbf8edde2..000000000000
--- a/src/libs/Navigation/AppNavigator/ReportScreenWrapper.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import type {StackScreenProps} from '@react-navigation/stack';
-import React from 'react';
-import type {AuthScreensParamList} from '@navigation/types';
-import ReportScreen from '@pages/home/ReportScreen';
-import type SCREENS from '@src/SCREENS';
-import ReportScreenIDSetter from './ReportScreenIDSetter';
-
-type ReportScreenWrapperProps = StackScreenProps;
-
-function ReportScreenWrapper({route, navigation}: ReportScreenWrapperProps) {
- // The ReportScreen without the reportID set will display a skeleton
- // until the reportID is loaded and set in the route param
- return (
- <>
-
-
- >
- );
-}
-
-ReportScreenWrapper.displayName = 'ReportScreenWrapper';
-
-export default ReportScreenWrapper;
-export type {ReportScreenWrapperProps};
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx
index 472d2c7d6d29..4194fd6c4c3b 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx
@@ -1,6 +1,6 @@
import {useNavigation, useNavigationState} from '@react-navigation/native';
import React, {memo, useCallback, useEffect} from 'react';
-import {View} from 'react-native';
+import {NativeModules, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import Icon from '@components/Icon';
@@ -52,6 +52,11 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps
return;
}
+ // HybridApp has own entry point when we decide whether to display onboarding and explanation modal.
+ if (NativeModules.HybridAppModule) {
+ return;
+ }
+
Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoadingApp]);
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index eaaf5eae12c0..dd3a2890d0ec 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -1,6 +1,7 @@
import type {NavigationState} from '@react-navigation/native';
import {DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native';
import React, {useContext, useEffect, useMemo, useRef} from 'react';
+import HybridAppMiddleware from '@components/HybridAppMiddleware';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useCurrentReportID from '@hooks/useCurrentReportID';
@@ -152,7 +153,10 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
enabled: false,
}}
>
-
+ {/* HybridAppMiddleware needs to have access to navigation ref and SplashScreenHidden context */}
+
+
+
);
}
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index b2a69d3aeb39..1adb302bac15 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -56,6 +56,9 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR,
SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT_BANK_ACCOUNT_SELECT,
SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR,
+ SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES,
+ SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS,
+ SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS,
],
[SCREENS.WORKSPACE.TAXES]: [
SCREENS.WORKSPACE.TAXES_SETTINGS,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index c66472abb3b4..a67b90beb04e 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -92,6 +92,14 @@ const config: LinkingOptions['config'] = {
},
},
},
+ [NAVIGATORS.EXPLANATION_MODAL_NAVIGATOR]: {
+ screens: {
+ [SCREENS.EXPLANATION_MODAL.ROOT]: {
+ path: ROUTES.EXPLANATION_MODAL_ROOT,
+ exact: true,
+ },
+ },
+ },
[NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: {
path: ROUTES.ONBOARDING_ROOT,
initialRouteName: SCREENS.ONBOARDING.PURPOSE,
@@ -346,6 +354,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT_PREFERRED_EXPORTER_SELECT]: {path: ROUTES.POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_XERO_BILL_PAYMENT_ACCOUNT_SELECTOR.route},
[SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.route},
+ [SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.route},
[SCREENS.WORKSPACE.DESCRIPTION]: {
path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route,
},
@@ -358,6 +369,13 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.SHARE]: {
path: ROUTES.WORKSPACE_PROFILE_SHARE.route,
},
+ // TODO: uncomment after development
+ // [SCREENS.WORKSPACE.EXPENSIFY_CARD]: {
+ // path: ROUTES.WORKSPACE_EXPENSIFY_CARD,
+ // },
+ [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW]: {
+ path: ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW,
+ },
[SCREENS.WORKSPACE.RATE_AND_UNIT]: {
path: ROUTES.WORKSPACE_RATE_AND_UNIT.route,
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 6c4e03aa2018..7e50712d3bf5 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -387,6 +387,15 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: {
policyID: string;
};
@@ -872,6 +881,10 @@ type WelcomeVideoModalNavigatorParamList = {
[SCREENS.WELCOME_VIDEO.ROOT]: undefined;
};
+type ExplanationModalNavigatorParamList = {
+ [SCREENS.EXPLANATION_MODAL.ROOT]: undefined;
+};
+
type BottomTabNavigatorParamList = {
[SCREENS.HOME]: {policyID?: string};
[SCREENS.SEARCH.BOTTOM_TAB]: {
@@ -944,6 +957,7 @@ type AuthScreensParamList = CentralPaneScreensParamList &
[NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.FEATURE_TRANING_MODAL_NAVIGATOR]: NavigatorScreenParams;
[NAVIGATORS.WELCOME_VIDEO_MODAL_NAVIGATOR]: NavigatorScreenParams;
+ [NAVIGATORS.EXPLANATION_MODAL_NAVIGATOR]: NavigatorScreenParams;
[SCREENS.DESKTOP_SIGN_IN_REDIRECT]: undefined;
[SCREENS.TRANSACTION_RECEIPT]: {
reportID: string;
@@ -990,6 +1004,7 @@ export type {
DetailsNavigatorParamList,
EditRequestNavigatorParamList,
EnablePaymentsNavigatorParamList,
+ ExplanationModalNavigatorParamList,
FlagCommentNavigatorParamList,
FullScreenName,
FullScreenNavigatorParamList,
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 24471b7f0140..16cd11fab12d 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -297,7 +297,7 @@ Onyx.connect({
const transactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(reportID, actions[reportActions[0]]);
if (transactionThreadReportID) {
const transactionThreadReportActionsArray = Object.values(actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {});
- sortedReportActions = ReportActionUtils.getCombinedReportActions(reportActionsArray, transactionThreadReportID, transactionThreadReportActionsArray, reportID);
+ sortedReportActions = ReportActionUtils.getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID);
}
lastReportActions[reportID] = sortedReportActions[0];
@@ -1740,6 +1740,26 @@ function getUserToInviteOption({
return userToInvite;
}
+/**
+ * Check whether report has violations
+ */
+function shouldShowViolations(report: Report, betas: OnyxEntry, transactionViolations: OnyxCollection) {
+ if (!Permissions.canUseViolations(betas)) {
+ return false;
+ }
+ const {parentReportID, parentReportActionID} = report ?? {};
+ const canGetParentReport = parentReportID && parentReportActionID && allReportActions;
+ if (!canGetParentReport) {
+ return false;
+ }
+ const parentReportActions = allReportActions ? allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? {} : {};
+ const parentReportAction = parentReportActions[parentReportActionID] ?? null;
+ if (!parentReportAction) {
+ return false;
+ }
+ return ReportUtils.shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction);
+}
+
/**
* filter options based on specific conditions
*/
@@ -1847,13 +1867,7 @@ function getOptions(
// Filter out all the reports that shouldn't be displayed
const filteredReportOptions = options.reports.filter((option) => {
const report = option.item;
-
- const {parentReportID, parentReportActionID} = report ?? {};
- const canGetParentReport = parentReportID && parentReportActionID && allReportActions;
- const parentReportActions = allReportActions ? allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? {} : {};
- const parentReportAction = canGetParentReport ? parentReportActions[parentReportActionID] ?? null : null;
- const doesReportHaveViolations =
- (betas?.includes(CONST.BETAS.VIOLATIONS) && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction)) ?? false;
+ const doesReportHaveViolations = shouldShowViolations(report, betas, transactionViolations);
return ReportUtils.shouldReportBeInOptionList({
report,
@@ -2581,6 +2595,7 @@ export {
getFirstKeyForList,
canCreateOptimisticPersonalDetailOption,
getUserToInviteOption,
+ shouldShowViolations,
};
export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index da4323a077b8..37068a9a133f 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -7,7 +7,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagList, PolicyTags, TaxRate} from '@src/types/onyx';
-import type {CustomUnit, PolicyFeatureName, Rate, Tenant} from '@src/types/onyx/Policy';
+import type {ConnectionLastSync, Connections, CustomUnit, NetSuiteConnection, PolicyFeatureName, Rate, Tenant} from '@src/types/onyx/Policy';
import type PolicyEmployee from '@src/types/onyx/PolicyEmployee';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import Navigation from './Navigation/Navigation';
@@ -21,6 +21,11 @@ type WorkspaceDetails = {
name: string;
};
+type ConnectionWithLastSyncData = {
+ /** State of the last synchronization */
+ lastSync?: ConnectionLastSync;
+};
+
let allPolicies: OnyxCollection;
Onyx.connect({
@@ -466,6 +471,16 @@ function getXeroBankAccountsWithDefaultSelect(policy: Policy | undefined, select
}));
}
+function getIntegrationLastSuccessfulDate(connection?: Connections[keyof Connections]) {
+ if (!connection) {
+ return undefined;
+ }
+ if ((connection as NetSuiteConnection)?.lastSyncDate) {
+ return (connection as NetSuiteConnection)?.lastSyncDate;
+ }
+ return (connection as ConnectionWithLastSyncData)?.lastSync?.successfulDate;
+}
+
/**
* Sort the workspaces by their name, while keeping the selected one at the beginning.
* @param workspace1 Details of the first workspace to be compared.
@@ -557,6 +572,7 @@ export {
sortWorkspacesBySelected,
removePendingFieldsFromCustomUnit,
navigateWhenEnableFeature,
+ getIntegrationLastSuccessfulDate,
};
export type {MemberEmailsToAccountIDs};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 2132b97ef555..83dfeaa08fa0 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -383,11 +383,14 @@ function getCombinedReportActions(
transactionThreadReportActions: ReportAction[],
reportID?: string,
): ReportAction[] {
- if (_.isEmpty(transactionThreadReportID)) {
+ const isSentMoneyReport = reportActions.some((action) => isSentMoneyReportAction(action));
+
+ // We don't want to combine report actions of transaction thread in iou report of send money request because we display the transaction report of send money request as a normal thread
+ if (_.isEmpty(transactionThreadReportID) || isSentMoneyReport) {
return reportActions;
}
- // Filter out the created action from the transaction thread report actions, since we already have the parent report's created action in `reportActions`
+ // Filter out request money actions because we don't want to show any preview actions for one transaction reports
const filteredTransactionThreadReportActions = transactionThreadReportActions?.filter((action) => action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED);
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
const isSelfDM = report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM;
@@ -398,9 +401,9 @@ function getCombinedReportActions(
}
const actionType = getOriginalMessage(action)?.type ?? '';
if (isSelfDM) {
- return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && !isSentMoneyReportAction(action);
+ return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE;
}
- return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && actionType !== CONST.IOU.REPORT_ACTION_TYPE.TRACK && !isSentMoneyReportAction(action);
+ return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && actionType !== CONST.IOU.REPORT_ACTION_TYPE.TRACK;
});
return getSortedReportActions(filteredReportActions, true);
@@ -1375,6 +1378,14 @@ function getIOUActionForReportID(reportID: string, transactionID: string): OnyxE
return action;
}
+/**
+ * Get the track expense actionable whisper of the corresponding track expense
+ */
+function getTrackExpenseActionableWhisper(transactionID: string, chatReportID: string) {
+ const chatReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`] ?? {};
+ return Object.values(chatReportActions).find((action: ReportAction) => isActionableTrackExpense(action) && getOriginalMessage(action)?.transactionID === transactionID);
+}
+
export {
extractLinksFromMessageHtml,
getDismissedViolationMessageText,
@@ -1431,6 +1442,7 @@ export {
isMemberChangeAction,
getMemberChangeMessageFragment,
isOldDotReportAction,
+ getTrackExpenseActionableWhisper,
getMessageOfOldDotReportAction,
getMemberChangeMessagePlainText,
isReimbursementDeQueuedAction,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index cca52ac37c9d..b17fe7266079 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -2918,7 +2918,7 @@ function getTransactionReportName(reportAction: OnyxEntry): string {
/**
* Get the title for a report.
*/
-function getReportName(report: OnyxEntry, policy?: OnyxEntry): string {
+function getReportName(report: OnyxEntry, policy?: OnyxEntry, parentReportActionParam?: OnyxInputOrEntry): string {
let formattedName: string | undefined;
- const parentReportAction = ReportActionsUtils.getParentReportAction(report);
+ const parentReportAction = parentReportActionParam ?? ReportActionsUtils.getParentReportAction(report);
if (isChatThread(report)) {
if (!isEmptyObject(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction)) {
formattedName = getTransactionReportName(parentReportAction);
@@ -4043,8 +4043,8 @@ function getIOUReportActionMessage(iouReportID: string, type: string, total: num
}
const amount =
- type === CONST.IOU.REPORT_ACTION_TYPE.PAY
- ? CurrencyUtils.convertToDisplayString(getMoneyRequestSpendBreakdown(!isEmptyObject(report) ? report : undefined).totalDisplaySpend, currency)
+ type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !isEmptyObject(report)
+ ? CurrencyUtils.convertToDisplayString(getMoneyRequestSpendBreakdown(report).totalDisplaySpend, currency)
: CurrencyUtils.convertToDisplayString(total, currency);
let paymentMethodMessage;
@@ -5227,7 +5227,7 @@ function isUnread(report: OnyxEntry): boolean {
return false;
}
- if (isEmptyReport(report)) {
+ if (isEmptyReport(report) && !isSelfDM(report)) {
return false;
}
// lastVisibleActionCreated and lastReadTime are both datetime strings and can be compared directly
@@ -6647,7 +6647,11 @@ function getAllAncestorReportActions(report: Report | null | undefined): Ancesto
const parentReport = getReportOrDraftReport(parentReportID);
const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '-1');
- if (!parentReportAction || ReportActionsUtils.isTransactionThread(parentReportAction) || ReportActionsUtils.isReportPreviewAction(parentReportAction)) {
+ if (
+ !parentReportAction ||
+ (ReportActionsUtils.isTransactionThread(parentReportAction) && !ReportActionsUtils.isSentMoneyReportAction(parentReportAction)) ||
+ ReportActionsUtils.isReportPreviewAction(parentReportAction)
+ ) {
break;
}
@@ -6693,7 +6697,9 @@ function getAllAncestorReportActionIDs(report: Report | null | undefined, includ
if (
!parentReportAction ||
- (!includeTransactionThread && (ReportActionsUtils.isTransactionThread(parentReportAction) || ReportActionsUtils.isReportPreviewAction(parentReportAction)))
+ (!includeTransactionThread &&
+ ((ReportActionsUtils.isTransactionThread(parentReportAction) && !ReportActionsUtils.isSentMoneyReportAction(parentReportAction)) ||
+ ReportActionsUtils.isReportPreviewAction(parentReportAction)))
) {
break;
}
@@ -6960,6 +6966,7 @@ function createDraftTransactionAndNavigateToParticipantSelector(transactionID: s
currency,
comment,
merchant,
+ modifiedMerchant: '',
mccGroup,
} as Transaction);
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index ee2807a94c7c..d48404349057 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -82,41 +82,48 @@ function getOrderedReportIDs(
const allReportsDictValues = Object.values(allReports ?? {});
// Filter out all the reports that shouldn't be displayed
- let reportsToDisplay = allReportsDictValues.filter((report) => {
+ let reportsToDisplay: Array = [];
+ allReportsDictValues.forEach((report) => {
if (!report) {
- return false;
+ return;
}
-
- const parentReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`;
- const parentReportActions = allReportActions?.[parentReportActionsKey];
const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {};
- const parentReportAction = parentReportActions?.find((action) => action && action?.reportActionID === report.parentReportActionID);
- const doesReportHaveViolations = !!(
- betas?.includes(CONST.BETAS.VIOLATIONS) &&
- !!parentReportAction &&
- ReportUtils.shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction as OnyxEntry)
- );
+ const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, betas ?? [], transactionViolations);
const isHidden = report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
const isFocused = report.reportID === currentReportId;
const allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions) ?? {};
const hasErrorsOtherThanFailedReceipt =
doesReportHaveViolations || Object.values(allReportErrors).some((error) => error?.[0] !== Localize.translateLocal('iou.error.genericSmartscanFailureMessage'));
+ if (ReportUtils.isOneTransactionThread(report.reportID, report.parentReportID ?? '0')) {
+ return;
+ }
+ if (hasErrorsOtherThanFailedReceipt) {
+ reportsToDisplay.push({
+ ...report,
+ hasErrorsOtherThanFailedReceipt: true,
+ });
+ return;
+ }
const isSystemChat = ReportUtils.isSystemChat(report);
const shouldOverrideHidden = hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned;
if (isHidden && !shouldOverrideHidden) {
- return false;
+ return;
}
- return ReportUtils.shouldReportBeInOptionList({
- report,
- currentReportId: currentReportId ?? '-1',
- isInFocusMode,
- betas,
- policies: policies as OnyxCollection,
- excludeEmptyChats: true,
- doesReportHaveViolations,
- includeSelfDM: true,
- });
+ if (
+ ReportUtils.shouldReportBeInOptionList({
+ report,
+ currentReportId: currentReportId ?? '-1',
+ isInFocusMode,
+ betas,
+ policies: policies as OnyxCollection,
+ excludeEmptyChats: true,
+ doesReportHaveViolations,
+ includeSelfDM: true,
+ })
+ ) {
+ reportsToDisplay.push(report);
+ }
});
// The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order:
@@ -128,10 +135,12 @@ function getOrderedReportIDs(
// 4. Archived reports
// - Sorted by lastVisibleActionCreated in default (most recent) view mode
// - Sorted by reportDisplayName in GSD (focus) view mode
+
const pinnedAndGBRReports: MiniReport[] = [];
const draftReports: MiniReport[] = [];
const nonArchivedReports: MiniReport[] = [];
const archivedReports: MiniReport[] = [];
+ const errorReports: MiniReport[] = [];
if (currentPolicyID || policyMemberAccountIDs.length > 0) {
reportsToDisplay = reportsToDisplay.filter(
@@ -140,7 +149,7 @@ function getOrderedReportIDs(
}
// There are a few properties that need to be calculated for the report which are used when sorting reports.
reportsToDisplay.forEach((reportToDisplay) => {
- const report = reportToDisplay as OnyxEntry;
+ const report = reportToDisplay;
const miniReport: MiniReport = {
reportID: report?.reportID,
displayName: ReportUtils.getReportName(report),
@@ -155,6 +164,8 @@ function getOrderedReportIDs(
draftReports.push(miniReport);
} else if (ReportUtils.isArchivedRoom(report)) {
archivedReports.push(miniReport);
+ } else if (report?.hasErrorsOtherThanFailedReceipt) {
+ errorReports.push(miniReport);
} else {
nonArchivedReports.push(miniReport);
}
@@ -162,6 +173,7 @@ function getOrderedReportIDs(
// Sort each group of reports accordingly
pinnedAndGBRReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0));
+ errorReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0));
draftReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0));
if (isInDefaultMode) {
@@ -182,7 +194,9 @@ function getOrderedReportIDs(
// Now that we have all the reports grouped and sorted, they must be flattened into an array and only return the reportID.
// The order the arrays are concatenated in matters and will determine the order that the groups are displayed in the sidebar.
- const LHNReports = [...pinnedAndGBRReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report?.reportID ?? '-1');
+
+ const LHNReports = [...pinnedAndGBRReports, ...errorReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report?.reportID ?? '-1');
+
return LHNReports;
}
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 87dcede7f0c9..5fedd5443a89 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -407,7 +407,7 @@ function isNumeric(value: string): boolean {
if (typeof value !== 'string') {
return false;
}
- return /^\d*$/.test(value);
+ return CONST.REGEX.NUMBER.test(value);
}
/**
diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts
index 9a011d88e582..aea952618071 100644
--- a/src/libs/actions/Card.ts
+++ b/src/libs/actions/Card.ts
@@ -5,7 +5,7 @@ import type {ActivatePhysicalExpensifyCardParams, ReportVirtualExpensifyCardFrau
import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ExpensifyCardDetails} from '@src/types/onyx/Card';
+import type {ExpensifyCardDetails, IssueNewCardStep} from '@src/types/onyx/Card';
type ReplacementReason = 'damaged' | 'stolen';
@@ -44,7 +44,11 @@ function reportVirtualExpensifyCardFraud(cardID: number) {
cardID,
};
- API.write(WRITE_COMMANDS.REPORT_VIRTUAL_EXPENSIFY_CARD_FRAUD, parameters, {optimisticData, successData, failureData});
+ API.write(WRITE_COMMANDS.REPORT_VIRTUAL_EXPENSIFY_CARD_FRAUD, parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ });
}
/**
@@ -89,7 +93,11 @@ function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReas
reason,
};
- API.write(WRITE_COMMANDS.REQUEST_REPLACEMENT_EXPENSIFY_CARD, parameters, {optimisticData, successData, failureData});
+ API.write(WRITE_COMMANDS.REQUEST_REPLACEMENT_EXPENSIFY_CARD, parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ });
}
/**
@@ -177,5 +185,9 @@ function revealVirtualCardDetails(cardID: number): Promise
});
}
-export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud, revealVirtualCardDetails};
+function setIssueNewCardStep(step: IssueNewCardStep | null) {
+ Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {currentStep: step});
+}
+
+export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud, revealVirtualCardDetails, setIssueNewCardStep};
export type {ReplacementReason};
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 44f92d73e8dc..a4a53346aa9c 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -1704,13 +1704,16 @@ function getDeleteTrackExpenseInformation(
}
if (actionableWhisperReportActionID) {
+ const actionableWhisperReportAction = ReportActionsUtils.getReportAction(chatReportID, actionableWhisperReportActionID);
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
value: {
[actionableWhisperReportActionID]: {
originalMessage: {
- resolution: null,
+ resolution: ReportActionsUtils.isActionableTrackExpense(actionableWhisperReportAction)
+ ? ReportActionsUtils.getOriginalMessage(actionableWhisperReportAction)?.resolution ?? null
+ : null,
},
},
},
@@ -5584,7 +5587,17 @@ function deleteTrackExpense(chatReportID: string, transactionID: string, reportA
return deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView);
}
- const {parameters, optimisticData, successData, failureData, shouldDeleteTransactionThread} = getDeleteTrackExpenseInformation(chatReportID, transactionID, reportAction);
+ const whisperAction = ReportActionsUtils.getTrackExpenseActionableWhisper(transactionID, chatReportID);
+ const actionableWhisperReportActionID = whisperAction?.reportActionID;
+ const {parameters, optimisticData, successData, failureData, shouldDeleteTransactionThread} = getDeleteTrackExpenseInformation(
+ chatReportID,
+ transactionID,
+ reportAction,
+ undefined,
+ undefined,
+ actionableWhisperReportActionID,
+ CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING,
+ );
// STEP 6: Make the API request
API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData});
diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts
index 702153dac0b7..19f5281e086f 100644
--- a/src/libs/actions/Link.ts
+++ b/src/libs/actions/Link.ts
@@ -21,9 +21,13 @@ Onyx.connect({
});
let currentUserEmail = '';
+let currentUserAccountID = -1;
Onyx.connect({
key: ONYXKEYS.SESSION,
- callback: (value) => (currentUserEmail = value?.email ?? ''),
+ callback: (value) => {
+ currentUserEmail = value?.email ?? '';
+ currentUserAccountID = value?.accountID ?? -1;
+ },
});
function buildOldDotURL(url: string, shortLivedAuthToken?: string): Promise {
@@ -157,4 +161,29 @@ function openLink(href: string, environmentURL: string, isAttachment = false) {
openExternalLink(href);
}
-export {buildOldDotURL, openOldDotLink, openExternalLink, openLink, getInternalNewExpensifyPath, getInternalExpensifyPath, openTravelDotLink, buildTravelDotURL};
+function buildURLWithAuthToken(url: string, shortLivedAuthToken?: string) {
+ const authTokenParam = shortLivedAuthToken ? `shortLivedAuthToken=${shortLivedAuthToken}` : '';
+ const emailParam = `email=${encodeURIComponent(currentUserEmail)}`;
+ const exitTo = `exitTo=${url}`;
+ const accountID = `accountID=${currentUserAccountID}`;
+ const paramsArray = [accountID, emailParam, authTokenParam, exitTo];
+ const params = paramsArray.filter(Boolean).join('&');
+
+ return `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}transition?${params}`;
+}
+
+/**
+ * @param shouldSkipCustomSafariLogic When true, we will use `Linking.openURL` even if the browser is Safari.
+ */
+function openExternalLinkWithToken(url: string, shouldSkipCustomSafariLogic = false) {
+ asyncOpenURL(
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method
+ API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_OLD_DOT_LINK, {}, {})
+ .then((response) => (response ? buildURLWithAuthToken(url, response.shortLivedAuthToken) : buildURLWithAuthToken(url)))
+ .catch(() => buildURLWithAuthToken(url)),
+ (link) => link,
+ shouldSkipCustomSafariLogic,
+ );
+}
+
+export {buildOldDotURL, openOldDotLink, openExternalLink, openLink, getInternalNewExpensifyPath, getInternalExpensifyPath, openTravelDotLink, buildTravelDotURL, openExternalLinkWithToken};
diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts
index 9874a175d0a2..f8472bd43098 100644
--- a/src/libs/actions/Policy/Member.ts
+++ b/src/libs/actions/Policy/Member.ts
@@ -101,6 +101,14 @@ Onyx.connect({
},
});
+/** Check if the passed employee is an approver in the policy's employeeList */
+function isApprover(policy: OnyxEntry, employeeAccountID: number) {
+ const employeeLogin = allPersonalDetails?.[employeeAccountID]?.login;
+ return Object.values(policy?.employeeList ?? {}).some(
+ (employee) => employee?.submitsTo === employeeLogin || employee?.forwardsTo === employeeLogin || employee?.overLimitForwardsTo === employeeLogin,
+ );
+}
+
/**
* Returns the policy of the report
*/
@@ -243,6 +251,42 @@ function removeMembers(accountIDs: number[], policyID: string) {
failureMembersState[email] = {errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.people.error.genericRemove')};
});
+ Object.keys(policy?.employeeList ?? {}).forEach((employeeEmail) => {
+ const employee = policy?.employeeList?.[employeeEmail];
+ optimisticMembersState[employeeEmail] = optimisticMembersState[employeeEmail] ?? {};
+ failureMembersState[employeeEmail] = failureMembersState[employeeEmail] ?? {};
+ if (employee?.submitsTo && emailList.includes(employee?.submitsTo)) {
+ optimisticMembersState[employeeEmail] = {
+ ...optimisticMembersState[employeeEmail],
+ submitsTo: policy?.owner,
+ };
+ failureMembersState[employeeEmail] = {
+ ...failureMembersState[employeeEmail],
+ submitsTo: employee?.submitsTo,
+ };
+ }
+ if (employee?.forwardsTo && emailList.includes(employee?.forwardsTo)) {
+ optimisticMembersState[employeeEmail] = {
+ ...optimisticMembersState[employeeEmail],
+ forwardsTo: policy?.owner,
+ };
+ failureMembersState[employeeEmail] = {
+ ...failureMembersState[employeeEmail],
+ forwardsTo: employee?.forwardsTo,
+ };
+ }
+ if (employee?.overLimitForwardsTo && emailList.includes(employee?.overLimitForwardsTo)) {
+ optimisticMembersState[employeeEmail] = {
+ ...optimisticMembersState[employeeEmail],
+ overLimitForwardsTo: policy?.owner,
+ };
+ failureMembersState[employeeEmail] = {
+ ...failureMembersState[employeeEmail],
+ overLimitForwardsTo: employee?.overLimitForwardsTo,
+ };
+ }
+ });
+
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -801,6 +845,7 @@ export {
inviteMemberToWorkspace,
acceptJoinRequest,
declineJoinRequest,
+ isApprover,
};
export type {NewCustomUnit};
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index d1326ee8f733..069ff5682552 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -606,12 +606,7 @@ function leaveWorkspace(policyID: string) {
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- value: {
- pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
- employeeList: {
- [sessionEmail]: null,
- },
- },
+ value: null,
},
];
const failureData: OnyxUpdate[] = [
@@ -2944,6 +2939,10 @@ function setForeignCurrencyDefault(policyID: string, taxCode: string) {
API.write(WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT, parameters, onyxData);
}
+function getPoliciesConnectedToSageIntacct(): Policy[] {
+ return Object.values(allPolicies ?? {}).filter((policy): policy is Policy => !!policy && !!policy?.connections?.intacct);
+}
+
export {
leaveWorkspace,
addBillingCardAndRequestPolicyOwnerChange,
@@ -3007,6 +3006,7 @@ export {
buildPolicyData,
createPolicyExpenseChats,
clearNetSuiteErrorField,
+ getPoliciesConnectedToSageIntacct,
};
export type {NewCustomUnit};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index e528fde34ffe..1f5c41f45560 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -1538,6 +1538,7 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry void;
onNotCompleted?: () => void;
};
+type HasOpenedForTheFirstTimeFromHybridAppProps = {
+ onFirstTimeInHybridApp?: () => void;
+ onSubsequentRuns?: () => void;
+};
+
let resolveIsReadyPromise: (value?: Promise) => void | undefined;
let isServerDataReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
@@ -23,6 +36,11 @@ let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => {
resolveOnboardingFlowStatus = resolve;
});
+let resolveTryNewDotStatus: (value?: Promise) => void | undefined;
+const tryNewDotStatusPromise = new Promise((resolve) => {
+ resolveTryNewDotStatus = resolve;
+});
+
function onServerDataReady(): Promise {
return isServerDataReadyPromise;
}
@@ -42,6 +60,54 @@ function isOnboardingFlowCompleted({onCompleted, onNotCompleted}: HasCompletedOn
}
/**
+ * Determines whether the application is being launched for the first time by a hybrid app user,
+ * and executes corresponding callback functions.
+ */
+function isFirstTimeHybridAppUser({onFirstTimeInHybridApp, onSubsequentRuns}: HasOpenedForTheFirstTimeFromHybridAppProps) {
+ tryNewDotStatusPromise.then(() => {
+ let completedHybridAppOnboarding = tryNewDotData?.classicRedirect?.completedHybridAppOnboarding;
+ // Backend might return strings instead of booleans
+ if (typeof completedHybridAppOnboarding === 'string') {
+ completedHybridAppOnboarding = completedHybridAppOnboarding === 'true';
+ }
+
+ if (NativeModules.HybridAppModule && !completedHybridAppOnboarding) {
+ onFirstTimeInHybridApp?.();
+ return;
+ }
+
+ onSubsequentRuns?.();
+ });
+}
+
+/**
+ * Handles HybridApp onboarding flow if it's possible and necessary.
+ */
+function handleHybridAppOnboarding() {
+ if (!NativeModules.HybridAppModule) {
+ return;
+ }
+
+ isFirstTimeHybridAppUser({
+ // When user opens New Expensify for the first time from HybridApp we always want to show explanation modal first.
+ onFirstTimeInHybridApp: () => Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT),
+ // In other scenarios we need to check if onboarding was completed.
+ onSubsequentRuns: () =>
+ isOnboardingFlowCompleted({
+ onNotCompleted: () =>
+ setTimeout(() => {
+ Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT);
+ }, variables.explanationModalDelay),
+ }),
+ });
+}
+
+/**
+ * Check that a few requests have completed so that the welcome action can proceed:
+ *
+ * - Whether we are a first time new expensify user
+ * - Whether we have loaded all policies the server knows about
+ * - Whether we have loaded all reports the server knows about
* Check if onboarding data is ready in order to check if the user has completed onboarding or not
*/
function checkOnboardingDataReady() {
@@ -63,6 +129,17 @@ function checkServerDataReady() {
resolveIsReadyPromise?.();
}
+/**
+ * Check if user completed HybridApp onboarding
+ */
+function checkTryNewDotDataReady() {
+ if (tryNewDotData === undefined) {
+ return;
+ }
+
+ resolveTryNewDotStatus?.();
+}
+
function setOnboardingPurposeSelected(value: OnboardingPurposeType) {
Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null);
}
@@ -75,9 +152,36 @@ function setOnboardingPolicyID(policyID?: string) {
Onyx.set(ONYXKEYS.ONBOARDING_POLICY_ID, policyID ?? null);
}
+function completeHybridAppOnboarding() {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_TRYNEWDOT,
+ value: {
+ classicRedirect: {
+ completedHybridAppOnboarding: true,
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_TRYNEWDOT,
+ value: {
+ classicRedirect: {
+ completedHybridAppOnboarding: false,
+ },
+ },
+ },
+ ];
+
+ API.write(WRITE_COMMANDS.COMPLETE_HYBRID_APP_ONBOARDING, {}, {optimisticData, failureData});
+}
+
Onyx.connect({
key: ONYXKEYS.NVP_ONBOARDING,
- initWithStoredValues: false,
callback: (value) => {
if (value === undefined) {
return;
@@ -115,6 +219,14 @@ Onyx.connect({
},
});
+Onyx.connect({
+ key: ONYXKEYS.NVP_TRYNEWDOT,
+ callback: (value) => {
+ tryNewDotData = value;
+ checkTryNewDotDataReady();
+ },
+});
+
function resetAllChecks() {
isServerDataReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
@@ -126,4 +238,13 @@ function resetAllChecks() {
isLoadingReportData = true;
}
-export {onServerDataReady, isOnboardingFlowCompleted, setOnboardingPurposeSelected, resetAllChecks, setOnboardingAdminsChatReportID, setOnboardingPolicyID};
+export {
+ onServerDataReady,
+ isOnboardingFlowCompleted,
+ setOnboardingPurposeSelected,
+ resetAllChecks,
+ setOnboardingAdminsChatReportID,
+ setOnboardingPolicyID,
+ completeHybridAppOnboarding,
+ handleHybridAppOnboarding,
+};
diff --git a/src/libs/actions/connections/SageIntacct.ts b/src/libs/actions/connections/SageIntacct.ts
new file mode 100644
index 000000000000..9f944bd17273
--- /dev/null
+++ b/src/libs/actions/connections/SageIntacct.ts
@@ -0,0 +1,17 @@
+import * as API from '@libs/API';
+import type ConnectPolicyToSageIntacctParams from '@libs/API/parameters/ConnectPolicyToSageIntacctParams';
+import {WRITE_COMMANDS} from '@libs/API/types';
+
+type SageIntacctCredentials = {companyID: string; userID: string; password: string};
+
+function connectToSageIntacct(policyID: string, credentials: SageIntacctCredentials) {
+ const parameters: ConnectPolicyToSageIntacctParams = {
+ policyID,
+ intacctCompanyID: credentials.companyID,
+ intacctUserID: credentials.userID,
+ intacctPassword: credentials.password,
+ };
+ API.write(WRITE_COMMANDS.CONNECT_POLICY_TO_SAGE_INTACCT, parameters, {});
+}
+
+export default connectToSageIntacct;
diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts
index d0c9fb762a4c..694c38064c61 100644
--- a/src/libs/actions/connections/index.ts
+++ b/src/libs/actions/connections/index.ts
@@ -1,13 +1,7 @@
import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
-import type {
- RemovePolicyConnectionParams,
- SyncPolicyToQuickbooksOnlineParams,
- SyncPolicyToXeroParams,
- UpdateManyPolicyConnectionConfigurationsParams,
- UpdatePolicyConnectionConfigParams,
-} from '@libs/API/parameters';
+import type {RemovePolicyConnectionParams, UpdateManyPolicyConnectionConfigurationsParams, UpdatePolicyConnectionConfigParams} from '@libs/API/parameters';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import * as ErrorUtils from '@libs/ErrorUtils';
import CONST from '@src/CONST';
@@ -122,6 +116,25 @@ function updatePolicyConnectionConfig>(
diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts
index 0ce33a02f95d..dab7e8e33a6d 100644
--- a/src/libs/fileDownload/FileUtils.ts
+++ b/src/libs/fileDownload/FileUtils.ts
@@ -15,7 +15,9 @@ import type {ReadFileAsync, SplitExtensionFromFileName} from './types';
function showSuccessAlert(successMessage?: string) {
Alert.alert(
Localize.translateLocal('fileDownload.success.title'),
- successMessage ?? Localize.translateLocal('fileDownload.success.message'),
+ // successMessage can be an empty string and we want to default to `Localize.translateLocal('fileDownload.success.message')`
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ successMessage || Localize.translateLocal('fileDownload.success.message'),
[
{
text: Localize.translateLocal('common.ok'),
diff --git a/src/libs/fileDownload/index.ts b/src/libs/fileDownload/index.ts
index 3bae37c2ed6b..b39e65a87f94 100644
--- a/src/libs/fileDownload/index.ts
+++ b/src/libs/fileDownload/index.ts
@@ -11,7 +11,13 @@ import type {FileDownload} from './types';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const fileDownload: FileDownload = (url, fileName, successMessage = '', shouldOpenExternalLink = false) => {
const resolvedUrl = tryResolveUrlFromApiRoot(url);
- if (shouldOpenExternalLink || (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) && !CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix)))) {
+ if (
+ // we have two file download cases that we should allow 1. dowloading attachments 2. downloading Expensify package for Sage Intacct
+ shouldOpenExternalLink ||
+ (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) &&
+ !CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix)) &&
+ url !== CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT)
+ ) {
// Different origin URLs might pose a CORS issue during direct downloads.
// Opening in a new tab avoids this limitation, letting the browser handle the download.
Link.openExternalLink(url);
diff --git a/src/libs/freezeScreenWithLazyLoading.tsx b/src/libs/freezeScreenWithLazyLoading.tsx
new file mode 100644
index 000000000000..177d7826306c
--- /dev/null
+++ b/src/libs/freezeScreenWithLazyLoading.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import FreezeWrapper from './Navigation/FreezeWrapper';
+
+function FrozenScreen(WrappedComponent: React.ComponentType) {
+ return (props: TProps) => (
+
+
+
+ );
+}
+
+export default function freezeScreenWithLazyLoading(lazyComponent: () => React.ComponentType) {
+ return () => {
+ const Component = lazyComponent();
+ return FrozenScreen(Component);
+ };
+}
diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx
index 72425e0e2ca6..47843aa434fa 100644
--- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx
+++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx
@@ -9,6 +9,7 @@ import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
+import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -34,6 +35,7 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const {navigateToExitUrl} = useHybridAppMiddleware();
const {email = '', shortLivedAuthToken = '', shortLivedToken = '', authTokenType, exitTo, error} = route?.params ?? {};
useEffect(() => {
@@ -65,7 +67,7 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA
if (exitTo) {
Navigation.isNavigationReady().then(() => {
const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : (exitTo as Route);
- Navigation.navigate(url);
+ navigateToExitUrl(url);
});
}
// The only dependencies of the effect are based on props.route
diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx
index c80b26bbb9e7..d46b13459c46 100644
--- a/src/pages/LogOutPreviousUserPage.tsx
+++ b/src/pages/LogOutPreviousUserPage.tsx
@@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import {InitialURLContext} from '@components/InitialURLContextProvider';
+import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware';
import * as SessionUtils from '@libs/SessionUtils';
import Navigation from '@navigation/Navigation';
import type {AuthScreensParamList} from '@navigation/types';
@@ -32,6 +33,8 @@ type LogOutPreviousUserPageProps = LogOutPreviousUserPageOnyxProps & StackScreen
// This component should not do any other navigation as that handled in App.setUpPoliciesAndNavigate
function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPreviousUserPageProps) {
const initialURL = useContext(InitialURLContext);
+ const {navigateToExitUrl} = useHybridAppMiddleware();
+
useEffect(() => {
const sessionEmail = session?.email;
const transitionURL = NativeModules.HybridAppModule ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL;
@@ -80,7 +83,7 @@ function LogOutPreviousUserPage({session, route, isAccountLoading}: LogOutPrevio
// remove this screen and navigate to exit route
const exitUrl = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo;
Navigation.goBack();
- Navigation.navigate(exitUrl);
+ navigateToExitUrl(exitUrl);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/src/pages/ReimbursementAccount/BankAccountStep.tsx b/src/pages/ReimbursementAccount/BankAccountStep.tsx
index c9665d908b58..689b646f465a 100644
--- a/src/pages/ReimbursementAccount/BankAccountStep.tsx
+++ b/src/pages/ReimbursementAccount/BankAccountStep.tsx
@@ -24,7 +24,6 @@ import * as BankAccounts from '@userActions/BankAccounts';
import * as Link from '@userActions/Link';
import * as ReimbursementAccount from '@userActions/ReimbursementAccount';
import * as Session from '@userActions/Session';
-import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -86,7 +85,7 @@ function BankAccountStep({
subStep = CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID;
}
const plaidDesktopMessage = getPlaidDesktopMessage();
- const bankAccountRoute = `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('new', policyID, ROUTES.WORKSPACE_INITIAL.getRoute(policyID))}`;
+ const bankAccountRoute = `${ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('new', policyID, ROUTES.WORKSPACE_INITIAL.getRoute(policyID))}`;
const loginNames = Object.keys(loginList ?? {});
const removeExistingBankAccountDetails = () => {
@@ -135,7 +134,7 @@ function BankAccountStep({
{!!plaidDesktopMessage && (
- Link.openExternalLink(bankAccountRoute)}>{translate(plaidDesktopMessage)}
+ Link.openExternalLinkWithToken(bankAccountRoute)}>{translate(plaidDesktopMessage)}
)}
) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true),
- });
-
- const reportActions = useMemo(() => {
- if (!sortedAllReportActions.length) {
- return [];
- }
- return ReportActionsUtils.getContinuousReportActionChain(sortedAllReportActions);
- }, [sortedAllReportActions]);
+ // The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here.
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || '-1'}`);
+ const {reportActions} = usePaginatedReportActions(report.reportID);
const transactionThreadReportID = useMemo(
() => ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, reportActions ?? [], isOffline),
diff --git a/src/pages/TransactionDuplicate/Review.tsx b/src/pages/TransactionDuplicate/Review.tsx
index 481f7fdcc0bd..fc6af1f273e5 100644
--- a/src/pages/TransactionDuplicate/Review.tsx
+++ b/src/pages/TransactionDuplicate/Review.tsx
@@ -43,8 +43,8 @@ function TransactionDuplicateReview() {
};
return (
-
-
+
+
-
-
+
+
);
}
diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx
index 699fbab345cb..6728b37946f6 100644
--- a/src/pages/home/HeaderView.tsx
+++ b/src/pages/home/HeaderView.tsx
@@ -103,7 +103,7 @@ function HeaderView({
const isTaskReport = ReportUtils.isTaskReport(report);
const reportHeaderData = !isTaskReport && !isChatThread && report.parentReportID ? parentReport : report;
// Use sorted display names for the title for group chats on native small screen widths
- const title = ReportUtils.getReportName(reportHeaderData);
+ const title = ReportUtils.getReportName(reportHeaderData, undefined, parentReportAction);
const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData);
const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData);
const isConcierge = ReportUtils.isConciergeChatReport(report);
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index 9a0dce44e9ec..3f37663928ff 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -23,14 +23,16 @@ import withCurrentReportID from '@components/withCurrentReportID';
import useAppFocusEvent from '@hooks/useAppFocusEvent';
import useDeepCompareRef from '@hooks/useDeepCompareRef';
import useIsReportOpenInRHP from '@hooks/useIsReportOpenInRHP';
+import useLastAccessedReportID from '@hooks/useLastAccessedReportID';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import useViewportOffsetTop from '@hooks/useViewportOffsetTop';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import {getCurrentUserAccountID} from '@libs/actions/Report';
import Timing from '@libs/actions/Timing';
+import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import clearReportNotifications from '@libs/Notification/clearReportNotifications';
import Performance from '@libs/Performance';
@@ -38,6 +40,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import shouldFetchReport from '@libs/shouldFetchReport';
+import * as ValidationUtils from '@libs/ValidationUtils';
import type {AuthScreensParamList} from '@navigation/types';
import variables from '@styles/variables';
import * as ComposerActions from '@userActions/Composer';
@@ -65,9 +68,6 @@ type ReportScreenOnyxProps = {
/** The policies which the user has access to */
policies: OnyxCollection;
- /** An array containing all report actions related to this report, sorted based on a date criterion */
- sortedAllReportActions: OnyxTypes.ReportAction[];
-
/** Additional report details */
reportNameValuePairs: OnyxEntry;
@@ -116,7 +116,6 @@ function ReportScreen({
betas = [],
route,
reportNameValuePairs,
- sortedAllReportActions,
reportMetadata = {
isLoadingInitialReportActions: true,
isLoadingOlderReportActions: false,
@@ -161,6 +160,29 @@ function ReportScreen({
const isLoadingReportOnyx = isLoadingOnyxValue(reportResult);
const permissions = useDeepCompareRef(reportOnyx?.permissions);
+ // Check if there's a reportID in the route. If not, set it to the last accessed reportID
+ const lastAccessedReportID = useLastAccessedReportID(!!route.params.openOnAdminRoom);
+ useEffect(() => {
+ // Don't update if there is a reportID in the params already
+ if (route.params.reportID) {
+ const reportActionID = route?.params?.reportActionID;
+ const isValidReportActionID = ValidationUtils.isNumeric(reportActionID);
+ if (reportActionID && !isValidReportActionID) {
+ navigation.setParams({reportActionID: ''});
+ }
+ return;
+ }
+
+ // It's possible that reports aren't fully loaded yet
+ // in that case the reportID is undefined
+ if (!lastAccessedReportID) {
+ return;
+ }
+
+ Log.info(`[ReportScreen] no reportID found in params, setting it to lastAccessedReportID: ${lastAccessedReportID}`);
+ navigation.setParams({reportID: lastAccessedReportID});
+ }, [lastAccessedReportID, navigation, route]);
+
/**
* Create a lightweight Report so as to keep the re-rendering as light as possible by
* passing in only the required props.
@@ -257,12 +279,14 @@ function ReportScreen({
const prevReport = usePrevious(report);
const prevUserLeavingStatus = usePrevious(userLeavingStatus);
const [isLinkingToMessage, setIsLinkingToMessage] = useState(!!reportActionIDFromRoute);
- const reportActions = useMemo(() => {
- if (!sortedAllReportActions.length) {
- return [];
- }
- return ReportActionsUtils.getContinuousReportActionChain(sortedAllReportActions, reportActionIDFromRoute);
- }, [reportActionIDFromRoute, sortedAllReportActions]);
+
+ const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.accountID});
+ const {reportActions, linkedAction} = usePaginatedReportActions(report.reportID, reportActionIDFromRoute);
+ const isLinkedActionDeleted = useMemo(() => !!linkedAction && !ReportActionsUtils.shouldReportActionBeVisible(linkedAction, linkedAction.reportActionID), [linkedAction]);
+ const isLinkedActionInaccessibleWhisper = useMemo(
+ () => !!linkedAction && ReportActionsUtils.isWhisperAction(linkedAction) && !(linkedAction?.whisperedToAccountIDs ?? []).includes(currentUserAccountID),
+ [currentUserAccountID, linkedAction],
+ );
// Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger.
// If we have cached reportActions, they will be shown immediately.
@@ -287,10 +311,6 @@ function ReportScreen({
const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}];
const isEmptyChat = useMemo(() => ReportUtils.isEmptyReport(report), [report]);
const isOptimisticDelete = report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED;
- const isLinkedMessageAvailable = useMemo(
- (): boolean => sortedAllReportActions.findIndex((obj) => String(obj.reportActionID) === String(reportActionIDFromRoute)) > -1,
- [sortedAllReportActions, reportActionIDFromRoute],
- );
// If there's a non-404 error for the report we should show it instead of blocking the screen
const hasHelpfulErrors = Object.keys(report?.errorFields ?? {}).some((key) => key !== 'notFound');
@@ -382,12 +402,12 @@ function ReportScreen({
const isLoading = isLoadingApp ?? (!reportIDFromRoute || (!isSidebarLoaded && !isReportOpenInRHP) || PersonalDetailsUtils.isPersonalDetailsEmpty());
const shouldShowSkeleton =
- !isLinkedMessageAvailable &&
+ !linkedAction &&
(isLinkingToMessage ||
!isCurrentReportLoadedFromOnyx ||
(reportActions.length === 0 && !!reportMetadata?.isLoadingInitialReportActions) ||
isLoading ||
- (!!reportActionIDFromRoute && reportMetadata?.isLoadingInitialReportActions));
+ (!!reportActionIDFromRoute && !!reportMetadata?.isLoadingInitialReportActions));
const shouldShowReportActionList = isCurrentReportLoadedFromOnyx && !isLoading;
const currentReportIDFormRoute = route.params?.reportID;
@@ -662,28 +682,16 @@ function ReportScreen({
fetchReport();
}, [fetchReport]);
- const {isLinkedReportActionDeleted, isInaccessibleWhisper} = useMemo(() => {
- const currentUserAccountID = getCurrentUserAccountID();
- if (!reportActionIDFromRoute || !sortedAllReportActions) {
- return {isLinkedReportActionDeleted: false, isInaccessibleWhisper: false};
- }
- const action = sortedAllReportActions.find((item) => item.reportActionID === reportActionIDFromRoute);
- return {
- isLinkedReportActionDeleted: action && !ReportActionsUtils.shouldReportActionBeVisible(action, action.reportActionID),
- isInaccessibleWhisper: action && ReportActionsUtils.isWhisperAction(action) && !(action?.whisperedToAccountIDs ?? []).includes(currentUserAccountID),
- };
- }, [reportActionIDFromRoute, sortedAllReportActions]);
-
// If user redirects to an inaccessible whisper via a deeplink, on a report they have access to,
// then we set reportActionID as empty string, so we display them the report and not the "Not found page".
useEffect(() => {
- if (!isInaccessibleWhisper) {
+ if (!isLinkedActionInaccessibleWhisper) {
return;
}
Navigation.isNavigationReady().then(() => {
Navigation.setParams({reportActionID: ''});
});
- }, [isInaccessibleWhisper]);
+ }, [isLinkedActionInaccessibleWhisper]);
useEffect(() => {
if (!!report.lastReadTime || !ReportUtils.isTaskReport(report)) {
@@ -693,7 +701,7 @@ function ReportScreen({
Report.readNewestAction(report.reportID);
}, [report]);
- if ((!isInaccessibleWhisper && isLinkedReportActionDeleted) ?? (!shouldShowSkeleton && reportActionIDFromRoute && reportActions?.length === 0 && !isLinkingToMessage)) {
+ if ((!isLinkedActionInaccessibleWhisper && isLinkedActionDeleted) ?? (!shouldShowSkeleton && reportActionIDFromRoute && reportActions?.length === 0 && !isLinkingToMessage)) {
return (
`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`,
- canEvict: false,
- selector: (allReportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true),
- },
reportNameValuePairs: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getReportID(route)}`,
allowStaleData: true,
@@ -840,7 +843,6 @@ export default withCurrentReportID(
ReportScreen,
(prevProps, nextProps) =>
prevProps.isSidebarLoaded === nextProps.isSidebarLoaded &&
- lodashIsEqual(prevProps.sortedAllReportActions, nextProps.sortedAllReportActions) &&
lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) &&
lodashIsEqual(prevProps.betas, nextProps.betas) &&
lodashIsEqual(prevProps.policies, nextProps.policies) &&
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index f7e9b4429e2b..2264feddd679 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -202,6 +202,7 @@ function ReportActionItem({
const downloadedPreviews = useRef([]);
const prevDraftMessage = usePrevious(draftMessage);
const originalReportID = ReportUtils.getOriginalReportID(report.reportID, action);
+
// The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || -1}`);
@@ -500,8 +501,7 @@ function ReportActionItem({
// For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message
(ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE ||
ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT ||
- ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ||
- isSendingMoney)
+ ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK)
) {
// There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID
const iouReportID = ReportActionsUtils.getOriginalMessage(action)?.IOUReportID ? ReportActionsUtils.getOriginalMessage(action)?.IOUReportID?.toString() ?? '-1' : '-1';
diff --git a/src/pages/home/report/ReportActionsListItemRenderer.tsx b/src/pages/home/report/ReportActionsListItemRenderer.tsx
index e433ff00e1a1..1ef689ab1da4 100644
--- a/src/pages/home/report/ReportActionsListItemRenderer.tsx
+++ b/src/pages/home/report/ReportActionsListItemRenderer.tsx
@@ -72,7 +72,9 @@ function ReportActionsListItemRenderer({
parentReportActionForTransactionThread,
}: ReportActionsListItemRendererProps) {
const shouldDisplayParentAction =
- reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && ReportUtils.isChatThread(report) && !ReportActionsUtils.isTransactionThread(parentReportAction);
+ reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED &&
+ ReportUtils.isChatThread(report) &&
+ (!ReportActionsUtils.isTransactionThread(parentReportAction) || ReportActionsUtils.isSentMoneyReportAction(parentReportAction));
/**
* Create a lightweight ReportAction so as to keep the re-rendering as light as possible by
diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx
index 8737c352237f..98c8e2dd4e8a 100755
--- a/src/pages/home/report/ReportActionsView.tsx
+++ b/src/pages/home/report/ReportActionsView.tsx
@@ -466,37 +466,6 @@ function ReportActionsView({
Timing.end(CONST.TIMING.SWITCH_REPORT, hasCachedActionOnFirstRender ? CONST.TIMING.WARM : CONST.TIMING.COLD);
}, [hasCachedActionOnFirstRender]);
- useEffect(() => {
- // Temporary solution for handling REPORT_PREVIEW. More details: https://expensify.slack.com/archives/C035J5C9FAP/p1705417778466539?thread_ts=1705035404.136629&cid=C035J5C9FAP
- // This code should be removed once REPORT_PREVIEW is no longer repositioned.
- // We need to call openReport for gaps created by moving REPORT_PREVIEW, which causes mismatches in previousReportActionID and reportActionID of adjacent reportActions. The server returns the correct sequence, allowing us to overwrite incorrect data with the correct one.
- const shouldOpenReport =
- newestReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW &&
- !hasCreatedAction &&
- isReadyForCommentLinking &&
- reportActions.length < 24 &&
- reportActions.length >= 1 &&
- !isLoadingInitialReportActions &&
- !isLoadingOlderReportActions &&
- !isLoadingNewerReportActions &&
- !ReportUtils.isInvoiceRoom(report);
-
- if (shouldOpenReport) {
- Report.openReport(reportID, reportActionID);
- }
- }, [
- hasCreatedAction,
- reportID,
- reportActions,
- reportActionID,
- newestReportAction?.actionName,
- isReadyForCommentLinking,
- isLoadingOlderReportActions,
- isLoadingNewerReportActions,
- isLoadingInitialReportActions,
- report,
- ]);
-
// Check if the first report action in the list is the one we're currently linked to
const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID;
diff --git a/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
index db9f3199954f..a61875e76c2b 100644
--- a/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
+++ b/src/pages/settings/ExitSurvey/ExitSurveyConfirmPage.tsx
@@ -10,6 +10,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {MushroomTopHat} from '@components/Icon/Illustrations';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
+import useHybridAppMiddleware from '@hooks/useHybridAppMiddleware';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -35,6 +36,7 @@ type ExitSurveyConfirmPageProps = ExitSurveyConfirmPageOnyxProps & StackScreenPr
function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitSurveyConfirmPageProps) {
const {translate} = useLocalize();
+ const {showSplashScreenOnNextStart} = useHybridAppMiddleware();
const {isOffline} = useNetwork();
const styles = useThemeStyles();
@@ -87,6 +89,7 @@ function ExitSurveyConfirmPage({exitReason, isLoading, route, navigation}: ExitS
ExitSurvey.switchToOldDot().then(() => {
if (NativeModules.HybridAppModule) {
Navigation.resetToHome();
+ showSplashScreenOnNextStart();
NativeModules.HybridAppModule.closeReactNativeApp();
return;
}
diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx
index 58d179033736..b1c599458ceb 100755
--- a/src/pages/settings/InitialSettingsPage.tsx
+++ b/src/pages/settings/InitialSettingsPage.tsx
@@ -2,7 +2,7 @@ import {useRoute} from '@react-navigation/native';
import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {GestureResponderEvent, ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native';
-import {View} from 'react-native';
+import {NativeModules, View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
@@ -225,45 +225,46 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa
*/
const generalMenuItemsData: Menu = useMemo(() => {
const signOutTranslationKey = Session.isSupportAuthToken() && Session.hasStashedSession() ? 'initialSettingsPage.restoreStashed' : 'initialSettingsPage.signOut';
+ const commonItems: MenuData[] = [
+ {
+ translationKey: 'initialSettingsPage.help',
+ icon: Expensicons.QuestionMark,
+ action: () => {
+ Link.openExternalLink(CONST.NEWHELP_URL);
+ },
+ iconRight: Expensicons.NewWindow,
+ shouldShowRightIcon: true,
+ link: CONST.NEWHELP_URL,
+ },
+ {
+ translationKey: 'initialSettingsPage.about',
+ icon: Expensicons.Info,
+ routeName: ROUTES.SETTINGS_ABOUT,
+ },
+ {
+ translationKey: 'initialSettingsPage.aboutPage.troubleshoot',
+ icon: Expensicons.Lightbulb,
+ routeName: ROUTES.SETTINGS_TROUBLESHOOT,
+ },
+ {
+ translationKey: 'sidebarScreen.saveTheWorld',
+ icon: Expensicons.Heart,
+ routeName: ROUTES.SETTINGS_SAVE_THE_WORLD,
+ },
+ ];
+ const signOutItem: MenuData = {
+ translationKey: signOutTranslationKey,
+ icon: Expensicons.Exit,
+ action: () => {
+ signOut(false);
+ },
+ };
const defaultMenu: Menu = {
sectionStyle: {
...styles.pt4,
},
sectionTranslationKey: 'initialSettingsPage.general',
- items: [
- {
- translationKey: 'initialSettingsPage.help',
- icon: Expensicons.QuestionMark,
- action: () => {
- Link.openExternalLink(CONST.NEWHELP_URL);
- },
- iconRight: Expensicons.NewWindow,
- shouldShowRightIcon: true,
- link: CONST.NEWHELP_URL,
- },
- {
- translationKey: 'initialSettingsPage.about',
- icon: Expensicons.Info,
- routeName: ROUTES.SETTINGS_ABOUT,
- },
- {
- translationKey: 'initialSettingsPage.aboutPage.troubleshoot',
- icon: Expensicons.Lightbulb,
- routeName: ROUTES.SETTINGS_TROUBLESHOOT,
- },
- {
- translationKey: 'sidebarScreen.saveTheWorld',
- icon: Expensicons.Heart,
- routeName: ROUTES.SETTINGS_SAVE_THE_WORLD,
- },
- {
- translationKey: signOutTranslationKey,
- icon: Expensicons.Exit,
- action: () => {
- signOut(false);
- },
- },
- ],
+ items: NativeModules.HybridAppModule ? commonItems : [...commonItems, signOutItem],
};
return defaultMenu;
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.tsx b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.tsx
index c8f2c357f05f..5ee440ef257a 100644
--- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.tsx
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.tsx
@@ -1,6 +1,6 @@
import {useRoute} from '@react-navigation/native';
import type {RouteProp} from '@react-navigation/native';
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import React, {useCallback, useEffect, useMemo} from 'react';
import {withOnyx} from 'react-native-onyx';
import type {AnimationDirection} from '@components/AnimatedStep/AnimatedStepContext';
import useAnimatedStepContext from '@components/AnimatedStep/useAnimatedStepContext';
@@ -23,30 +23,21 @@ type TwoFactorAuthStepProps = BaseTwoFactorAuthFormOnyxProps;
function TwoFactorAuthSteps({account}: TwoFactorAuthStepProps) {
const route = useRoute>();
const backTo = route.params?.backTo ?? '';
- const [currentStep, setCurrentStep] = useState(CONST.TWO_FACTOR_AUTH_STEPS.CODES);
+ const currentStep = useMemo(() => {
+ if (account?.twoFactorAuthStep) {
+ return account.twoFactorAuthStep;
+ }
+ return account?.requiresTwoFactorAuth ? CONST.TWO_FACTOR_AUTH_STEPS.ENABLED : CONST.TWO_FACTOR_AUTH_STEPS.CODES;
+ }, [account?.requiresTwoFactorAuth, account?.twoFactorAuthStep]);
const {setAnimationDirection} = useAnimatedStepContext();
useEffect(() => () => TwoFactorAuthActions.clearTwoFactorAuthData(), []);
- useEffect(() => {
- if (account?.twoFactorAuthStep) {
- setCurrentStep(account?.twoFactorAuthStep);
- return;
- }
-
- if (account?.requiresTwoFactorAuth) {
- setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.ENABLED);
- } else {
- setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.CODES);
- }
- }, [account?.requiresTwoFactorAuth, account?.twoFactorAuthStep]);
-
const handleSetStep = useCallback(
(step: TwoFactorAuthStep, animationDirection: AnimationDirection = CONST.ANIMATION_DIRECTION.IN) => {
setAnimationDirection(animationDirection);
TwoFactorAuthActions.setTwoFactorAuthStep(step);
- setCurrentStep(step);
},
[setAnimationDirection],
);
diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx
index 1cb9d7b1d619..b970bc338490 100644
--- a/src/pages/settings/Subscription/CardSection/CardSection.tsx
+++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx
@@ -28,7 +28,7 @@ function CardSection() {
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION);
- const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.isDefault), [fundList]);
+ const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]);
const cardMonth = useMemo(() => DateUtils.getMonthNames(preferredLocale)[(defaultCard?.accountData?.cardMonth ?? 1) - 1], [defaultCard?.accountData?.cardMonth, preferredLocale]);
diff --git a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx
index 0424682c7afb..f697590327a8 100644
--- a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx
+++ b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx
@@ -18,7 +18,6 @@ import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
-import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWaitForNavigation from '@hooks/useWaitForNavigation';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -28,7 +27,6 @@ import * as Report from '@userActions/Report';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import SCREENS from '@src/SCREENS';
import getLightbulbIllustrationStyle from './getLightbulbIllustrationStyle';
type BaseMenuItem = {
@@ -45,7 +43,6 @@ type TroubleshootPageProps = TroubleshootPageOnyxProps;
function TroubleshootPage({shouldStoreLogs}: TroubleshootPageProps) {
const {translate} = useLocalize();
- const theme = useTheme();
const styles = useThemeStyles();
const {isProduction} = useEnvironment();
const [isConfirmationModalVisible, setIsConfirmationModalVisible] = useState(false);
@@ -105,7 +102,6 @@ function TroubleshootPage({shouldStoreLogs}: TroubleshootPageProps) {
illustration={LottieAnimations.Desk}
illustrationStyle={illustrationStyle}
titleStyles={styles.accountSettingsSectionTitle}
- illustrationBackgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.TROUBLESHOOT].backgroundColor}
renderSubtitle={() => (
{translate('initialSettingsPage.troubleshoot.description')} {' '}
diff --git a/src/pages/signin/SignInPageLayout/BackgroundImage/index.ios.tsx b/src/pages/signin/SignInPageLayout/BackgroundImage/index.ios.tsx
index b52709951c80..b61136993488 100644
--- a/src/pages/signin/SignInPageLayout/BackgroundImage/index.ios.tsx
+++ b/src/pages/signin/SignInPageLayout/BackgroundImage/index.ios.tsx
@@ -4,7 +4,7 @@ import type {ImageSourcePropType} from 'react-native';
import Reanimated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import DesktopBackgroundImage from '@assets/images/home-background--desktop.svg';
import MobileBackgroundImage from '@assets/images/home-background--mobile-new.svg';
-import useIsSplashHidden from '@hooks/useIsSplashHidden';
+import useSplashScreen from '@hooks/useSplashScreen';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
@@ -25,7 +25,7 @@ function BackgroundImage({width, transitionDuration, isSmallScreen = false}: Bac
});
}
- const isSplashHidden = useIsSplashHidden();
+ const {isSplashHidden} = useSplashScreen();
// Prevent rendering the background image until the splash screen is hidden.
// See issue: https://github.com/Expensify/App/issues/34696
if (!isSplashHidden) {
diff --git a/src/pages/signin/SignInPageLayout/SignInHeroImage.tsx b/src/pages/signin/SignInPageLayout/SignInHeroImage.tsx
index 538f26e32e93..21556fb47bc0 100644
--- a/src/pages/signin/SignInPageLayout/SignInHeroImage.tsx
+++ b/src/pages/signin/SignInPageLayout/SignInHeroImage.tsx
@@ -2,8 +2,8 @@ import React, {useMemo} from 'react';
import {View} from 'react-native';
import Lottie from '@components/Lottie';
import LottieAnimations from '@components/LottieAnimations';
-import useIsSplashHidden from '@hooks/useIsSplashHidden';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSplashScreen from '@hooks/useSplashScreen';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import variables from '@styles/variables';
@@ -26,7 +26,7 @@ function SignInHeroImage() {
};
}, [shouldUseNarrowLayout, isMediumScreenWidth]);
- const isSplashHidden = useIsSplashHidden();
+ const {isSplashHidden} = useSplashScreen();
// Prevents rendering of the Lottie animation until the splash screen is hidden
// by returning an empty view of the same size as the animation.
// See issue: https://github.com/Expensify/App/issues/34696
diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx
index 35cbf4db3581..a2e81a6da550 100644
--- a/src/pages/workspace/WorkspaceInvitePage.tsx
+++ b/src/pages/workspace/WorkspaceInvitePage.tsx
@@ -94,6 +94,21 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli
const excludedUsers = useMemo(() => PolicyUtils.getIneligibleInvitees(policy?.employeeList), [policy?.employeeList]);
+ const defaultOptions = useMemo(() => {
+ if (!areOptionsInitialized) {
+ return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null, categoryOptions: [], tagOptions: [], taxRatesOptions: []};
+ }
+
+ const inviteOptions = OptionsListUtils.getMemberInviteOptions(options.personalDetails, betas ?? [], '', excludedUsers, true);
+
+ return {...inviteOptions, recentReports: [], currentUserOption: null, categoryOptions: [], tagOptions: [], taxRatesOptions: []};
+ }, [areOptionsInitialized, betas, excludedUsers, options.personalDetails]);
+
+ const inviteOptions = useMemo(
+ () => OptionsListUtils.filterOptions(defaultOptions, debouncedSearchTerm, {excludeLogins: excludedUsers}),
+ [debouncedSearchTerm, defaultOptions, excludedUsers],
+ );
+
useEffect(() => {
if (!areOptionsInitialized) {
return;
@@ -103,7 +118,6 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli
const newPersonalDetailsDict: Record = {};
const newSelectedOptionsDict: Record = {};
- const inviteOptions = OptionsListUtils.getMemberInviteOptions(options.personalDetails, betas ?? [], debouncedSearchTerm, excludedUsers, true);
// Update selectedOptions with the latest personalDetails and policyEmployeeList information
const detailsMap: Record = {};
inviteOptions.personalDetails.forEach((detail) => {
@@ -158,7 +172,7 @@ function WorkspaceInvitePage({route, betas, invitedEmailsToAccountIDsDraft, poli
setSelectedOptions(Object.values(newSelectedOptionsDict));
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change
- }, [options.personalDetails, policy?.employeeList, betas, debouncedSearchTerm, excludedUsers, areOptionsInitialized]);
+ }, [options.personalDetails, policy?.employeeList, betas, debouncedSearchTerm, excludedUsers, areOptionsInitialized, inviteOptions.personalDetails, inviteOptions.userToInvite]);
const sections: MembersSection[] = useMemo(() => {
const sectionsArr: MembersSection[] = [];
diff --git a/src/pages/workspace/WorkspaceJoinUserPage.tsx b/src/pages/workspace/WorkspaceJoinUserPage.tsx
index e14fda8753c9..d73dab51c4fe 100644
--- a/src/pages/workspace/WorkspaceJoinUserPage.tsx
+++ b/src/pages/workspace/WorkspaceJoinUserPage.tsx
@@ -6,6 +6,7 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import ScreenWrapper from '@components/ScreenWrapper';
import useThemeStyles from '@hooks/useThemeStyles';
import navigateAfterJoinRequest from '@libs/navigateAfterJoinRequest';
+import * as PolicyUtils from '@libs/PolicyUtils';
import Navigation from '@navigation/Navigation';
import type {AuthScreensParamList} from '@navigation/types';
import * as MemberAction from '@userActions/Policy/Member';
@@ -42,7 +43,7 @@ function WorkspaceJoinUserPage({route, policy}: WorkspaceJoinUserPageProps) {
if (isUnmounted.current || isJoinLinkUsed) {
return;
}
- if (!isEmptyObject(policy) && !policy?.isJoinRequestPending) {
+ if (!isEmptyObject(policy) && !policy?.isJoinRequestPending && !PolicyUtils.isPendingDeletePolicy(policy)) {
Navigation.isNavigationReady().then(() => {
Navigation.goBack(undefined, false, true);
Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID ?? '-1'));
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index 76665a923224..8f1bb6ee12ba 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -36,6 +36,7 @@ import type {FullScreenNavigatorParamList} from '@libs/Navigation/types';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
+import {getDisplayNameForParticipant} from '@libs/ReportUtils';
import * as Member from '@userActions/Policy/Member';
import * as Policy from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
@@ -95,6 +96,17 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft,
const selectionListRef = useRef(null);
const isFocused = useIsFocused();
const policyID = route.params.policyID;
+
+ const confirmModalPrompt = useMemo(() => {
+ const approverAccountID = selectedEmployees.find((selectedEmployee) => Member.isApprover(policy, selectedEmployee));
+ if (!approverAccountID) {
+ return translate('workspace.people.removeMembersPrompt');
+ }
+ return translate('workspace.people.removeMembersWarningPrompt', {
+ memberName: getDisplayNameForParticipant(approverAccountID),
+ ownerName: getDisplayNameForParticipant(policy?.ownerAccountID),
+ });
+ }, [selectedEmployees, policy, translate]);
/**
* Get filtered personalDetails list with current employeeList
*/
@@ -549,7 +561,7 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft,
isVisible={removeMembersConfirmModalVisible}
onConfirm={removeUsers}
onCancel={() => setRemoveMembersConfirmModalVisible(false)}
- prompt={translate('workspace.people.removeMembersPrompt')}
+ prompt={confirmModalPrompt}
confirmText={translate('common.remove')}
cancelText={translate('common.cancel')}
onModalHide={() => {
diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx
index 3bb63dadaadc..518a8720c7fc 100644
--- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx
+++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx
@@ -1,11 +1,13 @@
-import {differenceInMinutes, formatDistanceToNow, isValid, parseISO} from 'date-fns';
+import {differenceInMinutes, isValid, parseISO} from 'date-fns';
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {ActivityIndicator, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import CollapsibleSection from '@components/CollapsibleSection';
import ConfirmModal from '@components/ConfirmModal';
+import ConnectToNetSuiteButton from '@components/ConnectToNetSuiteButton';
import ConnectToQuickbooksOnlineButton from '@components/ConnectToQuickbooksOnlineButton';
+import ConnectToSageIntacctButton from '@components/ConnectToSageIntacctButton';
import ConnectToXeroButton from '@components/ConnectToXeroButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
@@ -28,7 +30,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {hasSynchronizationError, removePolicyConnection, syncConnection} from '@libs/actions/connections';
-import {findCurrentXeroOrganization, getCurrentXeroOrganizationName, getXeroTenants} from '@libs/PolicyUtils';
+import {findCurrentXeroOrganization, getCurrentXeroOrganizationName, getIntegrationLastSuccessfulDate, getXeroTenants} from '@libs/PolicyUtils';
import Navigation from '@navigation/Navigation';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
@@ -105,8 +107,22 @@ function accountingIntegrationData(
title: translate('workspace.accounting.netsuite'),
icon: Expensicons.NetSuiteSquare,
setupConnectionButton: (
- // TODO: Will be updated in the Token Input PR
-
+ ),
+ onImportPagePress: () => {},
+ onExportPagePress: () => {},
+ onAdvancedPagePress: () => {},
+ };
+ case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT:
+ return {
+ title: translate('workspace.accounting.intacct'),
+ icon: Expensicons.IntacctSquare,
+ setupConnectionButton: (
+ !(name === CONST.POLICY.CONNECTIONS.NAME.NETSUITE && !canUseNetSuiteIntegration));
+ const accountingIntegrations = Object.values(CONST.POLICY.CONNECTIONS.NAME).filter(
+ (name) => !((name === CONST.POLICY.CONNECTIONS.NAME.NETSUITE || name === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT) && !canUseNetSuiteIntegration),
+ );
const connectedIntegration = accountingIntegrations.find((integration) => !!policy?.connections?.[integration]) ?? connectionSyncProgress?.connectionName;
const policyID = policy?.id ?? '-1';
- const successfulDate = policy?.connections?.quickbooksOnline?.lastSync?.successfulDate;
- const formattedDate = useMemo(() => (successfulDate ? new Date(successfulDate) : new Date()), [successfulDate]);
+ const successfulDate = getIntegrationLastSuccessfulDate(connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined);
const policyConnectedToXero = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.XERO;
const policyConnectedToNetSuite = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.NETSUITE;
@@ -174,8 +191,12 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting
);
useEffect(() => {
- setDateTimeToRelative(formatDistanceToNow(formattedDate, {addSuffix: true}));
- }, [formattedDate]);
+ if (successfulDate) {
+ setDateTimeToRelative(getDatetimeToRelative(successfulDate));
+ return;
+ }
+ setDateTimeToRelative('');
+ }, [getDatetimeToRelative, successfulDate]);
const connectionsMenuItems: MenuItemData[] = useMemo(() => {
if (isEmptyObject(policy?.connections) && !isSyncInProgress) {
@@ -388,10 +409,13 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting
childrenStyles={styles.pt5}
>
{connectionsMenuItems.map((menuItem) => (
-
+
@@ -423,7 +447,7 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting
setIsDisconnectModalOpen(false);
}}
onCancel={() => setIsDisconnectModalOpen(false)}
- prompt={translate('workspace.accounting.disconnectPrompt', undefined, connectedIntegration)}
+ prompt={translate('workspace.accounting.disconnectPrompt', connectedIntegration)}
confirmText={translate('workspace.accounting.disconnect')}
cancelText={translate('common.cancel')}
danger
diff --git a/src/pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage.tsx b/src/pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage.tsx
new file mode 100644
index 000000000000..75f4cab3783d
--- /dev/null
+++ b/src/pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage.tsx
@@ -0,0 +1,98 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import connectToSageIntacct from '@libs/actions/connections/SageIntacct';
+import * as ErrorUtils from '@libs/ErrorUtils';
+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 INPUT_IDS from '@src/types/form/SageIntactCredentialsForm';
+
+type IntacctPrerequisitesPageProps = StackScreenProps;
+
+function EnterSageIntacctCredentialsPage({route}: IntacctPrerequisitesPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const policyID: string = route.params.policyID;
+
+ const confirmCredentials = useCallback(
+ (values: FormOnyxValues) => {
+ connectToSageIntacct(policyID, values);
+ Navigation.goBack();
+ Navigation.goBack();
+ },
+ [policyID],
+ );
+
+ const formItems = Object.values(INPUT_IDS);
+ const validate = useCallback(
+ (values: FormOnyxValues) => {
+ const errors: FormInputErrors = {};
+
+ formItems.forEach((formItem) => {
+ if (values[formItem]) {
+ return;
+ }
+ ErrorUtils.addErrorMessage(errors, formItem, translate('common.error.fieldRequired'));
+ });
+ return errors;
+ },
+ [formItems, translate],
+ );
+ return (
+
+ Navigation.goBack(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.getRoute(policyID))}
+ />
+
+ {translate('workspace.intacct.enterCredentials')}
+ {formItems.map((formItem) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+EnterSageIntacctCredentialsPage.displayName = 'PolicyEnterSageIntacctCredentialsPage';
+
+export default EnterSageIntacctCredentialsPage;
diff --git a/src/pages/workspace/accounting/intacct/ExistingConnectionsPage.tsx b/src/pages/workspace/accounting/intacct/ExistingConnectionsPage.tsx
new file mode 100644
index 000000000000..7e867d88285d
--- /dev/null
+++ b/src/pages/workspace/accounting/intacct/ExistingConnectionsPage.tsx
@@ -0,0 +1,64 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React from 'react';
+import {View} from 'react-native';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MenuItemList from '@components/MenuItemList';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getPoliciesConnectedToSageIntacct} from '@libs/actions/Policy/Policy';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as ReportUtils from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type ExistingConnectionsPageProps = StackScreenProps;
+
+function ExistingConnectionsPage({route}: ExistingConnectionsPageProps) {
+ const {translate, datetimeToRelative} = useLocalize();
+ const styles = useThemeStyles();
+ const policiesConnectedToSageIntacct = getPoliciesConnectedToSageIntacct();
+ const policyID: string = route.params.policyID;
+
+ const menuItems = policiesConnectedToSageIntacct.map((policy) => {
+ const lastSuccessfulSyncDate = policy.connections?.intacct.lastSync?.successfulDate;
+ const date = lastSuccessfulSyncDate ? datetimeToRelative(lastSuccessfulSyncDate) : undefined;
+ return {
+ title: policy.name,
+ key: policy.id,
+ icon: policy.avatarURL ? policy.avatarURL : ReportUtils.getDefaultWorkspaceAvatar(policy.name),
+ iconType: policy.avatarURL ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_WORKSPACE,
+ description: date ? translate('workspace.intacct.sageIntacctLastSync', date) : translate('workspace.accounting.intacct'),
+ onPress: () => {
+ // waiting for backend for reusing existing connections
+ Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID));
+ },
+ };
+ });
+
+ return (
+
+ Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID))}
+ />
+
+
+
+
+ );
+}
+
+ExistingConnectionsPage.displayName = 'PolicyExistingConnectionsPage';
+
+export default ExistingConnectionsPage;
diff --git a/src/pages/workspace/accounting/intacct/IntacctPrerequisitesPage.tsx b/src/pages/workspace/accounting/intacct/IntacctPrerequisitesPage.tsx
new file mode 100644
index 000000000000..eaf46392fb04
--- /dev/null
+++ b/src/pages/workspace/accounting/intacct/IntacctPrerequisitesPage.tsx
@@ -0,0 +1,104 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useMemo, useRef} from 'react';
+import {View} from 'react-native';
+// eslint-disable-next-line no-restricted-imports
+import type {GestureResponderEvent, Text as RNText} from 'react-native';
+import Computer from '@assets/images/computer.svg';
+import Button from '@components/Button';
+import FixedFooter from '@components/FixedFooter';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
+import ImageSVG from '@components/ImageSVG';
+import MenuItemList from '@components/MenuItemList';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import fileDownload from '@libs/fileDownload';
+import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu';
+import * as Link from '@userActions/Link';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type IntacctPrerequisitesPageProps = StackScreenProps;
+
+function IntacctPrerequisitesPage({route}: IntacctPrerequisitesPageProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const popoverAnchor = useRef(null);
+ const policyID: string = route.params.policyID;
+
+ const menuItems = useMemo(
+ () => [
+ {
+ title: translate('workspace.intacct.downloadExpensifyPackage'),
+ key: 'workspace.intacct.downloadExpensifyPackage',
+ icon: Expensicons.Download,
+ iconRight: Expensicons.NewWindow,
+ shouldShowRightIcon: true,
+ onPress: () => {
+ fileDownload(CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT, CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT_FILE_NAME);
+ },
+ onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) =>
+ ReportActionContextMenu.showContextMenu(CONST.CONTEXT_MENU_TYPES.LINK, event, CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT, popoverAnchor.current),
+ numberOfLinesTitle: 2,
+ },
+ {
+ title: translate('workspace.intacct.followSteps'),
+ key: 'workspace.intacct.followSteps',
+ icon: Expensicons.Task,
+ iconRight: Expensicons.NewWindow,
+ shouldShowRightIcon: true,
+ onPress: () => {
+ Link.openExternalLink(CONST.HOW_TO_CONNECT_TO_SAGE_INTACCT);
+ },
+ onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) =>
+ ReportActionContextMenu.showContextMenu(CONST.CONTEXT_MENU_TYPES.LINK, event, CONST.HOW_TO_CONNECT_TO_SAGE_INTACCT, popoverAnchor.current),
+ numberOfLinesTitle: 3,
+ },
+ ],
+ [translate],
+ );
+
+ return (
+
+ Navigation.goBack()}
+ />
+
+
+
+
+
+ {translate('workspace.intacct.prerequisitesTitle')}
+
+
+
+ Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID))}
+ pressOnEnter
+ large
+ />
+
+
+
+ );
+}
+
+IntacctPrerequisitesPage.displayName = 'PolicyIntacctPrerequisitesPage';
+
+export default IntacctPrerequisitesPage;
diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountSelectPage.tsx
index 3c93bb683166..a02c0b76809f 100644
--- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountSelectPage.tsx
+++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAccountSelectPage.tsx
@@ -45,7 +45,7 @@ function QuickbooksAccountSelectPage({policy}: WithPolicyConnectionsProps) {
const listHeaderComponent = useMemo(
() => (
- {translate('workspace.qbo.advancedConfig.invoiceAccountSelectorDescription')}
+ {translate('workspace.qbo.advancedConfig.accountSelectDescription')}
),
[translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal],
diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage.tsx
index d18edd0489b8..a286ca4222e0 100644
--- a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage.tsx
+++ b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage.tsx
@@ -40,7 +40,7 @@ function QuickbooksCompanyCardExpenseAccountPage({policy}: WithPolicyConnections
Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_SELECT.getRoute(policyID))}
brickRoadIndicator={errorFields?.nonReimbursableExpensesExportDestination ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
@@ -95,7 +95,7 @@ function QuickbooksCompanyCardExpenseAccountPage({policy}: WithPolicyConnections
Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT.getRoute(policyID))}
brickRoadIndicator={errorFields?.nonReimbursableBillDefaultVendor ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
shouldShowRightIcon
diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectCardPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectCardPage.tsx
index fd238173cd41..18aaa8c35cc5 100644
--- a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectCardPage.tsx
+++ b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectCardPage.tsx
@@ -93,11 +93,10 @@ function QuickbooksCompanyCardExpenseAccountSelectCardPage({policy}: WithPolicyC
featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED}
>
-
+
{translate('workspace.qbo.exportCompanyCardsDescription')} }
sections={sections}
ListItem={RadioListItem}
onSelectRow={selectExportCompanyCard}
diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableDefaultVendorSelectPage.tsx
index 55c5cf7e9898..d54dcc4853e0 100644
--- a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableDefaultVendorSelectPage.tsx
+++ b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableDefaultVendorSelectPage.tsx
@@ -6,7 +6,6 @@ import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import type {ListItem} from '@components/SelectionList/types';
-import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Connections from '@libs/actions/connections';
@@ -73,7 +72,6 @@ function QuickbooksNonReimbursableDefaultVendorSelectPage({policy}: WithPolicyCo
{translate('workspace.qbo.defaultVendorDescription')}}
sections={sections}
ListItem={RadioListItem}
onSelectRow={selectVendor}
diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseEntitySelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseEntitySelectPage.tsx
index 39eb6b1845c8..7c8a39aaa492 100644
--- a/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseEntitySelectPage.tsx
+++ b/src/pages/workspace/accounting/qbo/export/QuickbooksOutOfPocketExpenseEntitySelectPage.tsx
@@ -117,7 +117,6 @@ function QuickbooksOutOfPocketExpenseEntitySelectPage({policy}: WithPolicyConnec
{translate('workspace.qbo.optionBelow')}}
sections={sections}
ListItem={RadioListItem}
onSelectRow={selectExportEntity}
diff --git a/src/pages/workspace/card/issueNew/AssigneeStep.tsx b/src/pages/workspace/card/issueNew/AssigneeStep.tsx
new file mode 100644
index 000000000000..5012ba294518
--- /dev/null
+++ b/src/pages/workspace/card/issueNew/AssigneeStep.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@navigation/Navigation';
+import * as Card from '@userActions/Card';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function AssigneeStep() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const submit = () => {
+ // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CARD_TYPE);
+ };
+
+ const handleBackButtonPress = () => {
+ Navigation.goBack();
+ };
+
+ return (
+
+
+
+
+
+ {translate('workspace.card.issueNewCard.whoNeedsCard')}
+
+ {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
+
+
+
+ );
+}
+
+AssigneeStep.displayName = 'AssigneeStep';
+
+export default AssigneeStep;
diff --git a/src/pages/workspace/card/issueNew/CardNameStep.tsx b/src/pages/workspace/card/issueNew/CardNameStep.tsx
new file mode 100644
index 000000000000..9b48d6417732
--- /dev/null
+++ b/src/pages/workspace/card/issueNew/CardNameStep.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as Card from '@userActions/Card';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function CardNameStep() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const submit = () => {
+ // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CONFIRMATION);
+ };
+
+ const handleBackButtonPress = () => {
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.LIMIT);
+ };
+
+ return (
+
+
+
+
+
+ {translate('workspace.card.issueNewCard.giveItName')}
+
+ {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
+
+
+
+ );
+}
+
+CardNameStep.displayName = 'CardNameStep';
+
+export default CardNameStep;
diff --git a/src/pages/workspace/card/issueNew/CardTypeStep.tsx b/src/pages/workspace/card/issueNew/CardTypeStep.tsx
new file mode 100644
index 000000000000..93b99f51d239
--- /dev/null
+++ b/src/pages/workspace/card/issueNew/CardTypeStep.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as Card from '@userActions/Card';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function CardTypeStep() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const submit = () => {
+ // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE);
+ };
+
+ const handleBackButtonPress = () => {
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.ASSIGNEE);
+ };
+
+ return (
+
+
+
+
+
+ {translate('workspace.card.issueNewCard.chooseCardType')}
+
+ {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
+
+
+
+ );
+}
+
+CardTypeStep.displayName = 'CardTypeStep';
+
+export default CardTypeStep;
diff --git a/src/pages/workspace/card/issueNew/ConfirmationStep.tsx b/src/pages/workspace/card/issueNew/ConfirmationStep.tsx
new file mode 100644
index 000000000000..a64d6f463531
--- /dev/null
+++ b/src/pages/workspace/card/issueNew/ConfirmationStep.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+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 Card from '@userActions/Card';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+function ConfirmationStep() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {isOffline} = useNetwork();
+
+ const submit = () => {
+ // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
+ Navigation.navigate(ROUTES.SETTINGS);
+ };
+
+ const handleBackButtonPress = () => {
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CARD_NAME);
+ };
+
+ return (
+
+
+
+
+
+ {translate('workspace.card.issueNewCard.letsDoubleCheck')}
+
+ {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
+
+
+
+ );
+}
+
+ConfirmationStep.displayName = 'ConfirmationStep';
+
+export default ConfirmationStep;
diff --git a/src/pages/workspace/card/issueNew/IssueNewCardPage.tsx b/src/pages/workspace/card/issueNew/IssueNewCardPage.tsx
new file mode 100644
index 000000000000..d63bbd56b4d0
--- /dev/null
+++ b/src/pages/workspace/card/issueNew/IssueNewCardPage.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import {useOnyx} from 'react-native-onyx';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import AssigneeStep from './AssigneeStep';
+import CardNameStep from './CardNameStep';
+import CardTypeStep from './CardTypeStep';
+import ConfirmationStep from './ConfirmationStep';
+import LimitStep from './LimitStep';
+import LimitTypeStep from './LimitTypeStep';
+
+function IssueNewCardPage() {
+ const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD);
+
+ const {currentStep} = issueNewCard ?? {};
+
+ switch (currentStep) {
+ case CONST.EXPENSIFY_CARD.STEP.ASSIGNEE:
+ return ;
+ case CONST.EXPENSIFY_CARD.STEP.CARD_TYPE:
+ return ;
+ case CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE:
+ return ;
+ case CONST.EXPENSIFY_CARD.STEP.LIMIT:
+ return ;
+ case CONST.EXPENSIFY_CARD.STEP.CARD_NAME:
+ return ;
+ case CONST.EXPENSIFY_CARD.STEP.CONFIRMATION:
+ return ;
+ default:
+ return ;
+ }
+}
+
+IssueNewCardPage.displayName = 'IssueNewCardPage';
+export default IssueNewCardPage;
diff --git a/src/pages/workspace/card/issueNew/LimitStep.tsx b/src/pages/workspace/card/issueNew/LimitStep.tsx
new file mode 100644
index 000000000000..dd2e80a6612a
--- /dev/null
+++ b/src/pages/workspace/card/issueNew/LimitStep.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as Card from '@userActions/Card';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function LimitStep() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const submit = () => {
+ // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CARD_NAME);
+ };
+
+ const handleBackButtonPress = () => {
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE);
+ };
+
+ return (
+
+
+
+
+
+ {translate('workspace.card.issueNewCard.setLimit')}
+
+ {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
+
+
+
+ );
+}
+
+LimitStep.displayName = 'LimitStep';
+
+export default LimitStep;
diff --git a/src/pages/workspace/card/issueNew/LimitTypeStep.tsx b/src/pages/workspace/card/issueNew/LimitTypeStep.tsx
new file mode 100644
index 000000000000..b1249e33e3c4
--- /dev/null
+++ b/src/pages/workspace/card/issueNew/LimitTypeStep.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import {View} from 'react-native';
+import FormProvider from '@components/Form/FormProvider';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as Card from '@userActions/Card';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function LimitTypeStep() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const submit = () => {
+ // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.LIMIT);
+ };
+
+ const handleBackButtonPress = () => {
+ Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CARD_TYPE);
+ };
+
+ return (
+
+
+
+
+
+ {translate('workspace.card.issueNewCard.chooseLimitType')}
+
+ {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
+
+
+
+ );
+}
+
+LimitTypeStep.displayName = 'LimitTypeStep';
+
+export default LimitTypeStep;
diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
index 9080685d3080..03c96e7f67e2 100644
--- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
+++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx
@@ -141,7 +141,7 @@ function PolicyDistanceRatesSettingsPage({policy, policyCategories, route}: Poli
{
Navigation.dismissModal();
- Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID));
+ Navigation.goBack(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID));
}}
>
{translate('workspace.common.moreFeatures')}
diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
index 1675c78b2efb..9924d47764e8 100644
--- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
+++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
@@ -65,6 +65,16 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM
const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID;
const isCurrentUserAdmin = policy?.employeeList?.[personalDetails?.[currentUserPersonalDetails?.accountID]?.login ?? '']?.role === CONST.POLICY.ROLE.ADMIN;
const isCurrentUserOwner = policy?.owner === currentUserPersonalDetails?.login;
+ const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? -1] ?? ({} as PersonalDetails);
+ const policyOwnerDisplayName = ownerDetails.displayName ?? policy?.owner ?? '';
+
+ const confirmModalPrompt = useMemo(() => {
+ const isApprover = Member.isApprover(policy, accountID);
+ if (!isApprover) {
+ translate('workspace.people.removeMemberPrompt', {memberName: displayName});
+ }
+ return translate('workspace.people.removeMembersWarningPrompt', {memberName: displayName, ownerName: policyOwnerDisplayName});
+ }, [accountID, policy, displayName, policyOwnerDisplayName, translate]);
const roleItems: ListItemType[] = useMemo(
() => [
@@ -188,7 +198,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM
isVisible={isRemoveMemberConfirmModalVisible}
onConfirm={removeUser}
onCancel={() => setIsRemoveMemberConfirmModalVisible(false)}
- prompt={translate('workspace.people.removeMemberPrompt', {memberName: displayName})}
+ prompt={confirmModalPrompt}
confirmText={translate('common.remove')}
cancelText={translate('common.cancel')}
/>
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 7aacb77965c7..ebc30b5d2f0b 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -1799,7 +1799,7 @@ const styles = (theme: ThemeColors) =>
},
sidebarLinkActive: {
- backgroundColor: theme.buttonHoveredBG,
+ backgroundColor: theme.activeComponentBG,
textDecorationLine: 'none',
},
@@ -5013,6 +5013,20 @@ const styles = (theme: ThemeColors) =>
flex: 1,
},
+ computerIllustrationContainer: {
+ width: 272,
+ height: 188,
+ },
+
+ tripReservationIconContainer: {
+ width: variables.avatarSizeNormal,
+ height: variables.avatarSizeNormal,
+ backgroundColor: theme.border,
+ borderRadius: variables.componentBorderRadiusXLarge,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+
textLineThrough: {
textDecorationLine: 'line-through',
},
diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts
index 8df3750b57ac..b316f116c805 100644
--- a/src/styles/theme/themes/dark.ts
+++ b/src/styles/theme/themes/dark.ts
@@ -136,7 +136,7 @@ const darkTheme = {
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
},
[SCREENS.SETTINGS.TROUBLESHOOT]: {
- backgroundColor: colors.blue700,
+ backgroundColor: colors.productDark100,
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
},
[SCREENS.REFERRAL_DETAILS]: {
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index 16f403355ed2..05364515e264 100644
--- a/src/styles/theme/themes/light.ts
+++ b/src/styles/theme/themes/light.ts
@@ -136,8 +136,8 @@ const lightTheme = {
statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
},
[SCREENS.SETTINGS.TROUBLESHOOT]: {
- backgroundColor: colors.blue700,
- statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
+ backgroundColor: colors.productLight100,
+ statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
},
[SCREENS.REFERRAL_DETAILS]: {
backgroundColor: colors.pink800,
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index c963594684cb..812e2362e0b2 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -369,6 +369,10 @@ export default {
padding: 20,
},
+ pb6: {
+ padding: 24,
+ },
+
p8: {
padding: 32,
},
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 954321315da8..ec488cf42744 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -211,6 +211,7 @@ export default {
photoUploadPopoverWidth: 335,
onboardingModalWidth: 500,
welcomeVideoDelay: 1000,
+ explanationModalDelay: 2000,
// The height of the empty list is 14px (2px for borders and 12px for vertical padding)
// This is calculated based on the values specified in the 'getGoogleListViewStyle' function of the 'StyleUtils' utility
diff --git a/src/types/form/IssueNewExpensifyCardForm.ts b/src/types/form/IssueNewExpensifyCardForm.ts
new file mode 100644
index 000000000000..06ff2c421968
--- /dev/null
+++ b/src/types/form/IssueNewExpensifyCardForm.ts
@@ -0,0 +1,18 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ CARD_NAME: 'cardName',
+} as const;
+
+type InputID = ValueOf;
+
+type IssueNewExpensifyCardForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.CARD_NAME]: string;
+ }
+>;
+
+export type {IssueNewExpensifyCardForm};
+export default INPUT_IDS;
diff --git a/src/types/form/SageIntactCredentialsForm.ts b/src/types/form/SageIntactCredentialsForm.ts
new file mode 100644
index 000000000000..b70a28fbda8c
--- /dev/null
+++ b/src/types/form/SageIntactCredentialsForm.ts
@@ -0,0 +1,22 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ COMPANY_ID: 'companyID',
+ USER_ID: 'userID',
+ PASSWORD: 'password',
+} as const;
+
+type InputID = ValueOf;
+
+type SageIntactCredentialsForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.COMPANY_ID]: string;
+ [INPUT_IDS.USER_ID]: string;
+ [INPUT_IDS.PASSWORD]: string;
+ }
+>;
+
+export type {SageIntactCredentialsForm};
+export default INPUT_IDS;
diff --git a/src/types/form/index.ts b/src/types/form/index.ts
index 15339bf0acd7..97ca2c564f60 100644
--- a/src/types/form/index.ts
+++ b/src/types/form/index.ts
@@ -9,6 +9,7 @@ export type {GetPhysicalCardForm} from './GetPhysicalCardForm';
export type {HomeAddressForm} from './HomeAddressForm';
export type {IKnowTeacherForm} from './IKnowTeacherForm';
export type {IntroSchoolPrincipalForm} from './IntroSchoolPrincipalForm';
+export type {IssueNewExpensifyCardForm} from './IssueNewExpensifyCardForm';
export type {LegalNameForm} from './LegalNameForm';
export type {MoneyRequestAmountForm} from './MoneyRequestAmountForm';
export type {MoneyRequestDateForm} from './MoneyRequestDateForm';
@@ -53,4 +54,5 @@ export type {WalletAdditionalDetailsForm} from './WalletAdditionalDetailsForm';
export type {NewChatNameForm} from './NewChatNameForm';
export type {WorkForm} from './WorkForm';
export type {SubscriptionSizeForm} from './SubscriptionSizeForm';
+export type {SageIntactCredentialsForm} from './SageIntactCredentialsForm';
export type {default as Form} from './Form';
diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts
index 595104d4aed3..a490d24e9f17 100644
--- a/src/types/onyx/Card.ts
+++ b/src/types/onyx/Card.ts
@@ -83,5 +83,14 @@ type ExpensifyCardDetails = {
/** Record of Expensify cards, indexed by cardID */
type CardList = Record;
+/** Issue new card flow steps */
+type IssueNewCardStep = ValueOf;
+
+/** Model of Issue new card flow */
+type IssueNewCard = {
+ /** The current step of the flow */
+ currentStep: IssueNewCardStep;
+};
+
export default Card;
-export type {ExpensifyCardDetails, CardList};
+export type {ExpensifyCardDetails, CardList, IssueNewCard, IssueNewCardStep};
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index b309e9a36d2f..fab545a5fab5 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -949,6 +949,35 @@ type NetSuiteConnection = {
tokenSecret: string;
};
+/**
+ * Connection data for Sage Intacct
+ */
+// eslint-disable-next-line @typescript-eslint/ban-types
+type SageIntacctConnectionData = {};
+
+/**
+ * Connection config for Sage Intacct
+ */
+type SageIntacctConnectiosConfig = OnyxCommon.OnyxValueWithOfflineFeedback<{
+ /** Sage Intacct credentials */
+ credentials: {
+ /** Sage Intacct companyID */
+ companyID: string;
+
+ /** Sage Intacct password */
+ password: string;
+
+ /** Sage Intacct userID */
+ userID: string;
+ };
+
+ /** Collection of Sage Intacct config errors */
+ errors?: OnyxCommon.Errors;
+
+ /** Collection of form field errors */
+ errorFields?: OnyxCommon.ErrorFields;
+}>;
+
/** State of integration connection */
type Connection = {
/** State of the last synchronization */
@@ -971,6 +1000,9 @@ type Connections = {
/** NetSuite integration connection */
netsuite: NetSuiteConnection;
+
+ /** Sage Intacct integration connection */
+ intacct: Connection;
};
/** Names of integration connections */
@@ -1311,5 +1343,7 @@ export type {
QBOReimbursableExportAccountType,
QBOConnectionConfig,
XeroTrackingCategory,
+ NetSuiteConnection,
+ ConnectionLastSync,
NetSuiteSubsidiary,
};
diff --git a/src/types/onyx/PolicyEmployee.ts b/src/types/onyx/PolicyEmployee.ts
index 741e1e01ec05..91b7f1653821 100644
--- a/src/types/onyx/PolicyEmployee.ts
+++ b/src/types/onyx/PolicyEmployee.ts
@@ -8,12 +8,18 @@ type PolicyEmployee = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Email of the user */
email?: string;
- /** Email of the user this user forwards all approved reports to */
+ /** Determines if this employee should approve a report. If report total > approvalLimit, next approver will be 'overLimitForwardsTo', otherwise 'forwardsTo' */
+ approvalLimit?: number;
+
+ /** Email of the user this user forwards all approved reports to (when report total under 'approvalLimit' or when 'overLimitForwardsTo' is not set) */
forwardsTo?: string;
/** Email of the user this user submits all reports to */
submitsTo?: string;
+ /** Email of the user this user forwards all reports to when the report total is over the 'approvalLimit' */
+ overLimitForwardsTo?: string;
+
/**
* Errors from api calls on the specific user
* {: 'error message', : 'error message 2'}
diff --git a/src/types/onyx/TryNewDot.ts b/src/types/onyx/TryNewDot.ts
new file mode 100644
index 000000000000..67ea66d255d8
--- /dev/null
+++ b/src/types/onyx/TryNewDot.ts
@@ -0,0 +1,25 @@
+/**
+ * HybridApp NVP
+ */
+type TryNewDot = {
+ /**
+ * This key is mostly used on OldDot. In NewDot, we only use `completedHybridAppOnboarding`.
+ */
+ classicRedirect: {
+ /**
+ * Indicates if transistion from OldDot to NewDot should happen in HybridApp.
+ */
+ dismissed: boolean | string;
+ /**
+ * Indicates timestamp of an action.
+ */
+ timestamp: Date;
+
+ /**
+ * Indicates if explanation modal on NewDot was dismissed.
+ */
+ completedHybridAppOnboarding: boolean;
+ };
+};
+
+export default TryNewDot;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index 27e32f8e8c9c..82e497754d84 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -6,7 +6,7 @@ import type Beta from './Beta';
import type BillingGraceEndPeriod from './BillingGraceEndPeriod';
import type BlockedFromConcierge from './BlockedFromConcierge';
import type Card from './Card';
-import type {CardList} from './Card';
+import type {CardList, IssueNewCard} from './Card';
import type {CapturedLogs, Log} from './Console';
import type Credentials from './Credentials';
import type Currency from './Currency';
@@ -77,6 +77,7 @@ import type Transaction from './Transaction';
import type {TransactionViolation, ViolationName} from './TransactionViolation';
import type TransactionViolations from './TransactionViolation';
import type {TravelSettings} from './TravelSettings';
+import type TryNewDot from './TryNewDot';
import type User from './User';
import type UserLocation from './UserLocation';
import type UserMetadata from './UserMetadata';
@@ -90,6 +91,7 @@ import type WalletTransfer from './WalletTransfer';
import type WorkspaceRateAndUnit from './WorkspaceRateAndUnit';
export type {
+ TryNewDot,
Account,
AccountData,
BankAccount,
@@ -109,6 +111,7 @@ export type {
FundList,
IntroSelected,
IOU,
+ IssueNewCard,
Locale,
Login,
LoginList,
diff --git a/tests/e2e/ADDING_TESTS.md b/tests/e2e/ADDING_TESTS.md
index f262a5ed9a0a..92f6404203c1 100644
--- a/tests/e2e/ADDING_TESTS.md
+++ b/tests/e2e/ADDING_TESTS.md
@@ -72,7 +72,8 @@ const test = () => {
// ... do something with the measurements
E2EClient.submitTestResults({
name: "Navigate to report",
- duration: measurement.duration,
+ metric: measurement.duration,
+ unit: 'ms',
}).then(E2EClient.submitTestDone)
});
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index ea36172a52ff..1f590a474ad5 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -148,7 +148,8 @@ const someDurationWeCollected = // ...
E2EClient.submitTestResults({
name: 'My test name',
- duration: someDurationWeCollected,
+ metric: someDurationWeCollected,
+ unit: 'ms',
});
```
diff --git a/tests/e2e/TestSpec.yml b/tests/e2e/TestSpec.yml
index 3570bc11f3bb..59d33417cfe1 100644
--- a/tests/e2e/TestSpec.yml
+++ b/tests/e2e/TestSpec.yml
@@ -5,6 +5,7 @@ phases:
commands:
# Install correct version of node
- export NVM_DIR=$HOME/.nvm
+ - export AWS=true
- . $NVM_DIR/nvm.sh
# Note: Node v16 is the latest supported version of node for AWS Device Farm
# using v20 will not work!
diff --git a/tests/e2e/compare/compare.ts b/tests/e2e/compare/compare.ts
index c30900526bc2..685c5628633b 100644
--- a/tests/e2e/compare/compare.ts
+++ b/tests/e2e/compare/compare.ts
@@ -1,3 +1,4 @@
+import type {Unit} from '@libs/E2E/types';
import type {Stats} from '../measure/math';
import getStats from '../measure/math';
import * as math from './math';
@@ -28,7 +29,7 @@ const PROBABILITY_CONSIDERED_SIGNIFICANCE = 0.02;
*/
const DURATION_DIFF_THRESHOLD_SIGNIFICANCE = 100;
-function buildCompareEntry(name: string, compare: Stats, baseline: Stats): Entry {
+function buildCompareEntry(name: string, compare: Stats, baseline: Stats, unit: Unit): Entry {
const diff = compare.mean - baseline.mean;
const relativeDurationDiff = diff / baseline.mean;
@@ -38,6 +39,7 @@ function buildCompareEntry(name: string, compare: Stats, baseline: Stats): Entry
const isDurationDiffOfSignificance = prob < PROBABILITY_CONSIDERED_SIGNIFICANCE && Math.abs(diff) >= DURATION_DIFF_THRESHOLD_SIGNIFICANCE;
return {
+ unit,
name,
baseline,
current: compare,
@@ -50,7 +52,7 @@ function buildCompareEntry(name: string, compare: Stats, baseline: Stats): Entry
/**
* Compare results between baseline and current entries and categorize.
*/
-function compareResults(baselineEntries: Metric | string, compareEntries: Metric | string = baselineEntries) {
+function compareResults(baselineEntries: Metric | string, compareEntries: Metric | string = baselineEntries, metricForTest: Record = {}) {
// Unique test scenario names
const baselineKeys = Object.keys(baselineEntries ?? {});
const names = Array.from(new Set([...baselineKeys]));
@@ -66,7 +68,7 @@ function compareResults(baselineEntries: Metric | string, compareEntries: Metric
const deltaStats = getStats(current);
if (baseline && current) {
- compared.push(buildCompareEntry(name, deltaStats, currentStats));
+ compared.push(buildCompareEntry(name, deltaStats, currentStats, metricForTest[name]));
}
});
}
@@ -80,9 +82,9 @@ function compareResults(baselineEntries: Metric | string, compareEntries: Metric
};
}
-export default (main: Metric | string, delta: Metric | string, outputFile: string, outputFormat = 'all') => {
+export default (main: Metric | string, delta: Metric | string, outputFile: string, outputFormat = 'all', metricForTest = {}) => {
// IMPORTANT NOTE: make sure you are passing the main/baseline results first, then the delta/compare results:
- const outputData = compareResults(main, delta);
+ const outputData = compareResults(main, delta, metricForTest);
if (outputFormat === 'console' || outputFormat === 'all') {
printToConsole(outputData);
diff --git a/tests/e2e/compare/output/console.ts b/tests/e2e/compare/output/console.ts
index 77170e43f4a6..41ae5a4e0ccf 100644
--- a/tests/e2e/compare/output/console.ts
+++ b/tests/e2e/compare/output/console.ts
@@ -1,3 +1,4 @@
+import type {Unit} from '@libs/E2E/types';
import type {Stats} from '../../measure/math';
import * as format from './format';
@@ -8,6 +9,7 @@ type Entry = {
diff: number;
relativeDurationDiff: number;
isDurationDiffOfSignificance: boolean;
+ unit: Unit;
};
type Data = {
@@ -18,7 +20,7 @@ type Data = {
};
const printRegularLine = (entry: Entry) => {
- console.debug(` - ${entry.name}: ${format.formatDurationDiffChange(entry)}`);
+ console.debug(` - ${entry.name}: ${format.formatMetricDiffChange(entry)}`);
};
/**
diff --git a/tests/e2e/compare/output/format.ts b/tests/e2e/compare/output/format.ts
index 40c9e74d6247..f00684cd5a01 100644
--- a/tests/e2e/compare/output/format.ts
+++ b/tests/e2e/compare/output/format.ts
@@ -20,16 +20,16 @@ const formatPercentChange = (value: number): string => {
return `${value >= 0 ? '+' : '-'}${formatPercent(absValue)}`;
};
-const formatDuration = (duration: number): string => `${duration.toFixed(3)} ms`;
+const formatMetric = (duration: number, unit: string): string => `${duration.toFixed(3)} ${unit}`;
-const formatDurationChange = (value: number): string => {
+const formatMetricChange = (value: number, unit: string): string => {
if (value > 0) {
- return `+${formatDuration(value)}`;
+ return `+${formatMetric(value, unit)}`;
}
if (value < 0) {
- return `${formatDuration(value)}`;
+ return `${formatMetric(value, unit)}`;
}
- return '0 ms';
+ return `0 ${unit}`;
};
const formatChange = (value: number): string => {
@@ -69,13 +69,13 @@ const getDurationSymbols = (entry: Entry): string => {
return '';
};
-const formatDurationDiffChange = (entry: Entry): string => {
+const formatMetricDiffChange = (entry: Entry): string => {
const {baseline, current} = entry;
- let output = `${formatDuration(baseline.mean)} → ${formatDuration(current.mean)}`;
+ let output = `${formatMetric(baseline.mean, entry.unit)} → ${formatMetric(current.mean, entry.unit)}`;
if (baseline.mean !== current.mean) {
- output += ` (${formatDurationChange(entry.diff)}, ${formatPercentChange(entry.relativeDurationDiff)})`;
+ output += ` (${formatMetricChange(entry.diff, entry.unit)}, ${formatPercentChange(entry.relativeDurationDiff)})`;
}
output += ` ${getDurationSymbols(entry)}`;
@@ -83,4 +83,4 @@ const formatDurationDiffChange = (entry: Entry): string => {
return output;
};
-export {formatPercent, formatPercentChange, formatDuration, formatDurationChange, formatChange, getDurationSymbols, formatDurationDiffChange};
+export {formatPercent, formatPercentChange, formatMetric, formatMetricChange, formatChange, getDurationSymbols, formatMetricDiffChange};
diff --git a/tests/e2e/compare/output/markdown.ts b/tests/e2e/compare/output/markdown.ts
index 34bc3251c422..2e6ddfd5f03e 100644
--- a/tests/e2e/compare/output/markdown.ts
+++ b/tests/e2e/compare/output/markdown.ts
@@ -11,13 +11,13 @@ const tableHeader = ['Name', 'Duration'];
const collapsibleSection = (title: string, content: string) => `\n${title} \n\n${content}\n \n\n`;
-const buildDurationDetails = (title: string, entry: Stats) => {
+const buildDurationDetails = (title: string, entry: Stats, unit: string) => {
const relativeStdev = entry.stdev / entry.mean;
return [
`**${title}**`,
- `Mean: ${format.formatDuration(entry.mean)}`,
- `Stdev: ${format.formatDuration(entry.stdev)} (${format.formatPercent(relativeStdev)})`,
+ `Mean: ${format.formatMetric(entry.mean, unit)}`,
+ `Stdev: ${format.formatMetric(entry.stdev, unit)} (${format.formatPercent(relativeStdev)})`,
entry.entries ? `Runs: ${entry.entries.join(' ')}` : '',
]
.filter(Boolean)
@@ -25,7 +25,7 @@ const buildDurationDetails = (title: string, entry: Stats) => {
};
const buildDurationDetailsEntry = (entry: Entry) =>
- ['baseline' in entry ? buildDurationDetails('Baseline', entry.baseline) : '', 'current' in entry ? buildDurationDetails('Current', entry.current) : '']
+ ['baseline' in entry ? buildDurationDetails('Baseline', entry.baseline, entry.unit) : '', 'current' in entry ? buildDurationDetails('Current', entry.current, entry.unit) : '']
.filter(Boolean)
.join(' ');
@@ -33,15 +33,15 @@ const formatEntryDuration = (entry: Entry): string => {
let formattedDuration = '';
if ('baseline' in entry && 'current' in entry) {
- formattedDuration = format.formatDurationDiffChange(entry);
+ formattedDuration = format.formatMetricDiffChange(entry);
}
if ('baseline' in entry) {
- formattedDuration = format.formatDuration(entry.baseline.mean);
+ formattedDuration = format.formatMetric(entry.baseline.mean, entry.unit);
}
if ('current' in entry) {
- formattedDuration = format.formatDuration(entry.current.mean);
+ formattedDuration = format.formatMetric(entry.current.mean, entry.unit);
}
return formattedDuration;
diff --git a/tests/e2e/merge.ts b/tests/e2e/merge.ts
deleted file mode 100644
index d7c1b8699c7d..000000000000
--- a/tests/e2e/merge.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import compare from './compare/compare';
-import CONFIG from './config';
-
-const args = process.argv.slice(2);
-
-let mainPath = `${CONFIG.OUTPUT_DIR}/main.json`;
-if (args.includes('--mainPath')) {
- mainPath = args[args.indexOf('--mainPath') + 1];
-}
-
-let deltaPath = `${CONFIG.OUTPUT_DIR}/delta.json`;
-if (args.includes('--deltaPath')) {
- deltaPath = args[args.indexOf('--deltaPath') + 1];
-}
-
-let outputPath = `${CONFIG.OUTPUT_DIR}/output.md`;
-if (args.includes('--outputPath')) {
- outputPath = args[args.indexOf('--outputPath') + 1];
-}
-
-async function run() {
- await compare(mainPath, deltaPath, outputPath, 'all');
-
- process.exit(0);
-}
-
-run();
diff --git a/tests/e2e/testRunner.ts b/tests/e2e/testRunner.ts
index e5ebca5ad723..3556f311a393 100644
--- a/tests/e2e/testRunner.ts
+++ b/tests/e2e/testRunner.ts
@@ -17,7 +17,7 @@
import {execSync} from 'child_process';
import fs from 'fs';
import type {TestResult} from '@libs/E2E/client';
-import type {TestConfig} from '@libs/E2E/types';
+import type {TestConfig, Unit} from '@libs/E2E/types';
import compare from './compare/compare';
import defaultConfig from './config';
import createServerInstance from './server';
@@ -96,21 +96,19 @@ const runTests = async (): Promise => {
// Create a dict in which we will store the run durations for all tests
const results: Record = {};
+ const metricForTest: Record = {};
const attachTestResult = (testResult: TestResult) => {
let result = 0;
- if (testResult?.duration !== undefined) {
- if (testResult.duration < 0) {
+ if (testResult?.metric !== undefined) {
+ if (testResult.metric < 0) {
return;
}
- result = testResult.duration;
- }
- if (testResult?.renderCount !== undefined) {
- result = testResult.renderCount;
+ result = testResult.metric;
}
- Logger.log(`[LISTENER] Test '${testResult?.name}' on '${testResult?.branch}' measured ${result}`);
+ Logger.log(`[LISTENER] Test '${testResult?.name}' on '${testResult?.branch}' measured ${result}${testResult.unit}`);
if (testResult?.branch && !results[testResult.branch]) {
results[testResult.branch] = {};
@@ -119,6 +117,10 @@ const runTests = async (): Promise => {
if (testResult?.branch && testResult?.name) {
results[testResult.branch][testResult.name] = (results[testResult.branch][testResult.name] ?? []).concat(result);
}
+
+ if (!metricForTest[testResult.name] && testResult.unit) {
+ metricForTest[testResult.name] = testResult.unit;
+ }
};
// Collect results while tests are being executed
@@ -161,27 +163,32 @@ const runTests = async (): Promise => {
attachTestResult({
name: `${test.name} (CPU)`,
branch,
- duration: metrics.cpu,
+ metric: metrics.cpu,
+ unit: '%',
});
attachTestResult({
name: `${test.name} (FPS)`,
branch,
- duration: metrics.fps,
+ metric: metrics.fps,
+ unit: 'FPS',
});
attachTestResult({
name: `${test.name} (RAM)`,
branch,
- duration: metrics.ram,
+ metric: metrics.ram,
+ unit: 'MB',
});
attachTestResult({
name: `${test.name} (CPU/JS)`,
branch,
- duration: metrics.jsThread,
+ metric: metrics.jsThread,
+ unit: '%',
});
attachTestResult({
name: `${test.name} (CPU/UI)`,
branch,
- duration: metrics.uiThread,
+ metric: metrics.uiThread,
+ unit: '%',
});
}
removeListener();
@@ -282,7 +289,7 @@ const runTests = async (): Promise => {
// Calculate statistics and write them to our work file
Logger.info('Calculating statics and writing results');
- compare(results.main, results.delta, `${config.OUTPUT_DIR}/output.md`);
+ compare(results.main, results.delta, `${config.OUTPUT_DIR}/output.md`, 'all', metricForTest);
await server.stop();
};
diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx
index b5990ee5d002..f84c75823753 100644
--- a/tests/ui/UnreadIndicatorsTest.tsx
+++ b/tests/ui/UnreadIndicatorsTest.tsx
@@ -200,81 +200,104 @@ let reportAction9CreatedDate: string;
/**
* Sets up a test with a logged in user that has one unread chat from another user. Returns the test instance.
*/
-function signInAndGetAppWithUnreadChat(): Promise {
+async function signInAndGetAppWithUnreadChat() {
// Render the App and sign in as a test user.
render( );
- return waitForBatchedUpdatesWithAct()
- .then(async () => {
- await waitForBatchedUpdatesWithAct();
- const hintText = Localize.translateLocal('loginForm.loginForm');
- const loginForm = screen.queryAllByLabelText(hintText);
- expect(loginForm).toHaveLength(1);
-
- await act(async () => {
- await TestHelper.signInWithTestUser(USER_A_ACCOUNT_ID, USER_A_EMAIL, undefined, undefined, 'A');
- });
- return waitForBatchedUpdatesWithAct();
- })
- .then(() => {
- User.subscribeToUserEvents();
- return waitForBatchedUpdates();
- })
- .then(async () => {
- const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
- reportAction3CreatedDate = format(addSeconds(TEN_MINUTES_AGO, 30), CONST.DATE.FNS_DB_FORMAT_STRING);
- reportAction9CreatedDate = format(addSeconds(TEN_MINUTES_AGO, 90), CONST.DATE.FNS_DB_FORMAT_STRING);
-
- // Simulate setting an unread report and personal details
- await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {
- reportID: REPORT_ID,
- reportName: CONST.REPORT.DEFAULT_REPORT_NAME,
- lastReadTime: reportAction3CreatedDate,
- lastVisibleActionCreated: reportAction9CreatedDate,
- lastMessageText: 'Test',
- participants: {[USER_B_ACCOUNT_ID]: {hidden: false}},
- lastActorAccountID: USER_B_ACCOUNT_ID,
- type: CONST.REPORT.TYPE.CHAT,
- });
- const createdReportActionID = NumberUtils.rand64().toString();
- await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {
- [createdReportActionID]: {
- actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
- automatic: false,
- created: format(TEN_MINUTES_AGO, CONST.DATE.FNS_DB_FORMAT_STRING),
- reportActionID: createdReportActionID,
- message: [
- {
- style: 'strong',
- text: '__FAKE__',
- type: 'TEXT',
- },
- {
- style: 'normal',
- text: 'created this report',
- type: 'TEXT',
- },
- ],
+ await waitForBatchedUpdatesWithAct();
+ await waitForBatchedUpdatesWithAct();
+
+ const hintText = Localize.translateLocal('loginForm.loginForm');
+ const loginForm = screen.queryAllByLabelText(hintText);
+ expect(loginForm).toHaveLength(1);
+
+ await act(async () => {
+ await TestHelper.signInWithTestUser(USER_A_ACCOUNT_ID, USER_A_EMAIL, undefined, undefined, 'A');
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ User.subscribeToUserEvents();
+ await waitForBatchedUpdates();
+
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ reportAction3CreatedDate = format(addSeconds(TEN_MINUTES_AGO, 30), CONST.DATE.FNS_DB_FORMAT_STRING);
+ reportAction9CreatedDate = format(addSeconds(TEN_MINUTES_AGO, 90), CONST.DATE.FNS_DB_FORMAT_STRING);
+
+ // Simulate setting an unread report and personal details
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {
+ reportID: REPORT_ID,
+ reportName: CONST.REPORT.DEFAULT_REPORT_NAME,
+ lastReadTime: reportAction3CreatedDate,
+ lastVisibleActionCreated: reportAction9CreatedDate,
+ lastMessageText: 'Test',
+ participants: {[USER_B_ACCOUNT_ID]: {hidden: false}},
+ lastActorAccountID: USER_B_ACCOUNT_ID,
+ type: CONST.REPORT.TYPE.CHAT,
+ });
+ const createdReportActionID = NumberUtils.rand64().toString();
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {
+ [createdReportActionID]: {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ automatic: false,
+ created: format(TEN_MINUTES_AGO, CONST.DATE.FNS_DB_FORMAT_STRING),
+ reportActionID: createdReportActionID,
+ message: [
+ {
+ style: 'strong',
+ text: '__FAKE__',
+ type: 'TEXT',
},
- 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1', createdReportActionID),
- 2: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 20), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '2', '1'),
- 3: TestHelper.buildTestReportComment(reportAction3CreatedDate, USER_B_ACCOUNT_ID, '3', '2'),
- 4: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 40), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '4', '3'),
- 5: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 50), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '5', '4'),
- 6: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 60), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '6', '5'),
- 7: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 70), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '7', '6'),
- 8: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 80), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '8', '7'),
- 9: TestHelper.buildTestReportComment(reportAction9CreatedDate, USER_B_ACCOUNT_ID, '9', '8'),
- });
- await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
- [USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'),
- });
-
- // We manually setting the sidebar as loaded since the onLayout event does not fire in tests
- AppActions.setSidebarLoaded();
- return waitForBatchedUpdatesWithAct();
- });
+ {
+ style: 'normal',
+ text: 'created this report',
+ type: 'TEXT',
+ },
+ ],
+ },
+ 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1', createdReportActionID),
+ 2: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 20), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '2', '1'),
+ 3: TestHelper.buildTestReportComment(reportAction3CreatedDate, USER_B_ACCOUNT_ID, '3', '2'),
+ 4: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 40), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '4', '3'),
+ 5: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 50), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '5', '4'),
+ 6: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 60), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '6', '5'),
+ 7: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 70), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '7', '6'),
+ 8: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 80), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '8', '7'),
+ 9: TestHelper.buildTestReportComment(reportAction9CreatedDate, USER_B_ACCOUNT_ID, '9', '8'),
+ });
+ await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
+ [USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'),
+ });
+
+ // We manually setting the sidebar as loaded since the onLayout event does not fire in tests
+ AppActions.setSidebarLoaded();
+
+ await waitForBatchedUpdatesWithAct();
+}
+
+let lastComment = 'Current User Comment 1';
+async function addComment() {
+ const num = Number.parseInt(lastComment.slice(-1), 10);
+ lastComment = `${lastComment.slice(0, -1)}${num + 1}`;
+ const comment = lastComment;
+ const reportActionsBefore = (await TestHelper.onyxGet(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`)) as Record;
+ Report.addComment(REPORT_ID, comment);
+ const reportActionsAfter = (await TestHelper.onyxGet(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`)) as Record;
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const newReportActionID = Object.keys(reportActionsAfter).find((reportActionID) => !reportActionsBefore[reportActionID])!;
+ await act(() =>
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {
+ [newReportActionID]: {
+ previousReportActionID: '9',
+ },
+ }),
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ // Verify the comment is visible (it will appear twice, once in the LHN and once on the report screen)
+ expect(screen.getAllByText(comment)[0]).toBeOnTheScreen();
}
+const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
+
describe('Unread Indicators', () => {
afterEach(() => {
jest.clearAllMocks();
@@ -319,7 +342,6 @@ describe('Unread Indicators', () => {
expect(reportComments).toHaveLength(9);
// Since the last read timestamp is the timestamp of action 3 we should have an unread indicator above the next "unread" action which will
// have actionID of 4
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
expect(unreadIndicator).toHaveLength(1);
const reportActionID = unreadIndicator[0]?.props?.['data-action-id'];
@@ -335,7 +357,6 @@ describe('Unread Indicators', () => {
.then(async () => {
await act(() => transitionEndCB?.());
// Verify the unread indicator is present
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
expect(unreadIndicator).toHaveLength(1);
})
@@ -358,7 +379,6 @@ describe('Unread Indicators', () => {
})
.then(() => {
// Verify the unread indicator is not present
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
expect(unreadIndicator).toHaveLength(0);
// Tap on the chat again
@@ -366,7 +386,6 @@ describe('Unread Indicators', () => {
})
.then(() => {
// Verify the unread indicator is not present
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
expect(unreadIndicator).toHaveLength(0);
expect(areYouOnChatListScreen()).toBe(false);
@@ -476,7 +495,6 @@ describe('Unread Indicators', () => {
})
.then(() => {
// Verify the indicator appears above the last action
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
expect(unreadIndicator).toHaveLength(1);
const reportActionID = unreadIndicator[0]?.props?.['data-action-id'];
@@ -511,7 +529,6 @@ describe('Unread Indicators', () => {
return navigateToSidebarOption(0);
})
.then(() => {
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
expect(unreadIndicator).toHaveLength(0);
@@ -520,30 +537,23 @@ describe('Unread Indicators', () => {
return waitFor(() => expect(isNewMessagesBadgeVisible()).toBe(false));
}));
- it('Keep showing the new line indicator when a new message is created by the current user', () =>
- signInAndGetAppWithUnreadChat()
- .then(() => {
- // Verify we are on the LHN and that the chat shows as unread in the LHN
- expect(areYouOnChatListScreen()).toBe(true);
+ it('Keep showing the new line indicator when a new message is created by the current user', async () => {
+ await signInAndGetAppWithUnreadChat();
- // Navigate to the report and verify the indicator is present
- return navigateToSidebarOption(0);
- })
- .then(async () => {
- await act(() => transitionEndCB?.());
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
- const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
- expect(unreadIndicator).toHaveLength(1);
+ // Verify we are on the LHN and that the chat shows as unread in the LHN
+ expect(areYouOnChatListScreen()).toBe(true);
- // Leave a comment as the current user and verify the indicator is removed
- Report.addComment(REPORT_ID, 'Current User Comment 1');
- return waitForBatchedUpdates();
- })
- .then(() => {
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
- const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
- expect(unreadIndicator).toHaveLength(1);
- }));
+ // Navigate to the report and verify the indicator is present
+ await navigateToSidebarOption(0);
+ await act(() => transitionEndCB?.());
+ let unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
+ expect(unreadIndicator).toHaveLength(1);
+
+ // Leave a comment as the current user and verify the indicator is not removed
+ await addComment();
+ unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
+ expect(unreadIndicator).toHaveLength(1);
+ });
xit('Keeps the new line indicator when the user moves the App to the background', () =>
signInAndGetAppWithUnreadChat()
@@ -555,7 +565,6 @@ describe('Unread Indicators', () => {
return navigateToSidebarOption(0);
})
.then(() => {
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
expect(unreadIndicator).toHaveLength(1);
@@ -564,7 +573,6 @@ describe('Unread Indicators', () => {
})
.then(() => navigateToSidebarOption(0))
.then(() => {
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
expect(unreadIndicator).toHaveLength(0);
@@ -573,7 +581,6 @@ describe('Unread Indicators', () => {
return waitForBatchedUpdates();
})
.then(() => {
- const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
let unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
expect(unreadIndicator).toHaveLength(1);
@@ -597,11 +604,10 @@ describe('Unread Indicators', () => {
signInAndGetAppWithUnreadChat()
// Navigate to the chat and simulate leaving a comment from the current user
.then(() => navigateToSidebarOption(0))
- .then(() => {
+ .then(() =>
// Leave a comment as the current user
- Report.addComment(REPORT_ID, 'Current User Comment 1');
- return waitForBatchedUpdates();
- })
+ addComment(),
+ )
.then(() => {
// Simulate the response from the server so that the comment can be deleted in this test
lastReportAction = reportActions ? CollectionUtils.lastItem(reportActions) : undefined;
@@ -619,7 +625,7 @@ describe('Unread Indicators', () => {
expect(alternateText).toHaveLength(1);
// This message is visible on the sidebar and the report screen, so there are two occurrences.
- expect(screen.getAllByText('Current User Comment 1')[0]).toBeOnTheScreen();
+ expect(screen.getAllByText(lastComment)[0]).toBeOnTheScreen();
if (lastReportAction) {
Report.deleteReportComment(REPORT_ID, lastReportAction);
diff --git a/tests/utils/TestHelper.ts b/tests/utils/TestHelper.ts
index 9ca0969abc6a..dffb2b4e312a 100644
--- a/tests/utils/TestHelper.ts
+++ b/tests/utils/TestHelper.ts
@@ -1,5 +1,6 @@
import {Str} from 'expensify-common';
import Onyx from 'react-native-onyx';
+import type {ConnectOptions, OnyxKey} from 'react-native-onyx';
import CONST from '@src/CONST';
import * as Session from '@src/libs/actions/Session';
import HttpUtils from '@src/libs/HttpUtils';
@@ -247,5 +248,33 @@ const createAddListenerMock = () => {
return {triggerTransitionEnd, addListener};
};
+/**
+ * Get an Onyx value. Only for use in tests for now.
+ */
+async function onyxGet(key: OnyxKey): Promise>['callback']>[0]> {
+ return new Promise((resolve) => {
+ // eslint-disable-next-line rulesdir/prefer-onyx-connect-in-libs
+ // @ts-expect-error This does not need more strict type checking as it's only for tests
+ const connectionID = Onyx.connect({
+ key,
+ callback: (value) => {
+ Onyx.disconnect(connectionID);
+ resolve(value);
+ },
+ waitForCollectionCallback: true,
+ });
+ });
+}
+
export type {MockFetch, FormData};
-export {assertFormDataMatchesObject, buildPersonalDetails, buildTestReportComment, createAddListenerMock, getGlobalFetchMock, setPersonalDetails, signInWithTestUser, signOutTestUser};
+export {
+ assertFormDataMatchesObject,
+ buildPersonalDetails,
+ buildTestReportComment,
+ createAddListenerMock,
+ getGlobalFetchMock,
+ setPersonalDetails,
+ signInWithTestUser,
+ signOutTestUser,
+ onyxGet,
+};
diff --git a/tests/utils/debug.ts b/tests/utils/debug.ts
new file mode 100644
index 000000000000..b33acdf1d5d4
--- /dev/null
+++ b/tests/utils/debug.ts
@@ -0,0 +1,114 @@
+/**
+ * The debug utility that ships with react native testing library does not work properly and
+ * has limited functionality. This is a better version of it that allows logging a subtree of
+ * the app.
+ */
+
+/* eslint-disable no-console, testing-library/no-node-access, testing-library/no-debugging-utils, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
+import type {NewPlugin} from 'pretty-format';
+import prettyFormat, {plugins} from 'pretty-format';
+import ReactIs from 'react-is';
+import type {ReactTestInstance, ReactTestRendererJSON} from 'react-test-renderer';
+
+// These are giant objects and cause the serializer to crash because the
+// output becomes too large.
+const NativeComponentPlugin: NewPlugin = {
+ // eslint-disable-next-line no-underscore-dangle
+ test: (val) => !!val?._reactInternalInstance,
+ serialize: () => 'NativeComponentInstance {}',
+};
+
+type Options = {
+ includeProps?: boolean;
+ maxDepth?: number;
+};
+
+const format = (input: ReactTestRendererJSON | ReactTestRendererJSON[], options: Options) =>
+ prettyFormat(input, {
+ plugins: [plugins.ReactTestComponent, plugins.ReactElement, NativeComponentPlugin],
+ highlight: true,
+ printBasicPrototype: false,
+ maxDepth: options.maxDepth,
+ });
+
+function getType(element: any) {
+ const type = element.type;
+ if (typeof type === 'string') {
+ return type;
+ }
+ if (typeof type === 'function') {
+ return type.displayName || type.name || 'Unknown';
+ }
+
+ if (ReactIs.isFragment(element)) {
+ return 'React.Fragment';
+ }
+ if (ReactIs.isSuspense(element)) {
+ return 'React.Suspense';
+ }
+ if (typeof type === 'object' && type !== null) {
+ if (ReactIs.isContextProvider(element)) {
+ return 'Context.Provider';
+ }
+
+ if (ReactIs.isContextConsumer(element)) {
+ return 'Context.Consumer';
+ }
+
+ if (ReactIs.isForwardRef(element)) {
+ if (type.displayName) {
+ return type.displayName;
+ }
+
+ const functionName = type.render.displayName || type.render.name || '';
+
+ return functionName === '' ? 'ForwardRef' : `ForwardRef(${functionName})`;
+ }
+
+ if (ReactIs.isMemo(element)) {
+ const functionName = type.displayName || type.type.displayName || type.type.name || '';
+
+ return functionName === '' ? 'Memo' : `Memo(${functionName})`;
+ }
+ }
+ return 'UNDEFINED';
+}
+
+function getProps(props: Record, options: Options) {
+ if (!options.includeProps) {
+ return {};
+ }
+ const {children, ...propsWithoutChildren} = props;
+ return propsWithoutChildren;
+}
+
+function toJSON(node: ReactTestInstance, options: Options): ReactTestRendererJSON {
+ const json = {
+ $$typeof: Symbol.for('react.test.json'),
+ type: getType({type: node.type, $$typeof: Symbol.for('react.element')}),
+ props: getProps(node.props, options),
+ children: node.children?.map((c) => (typeof c === 'string' ? c : toJSON(c, options))) ?? null,
+ };
+
+ return json;
+}
+
+function formatNode(node: ReactTestInstance, options: Options) {
+ return format(toJSON(node, options), options);
+}
+
+/**
+ * Log a subtree of the app for debugging purposes.
+ *
+ * @example debug(screen.getByTestId('report-actions-view-wrapper'));
+ */
+export default function debug(node: ReactTestInstance | ReactTestInstance[] | null, {includeProps = true, maxDepth = Infinity}: Options = {}): void {
+ const options = {includeProps, maxDepth};
+ if (node == null) {
+ console.log('null');
+ } else if (Array.isArray(node)) {
+ console.log(node.map((n) => formatNode(n, options)).join('\n'));
+ } else {
+ console.log(formatNode(node, options));
+ }
+}