diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml
index 7e1b5fbbae90..0b32d8ee6dc1 100644
--- a/.github/actions/composite/setupNode/action.yml
+++ b/.github/actions/composite/setupNode/action.yml
@@ -1,6 +1,11 @@
name: Set up Node
description: Set up Node
+outputs:
+ cache-hit:
+ description: Was there a cache hit on the main node_modules?
+ value: ${{ steps.cache-node-modules.outputs.cache-hit }}
+
runs:
using: composite
steps:
diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml
index d2e0ec4f38e5..9eb5bc6eb409 100644
--- a/.github/workflows/failureNotifier.yml
+++ b/.github/workflows/failureNotifier.yml
@@ -88,7 +88,7 @@ jobs:
repo: context.repo.repo,
title: issueTitle,
body: issueBody,
- labels: [failureLabel, 'Daily'],
+ labels: [failureLabel, 'Hourly'],
assignees: [prMerger]
});
}
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index 7c7b51240fdb..04de0f5b5deb 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -184,6 +184,7 @@ jobs:
run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
- name: Setup Node
+ id: setup-node
uses: ./.github/actions/composite/setupNode
- name: Setup Ruby
@@ -206,7 +207,7 @@ jobs:
- name: Install cocoapods
uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350
- if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true'
+ if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true'
with:
timeout_minutes: 10
max_attempts: 5
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 12214e078c9c..99d7a186e7ee 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001044310
- versionName "1.4.43-10"
+ versionCode 1001044318
+ versionName "1.4.43-18"
}
flavorDimensions "default"
diff --git a/assets/images/folder.svg b/assets/images/folder.svg
new file mode 100644
index 000000000000..17cef959132f
--- /dev/null
+++ b/assets/images/folder.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/product-illustrations/emptystate__expenses.svg b/assets/images/product-illustrations/emptystate__expenses.svg
new file mode 100644
index 000000000000..c01a89109cbf
--- /dev/null
+++ b/assets/images/product-illustrations/emptystate__expenses.svg
@@ -0,0 +1,58 @@
+
diff --git a/assets/images/product-illustrations/mushroom-top-hat.svg b/assets/images/product-illustrations/mushroom-top-hat.svg
new file mode 100644
index 000000000000..cb808f7289e0
--- /dev/null
+++ b/assets/images/product-illustrations/mushroom-top-hat.svg
@@ -0,0 +1,142 @@
+
+
+
diff --git a/assets/images/simple-illustrations/simple-illustration__approval.svg b/assets/images/simple-illustrations/simple-illustration__approval.svg
new file mode 100644
index 000000000000..bdef2436958b
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__approval.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__folder-open.svg b/assets/images/simple-illustrations/simple-illustration__folder-open.svg
new file mode 100644
index 000000000000..c104313a9b6c
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__folder-open.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg b/assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg
new file mode 100644
index 000000000000..fc7082e9932c
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg
@@ -0,0 +1 @@
+
diff --git a/assets/images/simple-illustrations/simple-illustration__wallet-alt.svg b/assets/images/simple-illustrations/simple-illustration__wallet-alt.svg
new file mode 100644
index 000000000000..33d1fc0fa044
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__wallet-alt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__workflows.svg b/assets/images/simple-illustrations/simple-illustration__workflows.svg
new file mode 100644
index 000000000000..47d30d54310f
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__workflows.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/workflows.svg b/assets/images/workflows.svg
new file mode 100644
index 000000000000..24156c66eb69
--- /dev/null
+++ b/assets/images/workflows.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md
index 09dd4de2867b..3fe5ec41f5f6 100644
--- a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md
+++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md
@@ -3,7 +3,7 @@ title: Billing Overview
description: An overview of how billing works in Expensify.
---
# Overview
-Expensify’s billing is based on monthly member activity. At the beginning of each month, you’ll be billed for the previous month’s activity. Your Expensify bill ultimately depends on your plan type, whether you're on an annual subscription or pay-per-use billing, and whether you’re using Expensify Cards.
+Expensify’s billing is based on monthly member activity. At the beginning of each month, you’ll be billed for the previous month’s activity. Your Expensify bill ultimately depends on your plan type, whether you're on an annual subscription or pay-per-use billing, and whether you’re using the Expensify Visa® Commercial Card.
# How billing works in Expensify
Expensify bills the owners of Group Workspaces on the first of each month for the previous month's usage. You can find billing receipts in **Settings > Account > Payments > Billing History**. We recommend that businesses have one billing owner for all of their Group Workspaces.
## Active members
@@ -23,7 +23,7 @@ Bundling the Expensify Card with an annual subscription ensures you pay the lowe
If at least 50% of your approved USD spend in a given month is on your company’s Expensify Cards, you will receive an additional 50% discount on the price per member. This additional 50% discount, when coupled with an annual subscription, brings the price per member to $5 on a Collect plan and $9 on a Control plan.
-Additionally, every month, you receive 1% cash back on all Expensify Card purchases, and 2% if the spend across your Expensify Cards is $250k or more. Any cash back from the Expensify Card is first applied to your Expensify bill, further reducing your price per member. Any leftover cash back is deposited directly into your connected bank account.
+Additionally, every month, you receive 1% cash back on all Expensify Card purchases, and 2% if the spend across your Expensify Cards is $250k or more (_applies to USD purchases only_). Any cash back from the Expensify Card is first applied to your Expensify bill, further reducing your price per member. Any leftover cash back is deposited directly into your connected bank account.
## Savings calculator
To see how much money you can save (and even earn!) by using the Expensify Card, check out our [savings calculator](https://use.expensify.com/price-savings-calculator). Just enter a few details and see how much you’ll save!
{% include faq-begin.md %}
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 8e160e3bcdf2..4ed309467f13 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -25,16 +25,16 @@ https://community.expensify.com/discussion/5194/how-to-assign-a-vacation-delegat
https://community.expensify.com/discussion/5190/how-to-individually-assign-a-vacation-delegate-from-account-settings,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate
https://community.expensify.com/discussion/5274/how-to-set-up-an-adp-indirect-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/integrations/HR-integrations/ADP
https://community.expensify.com/discussion/5776/how-to-create-mileage-expenses-in-expensify,https://help.expensify.com/articles/expensify-classic/get-paid-back/Distance-Tracking
-https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
-https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
-https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
-https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
-https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details
+https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks.html,https://use.expensify.com/company-credit-card
https://help.expensify.com/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.html,https://use.expensify.com/accountants-program
https://help.expensify.com/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners, https://use.expensify.com/blog/maximizing-rewards-expensifyapproved-accounting-partners-now-earn-0-5-revenue-share
https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements
-https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts
+https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts
https://community.expensify.com/discussion/4783/how-to-add-or-remove-a-copilot,https://help.expensify.com/articles/expensify-classic/account-settings/Copilot
https://community.expensify.com/discussion/4343/expensify-anz-partnership-announcement,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-ANZ
https://community.expensify.com/discussion/7318/deep-dive-company-credit-card-import-options,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards
@@ -55,3 +55,8 @@ https://help.expensify.com/articles/expensify-classic/getting-started/Using-The-
https://help.expensify.com/articles/expensify-classic/getting-started/support/Expensify-Support,https://use.expensify.com/support
https://help.expensify.com/articles/expensify-classic/getting-started/Plan-Types,https://use.expensify.com/
https://help.expensify.com/articles/new-expensify/payments/Referral-Program,https://help.expensify.com/articles/expensify-classic/get-paid-back/Referral-Program
+https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://help.expensify.com/articles/expensify-classic/account-settings/Preferences,https://help.expensify.com/expensify-classic/hubs/settings/account-settings
+https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts
+https://help.expensify.com/articles/expensify-classic/getting-started/Individual-Users,https://help.expensify.com/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself
+https://help.expensify.com/articles/expensify-classic/getting-started/Invite-Members,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 4896f59a80ba..574657c8c3f4 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.43.10
+ 1.4.43.18
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 15ac653ece50..e4962c94df8d 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.43.10
+ 1.4.43.18
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index d07825cbc392..308c4314ee68 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
1.4.43
CFBundleVersion
- 1.4.43.10
+ 1.4.43.18
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 33ecd9d9ecc5..aab783e8bbb7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.43-10",
+ "version": "1.4.43-18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.43-10",
+ "version": "1.4.43-18",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -38,6 +38,7 @@
"@react-native-picker/picker": "2.5.1",
"@react-navigation/material-top-tabs": "^6.6.3",
"@react-navigation/native": "6.1.8",
+ "@react-navigation/native-stack": "^6.9.17",
"@react-navigation/stack": "6.3.16",
"@react-ng/bounds-observer": "^0.2.1",
"@rnmapbox/maps": "^10.1.11",
@@ -97,7 +98,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.2",
+ "react-native-onyx": "2.0.6",
"react-native-pager-view": "6.2.2",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -236,7 +237,7 @@
"style-loader": "^2.0.0",
"time-analytics-webpack-plugin": "^0.1.17",
"ts-node": "^10.9.2",
- "type-fest": "^3.12.0",
+ "type-fest": "^4.10.2",
"typescript": "^5.3.2",
"wait-port": "^0.2.9",
"webpack": "^5.76.0",
@@ -8138,9 +8139,9 @@
}
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
- "version": "0.5.10",
- "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz",
- "integrity": "sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==",
+ "version": "0.5.11",
+ "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz",
+ "integrity": "sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==",
"dev": true,
"dependencies": {
"ansi-html-community": "^0.0.8",
@@ -8160,7 +8161,7 @@
"@types/webpack": "4.x || 5.x",
"react-refresh": ">=0.10.0 <1.0.0",
"sockjs-client": "^1.4.0",
- "type-fest": ">=0.17.0 <4.0.0",
+ "type-fest": ">=0.17.0 <5.0.0",
"webpack": ">=4.43.0 <6.0.0",
"webpack-dev-server": "3.x || 4.x",
"webpack-hot-middleware": "2.x",
@@ -10258,6 +10259,17 @@
"react": "*"
}
},
+ "node_modules/@react-navigation/elements": {
+ "version": "1.3.21",
+ "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.21.tgz",
+ "integrity": "sha512-eyS2C6McNR8ihUoYfc166O1D8VYVh9KIl0UQPI8/ZJVsStlfSTgeEEh+WXge6+7SFPnZ4ewzEJdSAHH+jzcEfg==",
+ "peerDependencies": {
+ "@react-navigation/native": "^6.0.0",
+ "react": "*",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 3.0.0"
+ }
+ },
"node_modules/@react-navigation/material-top-tabs": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/@react-navigation/material-top-tabs/-/material-top-tabs-6.6.3.tgz",
@@ -10289,6 +10301,22 @@
"react-native": "*"
}
},
+ "node_modules/@react-navigation/native-stack": {
+ "version": "6.9.17",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-6.9.17.tgz",
+ "integrity": "sha512-X8p8aS7JptQq7uZZNFEvfEcPf6tlK4PyVwYDdryRbG98B4bh2wFQYMThxvqa+FGEN7USEuHdv2mF0GhFKfX0ew==",
+ "dependencies": {
+ "@react-navigation/elements": "^1.3.21",
+ "warn-once": "^0.1.0"
+ },
+ "peerDependencies": {
+ "@react-navigation/native": "^6.0.0",
+ "react": "*",
+ "react-native": "*",
+ "react-native-safe-area-context": ">= 3.0.0",
+ "react-native-screens": ">= 3.0.0"
+ }
+ },
"node_modules/@react-navigation/routers": {
"version": "6.1.9",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz",
@@ -10315,17 +10343,6 @@
"react-native-screens": ">= 3.0.0"
}
},
- "node_modules/@react-navigation/stack/node_modules/@react-navigation/elements": {
- "version": "1.3.17",
- "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz",
- "integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA==",
- "peerDependencies": {
- "@react-navigation/native": "^6.0.0",
- "react": "*",
- "react-native": "*",
- "react-native-safe-area-context": ">= 3.0.0"
- }
- },
"node_modules/@react-ng/bounds-observer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@react-ng/bounds-observer/-/bounds-observer-0.2.1.tgz",
@@ -26853,10 +26870,11 @@
}
},
"node_modules/core-js-pure": {
- "version": "3.24.1",
+ "version": "3.36.0",
+ "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.36.0.tgz",
+ "integrity": "sha512-cN28qmhRNgbMZZMc/RFu5w8pK9VJzpb2rJVR/lHuZJKwmXnoWOpXmMkxqBB514igkp1Hu8WGROsiOAzUcKdHOQ==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -38605,6 +38623,17 @@
"node": ">=8"
}
},
+ "node_modules/jest-watch-typeahead/node_modules/type-fest": {
+ "version": "3.13.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
+ "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/jest-watcher": {
"version": "29.4.1",
"license": "MIT",
@@ -45141,9 +45170,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.2.tgz",
- "integrity": "sha512-24kcG3ChBXp+uSSCXudFvZTdCnKLRHQRgvTcnh2eA7COtKvbL8ggEJNkglSYmcf5WoDzLgYyWiKvcjcXQnmBvw==",
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.6.tgz",
+ "integrity": "sha512-qsCxvNKc+mq/Y74v6Twe7VZxqgfpjBm0997R8OEtCUJEtgAp0riCQ3GvuVVIWYALz3S+ADokEAEPzeFW2frtpw==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -50832,11 +50861,12 @@
}
},
"node_modules/type-fest": {
- "version": "3.13.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
- "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+ "version": "4.10.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.3.tgz",
+ "integrity": "sha512-JLXyjizi072smKGGcZiAJDCNweT8J+AuRxmPZ1aG7TERg4ijx9REl8CNhbr36RV4qXqL1gO1FF9HL8OkVmmrsA==",
+ "dev": true,
"engines": {
- "node": ">=14.16"
+ "node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
diff --git a/package.json b/package.json
index d05b2be0e76d..f5ff807cdbec 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.43-10",
+ "version": "1.4.43-18",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -86,6 +86,7 @@
"@react-native-picker/picker": "2.5.1",
"@react-navigation/material-top-tabs": "^6.6.3",
"@react-navigation/native": "6.1.8",
+ "@react-navigation/native-stack": "^6.9.17",
"@react-navigation/stack": "6.3.16",
"@react-ng/bounds-observer": "^0.2.1",
"@rnmapbox/maps": "^10.1.11",
@@ -145,7 +146,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "2.0.2",
+ "react-native-onyx": "2.0.6",
"react-native-pager-view": "6.2.2",
"react-native-pdf": "6.7.3",
"react-native-performance": "^5.1.0",
@@ -284,7 +285,7 @@
"style-loader": "^2.0.0",
"time-analytics-webpack-plugin": "^0.1.17",
"ts-node": "^10.9.2",
- "type-fest": "^3.12.0",
+ "type-fest": "^4.10.2",
"typescript": "^5.3.2",
"wait-port": "^0.2.9",
"webpack": "^5.76.0",
diff --git a/patches/@react-navigation+native-stack+6.9.17.patch b/patches/@react-navigation+native-stack+6.9.17.patch
new file mode 100644
index 000000000000..933ca6ce792e
--- /dev/null
+++ b/patches/@react-navigation+native-stack+6.9.17.patch
@@ -0,0 +1,39 @@
+diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx
+index 206fb0b..7a34a8e 100644
+--- a/node_modules/@react-navigation/native-stack/src/types.tsx
++++ b/node_modules/@react-navigation/native-stack/src/types.tsx
+@@ -490,6 +490,14 @@ export type NativeStackNavigationOptions = {
+ * Only supported on iOS and Android.
+ */
+ freezeOnBlur?: boolean;
++ // partial changes from https://github.com/react-navigation/react-navigation/commit/90cfbf23bcc5259f3262691a9eec6c5b906e5262
++ // patch can be removed when new version of `native-stack` will be released
++ /**
++ * Whether the keyboard should hide when swiping to the previous screen. Defaults to `false`.
++ *
++ * Only supported on iOS
++ */
++ keyboardHandlingEnabled?: boolean;
+ };
+
+ export type NativeStackNavigatorProps = DefaultNavigatorOptions<
+diff --git a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx
+index a005c43..03d8b50 100644
+--- a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx
++++ b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx
+@@ -161,6 +161,7 @@ const SceneView = ({
+ statusBarTranslucent,
+ statusBarColor,
+ freezeOnBlur,
++ keyboardHandlingEnabled,
+ } = options;
+
+ let {
+@@ -289,6 +290,7 @@ const SceneView = ({
+ onNativeDismissCancelled={onNativeDismissCancelled}
+ // this prop is available since rn-screens 3.16
+ freezeOnBlur={freezeOnBlur}
++ hideKeyboardOnSwipe={keyboardHandlingEnabled}
+ >
+
+
diff --git a/patches/expo-av+13.10.4.patch b/patches/expo-av+13.10.4.patch
new file mode 100644
index 000000000000..c7b1626e233a
--- /dev/null
+++ b/patches/expo-av+13.10.4.patch
@@ -0,0 +1,17 @@
+diff --git a/node_modules/expo-av/android/build.gradle b/node_modules/expo-av/android/build.gradle
+index 2d68ca6..c3fa3c5 100644
+--- a/node_modules/expo-av/android/build.gradle
++++ b/node_modules/expo-av/android/build.gradle
+@@ -7,10 +7,11 @@ apply plugin: 'maven-publish'
+ group = 'host.exp.exponent'
+ version = '13.10.4'
+
++def REACT_NATIVE_PATH = this.hasProperty('reactNativeProject') ? this.reactNativeProject + '/node_modules/react-native/package.json' : 'react-native/package.json'
+ def REACT_NATIVE_BUILD_FROM_SOURCE = findProject(":ReactAndroid") != null
+ def REACT_NATIVE_DIR = REACT_NATIVE_BUILD_FROM_SOURCE
+ ? findProject(":ReactAndroid").getProjectDir().parent
+- : new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).parent
++ : new File(["node", "--print", "require.resolve('${REACT_NATIVE_PATH}')"].execute(null, rootDir).text.trim()).parent
+
+ def reactNativeArchitectures() {
+ def value = project.getProperties().get("reactNativeArchitectures")
diff --git a/src/App.tsx b/src/App.tsx
index 9562ea647e25..cbe5948f8d4e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,7 +2,6 @@ import {PortalProvider} from '@gorhom/portal';
import React from 'react';
import {LogBox} from 'react-native';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
-import Onyx from 'react-native-onyx';
import {PickerStateProvider} from 'react-native-picker-select';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import '../wdyr';
@@ -31,8 +30,6 @@ import {WindowDimensionsProvider} from './components/withWindowDimensions';
import Expensify from './Expensify';
import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop';
import OnyxUpdateManager from './libs/actions/OnyxUpdateManager';
-import * as Session from './libs/actions/Session';
-import * as Environment from './libs/Environment/Environment';
import InitialUrlContext from './libs/InitialUrlContext';
import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext';
import type {Route} from './ROUTES';
@@ -42,12 +39,6 @@ type AppProps = {
url?: Route;
};
-// For easier debugging and development, when we are in web we expose Onyx to the window, so you can more easily set data into Onyx
-if (window && Environment.isDevelopment()) {
- window.Onyx = Onyx;
- window.setSupportToken = Session.setSupportAuthToken;
-}
-
LogBox.ignoreLogs([
// Basically it means that if the app goes in the background and back to foreground on Android,
// the timer is lost. Currently Expensify is using a 30 minutes interval to refresh personal details.
diff --git a/src/CONST.ts b/src/CONST.ts
index 6a57738d06ec..8abd4c087b16 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -895,6 +895,7 @@ const CONST = {
DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'},
DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
+ DEFAULT_NETWORK_DATA: {isOffline: false},
FORMS: {
LOGIN_FORM: 'LoginForm',
VALIDATE_CODE_FORM: 'ValidateCodeForm',
@@ -951,6 +952,9 @@ const CONST = {
EMOJI_DEFAULT_SKIN_TONE: -1,
+ // Amount of emojis to render ahead at the end of the update cycle
+ EMOJI_DRAW_AMOUNT: 250,
+
INVISIBLE_CODEPOINTS: ['fe0f', '200d', '2066'],
UNICODE: {
@@ -1554,7 +1558,9 @@ const CONST = {
WORKSPACE_INVOICES: 'WorkspaceSendInvoices',
WORKSPACE_TRAVEL: 'WorkspaceBookTravel',
WORKSPACE_MEMBERS: 'WorkspaceManageMembers',
+ WORKSPACE_WORKFLOWS: 'WorkspaceWorkflows',
WORKSPACE_BANK_ACCOUNT: 'WorkspaceBankAccount',
+ WORKSPACE_SETTINGS: 'WorkspaceSettings',
},
get EXPENSIFY_EMAILS() {
return [
@@ -3106,6 +3112,8 @@ const CONST = {
*/
ADDITIONAL_ALLOWED_CHARACTERS: 20,
+ VALIDATION_REIMBURSEMENT_INPUT_LIMIT: 20,
+
REFERRAL_PROGRAM: {
CONTENT_TYPES: {
MONEY_REQUEST: 'request',
@@ -3303,6 +3311,14 @@ const CONST = {
ADDRESS: 3,
},
},
+
+ EXIT_SURVEY: {
+ REASONS: {
+ FEATURE_NOT_AVAILABLE: 'featureNotAvailable',
+ DONT_UNDERSTAND: 'dontUnderstand',
+ PREFER_CLASSIC: 'preferClassic',
+ },
+ },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 9d35994875e1..f0b400687b12 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -205,6 +205,9 @@ const ONYXKEYS = {
/** Is report data loading? */
IS_LOADING_APP: 'isLoadingApp',
+ /** Is the user in the process of switching to OldDot? */
+ IS_SWITCHING_TO_OLD_DOT: 'isSwitchingToOldDot',
+
/** Is the test tools modal open? */
IS_TEST_TOOLS_MODAL_OPEN: 'isTestToolsModalOpen',
@@ -388,6 +391,10 @@ const ONYXKEYS = {
REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft',
PERSONAL_BANK_ACCOUNT: 'personalBankAccountForm',
PERSONAL_BANK_ACCOUNT_DRAFT: 'personalBankAccountFormDraft',
+ EXIT_SURVEY_REASON_FORM: 'exitSurveyReasonForm',
+ EXIT_SURVEY_REASON_FORM_DRAFT: 'exitSurveyReasonFormDraft',
+ EXIT_SURVEY_RESPONSE_FORM: 'exitSurveyResponseForm',
+ EXIT_SURVEY_RESPONSE_FORM_DRAFT: 'exitSurveyResponseFormDraft',
},
} as const;
@@ -396,36 +403,37 @@ type AllOnyxKeys = DeepValueOf;
type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: FormTypes.AddDebitCardForm;
[ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm;
- [ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceProfileDescriptionForm;
[ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm;
[ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm;
- [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm;
[ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm;
[ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm;
- [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm;
+ [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm;
+ [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.WorkspaceInviteMessageForm;
[ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: FormTypes.DateOfBirthForm;
- [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: FormTypes.HomeAddressForm;
[ONYXKEYS.FORMS.NEW_ROOM_FORM]: FormTypes.NewRoomForm;
- [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.RoomSettingsForm;
+ [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.NewTaskForm;
+ [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.EditTaskForm;
+ [ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM]: FormTypes.ExitSurveyReasonForm;
+ [ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM]: FormTypes.ExitSurveyResponseForm;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.MoneyRequestDescriptionForm;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.MoneyRequestMerchantForm;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.MoneyRequestAmountForm;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.MoneyRequestDateForm;
[ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm;
- [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.Form;
- [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.NewContactMethodForm;
+ [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.WaypointForm;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.SettingsStatusSetForm;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.SettingsStatusClearDateForm;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: FormTypes.SettingsStatusSetClearAfterForm;
[ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: FormTypes.PrivateNotesForm;
[ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: FormTypes.IKnowTeacherForm;
[ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: FormTypes.IntroSchoolPrincipalForm;
- [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.Form;
- [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.ReportVirtualCardFraudForm;
+ [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.ReportPhysicalCardForm;
[ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: FormTypes.GetPhysicalCardForm;
[ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: FormTypes.ReportFieldEditForm;
[ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm;
@@ -491,7 +499,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails;
[ONYXKEYS.TASK]: OnyxTypes.Task;
[ONYXKEYS.WORKSPACE_RATE_AND_UNIT]: OnyxTypes.WorkspaceRateAndUnit;
- [ONYXKEYS.CURRENCY_LIST]: Record;
+ [ONYXKEYS.CURRENCY_LIST]: OnyxTypes.CurrencyList;
[ONYXKEYS.UPDATE_AVAILABLE]: boolean;
[ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest;
[ONYXKEYS.COUNTRY_CODE]: number;
@@ -535,6 +543,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean;
[ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean;
[ONYXKEYS.IS_LOADING_APP]: boolean;
+ [ONYXKEYS.IS_SWITCHING_TO_OLD_DOT]: boolean;
[ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer;
[ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string;
[ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: boolean;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index c41ef521661c..a8786bda3ffb 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -159,6 +159,17 @@ const ROUTES = {
getRoute: (source: string) => `settings/troubleshoot/console/share-log?source=${encodeURI(source)}` as const,
},
+ SETTINGS_EXIT_SURVEY_REASON: 'settings/exit-survey/reason',
+ SETTINGS_EXIT_SURVEY_RESPONSE: {
+ route: 'settings/exit-survey/response',
+ getRoute: (reason?: ValueOf, backTo?: string) =>
+ getUrlWithBackToParam(`settings/exit-survey/response${reason ? `?reason=${encodeURIComponent(reason)}` : ''}`, backTo),
+ },
+ SETTINGS_EXIT_SURVEY_CONFIRM: {
+ route: 'settings/exit-survey/confirm',
+ getRoute: (backTo?: string) => getUrlWithBackToParam('settings/exit-survey/confirm', backTo),
+ },
+
KEYBOARD_SHORTCUTS: 'keyboard-shortcuts',
NEW: 'new',
@@ -287,10 +298,6 @@ const ROUTES = {
route: ':iouType/new/currency/:reportID?',
getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const,
},
- MONEY_REQUEST_CATEGORY: {
- route: ':iouType/new/category/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const,
- },
MONEY_REQUEST_HOLD_REASON: {
route: ':iouType/edit/reason/:transactionID?',
getRoute: (iouType: string, transactionID: string, reportID: string, backTo: string) => `${iouType}/edit/reason/${transactionID}?backTo=${backTo}&reportID=${reportID}` as const,
@@ -338,9 +345,9 @@ const ROUTES = {
getUrlWithBackToParam(`create/${iouType}/taxAmount/${transactionID}/${reportID}`, backTo),
},
MONEY_REQUEST_STEP_CATEGORY: {
- route: 'create/:iouType/category/:transactionID/:reportID',
- getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
- getUrlWithBackToParam(`create/${iouType}/category/${transactionID}/${reportID}`, backTo),
+ route: ':action/:iouType/category/:transactionID/:reportID',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
+ getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}`, backTo),
},
MONEY_REQUEST_STEP_CURRENCY: {
route: 'create/:iouType/currency/:transactionID/:reportID/:pageIndex?',
@@ -463,6 +470,10 @@ const ROUTES = {
route: 'workspace/:policyID/profile/description',
getRoute: (policyID: string) => `workspace/${policyID}/profile/description` as const,
},
+ WORKSPACE_PROFILE_SHARE: {
+ route: 'workspace/:policyID/profile/share',
+ getRoute: (policyID: string) => `workspace/${policyID}/profile/share` as const,
+ },
WORKSPACE_AVATAR: {
route: 'workspace/:policyID/avatar',
getRoute: (policyID: string) => `workspace/${policyID}/avatar` as const,
@@ -471,6 +482,10 @@ const ROUTES = {
route: 'workspace/:policyID/settings/currency',
getRoute: (policyID: string) => `workspace/${policyID}/settings/currency` as const,
},
+ WORKSPACE_WORKFLOWS: {
+ route: 'workspace/:policyID/workflows',
+ getRoute: (policyID: string) => `workspace/${policyID}/workflows` as const,
+ },
WORKSPACE_CARD: {
route: 'workspace/:policyID/card',
getRoute: (policyID: string) => `workspace/${policyID}/card` as const,
@@ -507,6 +522,10 @@ const ROUTES = {
route: 'workspace/:policyID/members',
getRoute: (policyID: string) => `workspace/${policyID}/members` as const,
},
+ WORKSPACE_CATEGORIES: {
+ route: 'workspace/:policyID/categories',
+ getRoute: (policyID: string) => `workspace/${policyID}/categories` as const,
+ },
// Referral program promotion
REFERRAL_DETAILS_MODAL: {
route: 'referral/:contentType',
@@ -546,4 +565,4 @@ type Route = RouteIsPlainString extends true ? never : AllRoutes;
type HybridAppRoute = (typeof HYBRID_APP_ROUTES)[keyof typeof HYBRID_APP_ROUTES];
-export type {Route, HybridAppRoute};
+export type {Route, HybridAppRoute, AllRoutes};
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 18754a3513c1..520895c89c98 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -80,6 +80,12 @@ const SCREENS = {
REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud',
CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address',
},
+
+ EXIT_SURVEY: {
+ REASON: 'Settings_ExitSurvey_Reason',
+ RESPONSE: 'Settings_ExitSurvey_Response',
+ CONFIRM: 'Settings_ExitSurvey_Confirm',
+ },
},
SAVE_THE_WORLD: {
ROOT: 'SaveTheWorld_Root',
@@ -149,7 +155,6 @@ const SCREENS = {
PARTICIPANTS: 'Money_Request_Participants',
CONFIRMATION: 'Money_Request_Confirmation',
CURRENCY: 'Money_Request_Currency',
- CATEGORY: 'Money_Request_Category',
WAYPOINT: 'Money_Request_Waypoint',
EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint',
DISTANCE: 'Money_Request_Distance',
@@ -208,8 +213,11 @@ const SCREENS = {
MEMBERS: 'Workspace_Members',
INVITE: 'Workspace_Invite',
INVITE_MESSAGE: 'Workspace_Invite_Message',
+ CATEGORIES: 'Workspace_Categories',
CURRENCY: 'Workspace_Profile_Currency',
+ WORKFLOWS: 'Workspace_Workflows',
DESCRIPTION: 'Workspace_Profile_Description',
+ SHARE: 'Workspace_Profile_Share',
NAME: 'Workspace_Profile_Name',
},
diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx
index 89e87eeebe54..8ad26e5a7c46 100644
--- a/src/components/AddressSearch/index.tsx
+++ b/src/components/AddressSearch/index.tsx
@@ -272,7 +272,7 @@ function AddressSearch(
const renderHeaderComponent = () => (
<>
- {predefinedPlaces.length > 0 && (
+ {(predefinedPlaces?.length ?? 0) > 0 && (
<>
{/* This will show current location button in list if there are some recent destinations */}
{shouldShowCurrentLocationButton && (
@@ -339,7 +339,7 @@ function AddressSearch(
fetchDetails
suppressDefaultStyles
enablePoweredByContainer={false}
- predefinedPlaces={predefinedPlaces}
+ predefinedPlaces={predefinedPlaces ?? undefined}
listEmptyComponent={listEmptyComponent}
listLoaderComponent={listLoader}
renderHeaderComponent={renderHeaderComponent}
@@ -348,7 +348,7 @@ function AddressSearch(
const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text;
return (
- {title && {title}}
+ {!!title && {title}}
{subtitle}
);
@@ -398,10 +398,10 @@ function AddressSearch(
if (inputID) {
onInputChange?.(text);
} else {
- onInputChange({street: text});
+ onInputChange?.({street: text});
}
// If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering
- if (!text && !predefinedPlaces.length) {
+ if (!text && !predefinedPlaces?.length) {
setDisplayListViewBorder(false);
}
},
diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts
index 9b4254a9bc45..e115d4f697b2 100644
--- a/src/components/AddressSearch/types.ts
+++ b/src/components/AddressSearch/types.ts
@@ -20,6 +20,8 @@ type RenamedInputKeysProps = {
lat: string;
lng: string;
zipCode: string;
+ address?: string;
+ country?: string;
};
type OnPressProps = {
@@ -59,7 +61,7 @@ type AddressSearchProps = {
defaultValue?: string;
/** A callback function when the value of this field has changed */
- onInputChange: (value: string | number | RenamedInputKeysProps | StreetValue, key?: string) => void;
+ onInputChange?: (value: string | number | RenamedInputKeysProps | StreetValue, key?: string) => void;
/** A callback function when an address has been auto-selected */
onPress?: (props: OnPressProps) => void;
@@ -74,7 +76,7 @@ type AddressSearchProps = {
canUseCurrentLocation?: boolean;
/** A list of predefined places that can be shown when the user isn't searching for something */
- predefinedPlaces?: Place[];
+ predefinedPlaces?: Place[] | null;
/** A map of inputID key names */
renamedInputKeys: RenamedInputKeysProps;
diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js
deleted file mode 100644
index 3fc90433f13e..000000000000
--- a/src/components/Alert/index.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import _ from 'underscore';
-
-/**
- * Shows an alert modal with ok and cancel options.
- *
- * @param {String} title The title of the alert
- * @param {String} description The description of the alert
- * @param {Object[]} [options] An array of objects with `style` and `onPress` properties
- */
-export default (title, description, options) => {
- const result = _.filter(window.confirm([title, description], Boolean)).join('\n');
-
- if (result) {
- const confirmOption = _.find(options, ({style}) => style !== 'cancel');
- if (confirmOption && confirmOption.onPress) {
- confirmOption.onPress();
- }
- } else {
- const cancelOption = _.find(options, ({style}) => style === 'cancel');
- if (cancelOption && cancelOption.onPress) {
- cancelOption.onPress();
- }
- }
-};
diff --git a/src/components/Alert/index.native.js b/src/components/Alert/index.native.js
deleted file mode 100644
index 31c837a7dd6b..000000000000
--- a/src/components/Alert/index.native.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import {Alert} from 'react-native';
-
-export default Alert.alert;
diff --git a/src/components/Alert/index.native.tsx b/src/components/Alert/index.native.tsx
new file mode 100644
index 000000000000..b72eff5d9b58
--- /dev/null
+++ b/src/components/Alert/index.native.tsx
@@ -0,0 +1,6 @@
+import {Alert as AlertRN} from 'react-native';
+import type Alert from './types';
+
+const alert: Alert = AlertRN.alert;
+
+export default alert;
diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx
new file mode 100644
index 000000000000..f212f06aa9d3
--- /dev/null
+++ b/src/components/Alert/index.tsx
@@ -0,0 +1,16 @@
+import type Alert from './types';
+
+/** Shows an alert modal with ok and cancel options. */
+const alert: Alert = (title, description, options) => {
+ const result = window.confirm([title, description].filter(Boolean).join('\n'));
+
+ if (result) {
+ const confirmOption = options?.find(({style}) => style !== 'cancel');
+ confirmOption?.onPress?.();
+ } else {
+ const cancelOption = options?.find(({style}) => style === 'cancel');
+ cancelOption?.onPress?.();
+ }
+};
+
+export default alert;
diff --git a/src/components/Alert/types.ts b/src/components/Alert/types.ts
new file mode 100644
index 000000000000..25454abfe8b8
--- /dev/null
+++ b/src/components/Alert/types.ts
@@ -0,0 +1,5 @@
+import type {AlertStatic} from 'react-native';
+
+type Alert = AlertStatic['alert'];
+
+export default Alert;
diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js
index 59928b80c4b1..d4d3d0696c59 100644
--- a/src/components/AttachmentPicker/index.native.js
+++ b/src/components/AttachmentPicker/index.native.js
@@ -1,7 +1,7 @@
import lodashCompact from 'lodash/compact';
import PropTypes from 'prop-types';
import React, {useCallback, useMemo, useRef, useState} from 'react';
-import {Alert, View} from 'react-native';
+import {Alert, Image as RNImage, View} from 'react-native';
import RNFetchBlob from 'react-native-blob-util';
import RNDocumentPicker from 'react-native-document-picker';
import {launchImageLibrary} from 'react-native-image-picker';
@@ -57,11 +57,22 @@ const getImagePickerOptions = (type) => {
};
/**
- * See https://github.com/rnmods/react-native-document-picker#options for DocumentPicker configuration options
+ * Return documentPickerOptions based on the type
+ * @param {String} type
+ * @returns {Object}
*/
-const documentPickerOptions = {
- type: [RNDocumentPicker.types.allFiles],
- copyTo: 'cachesDirectory',
+
+const getDocumentPickerOptions = (type) => {
+ if (type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE) {
+ return {
+ type: [RNDocumentPicker.types.images],
+ copyTo: 'cachesDirectory',
+ };
+ }
+ return {
+ type: [RNDocumentPicker.types.allFiles],
+ copyTo: 'cachesDirectory',
+ };
};
/**
@@ -158,7 +169,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
*/
const showDocumentPicker = useCallback(
() =>
- RNDocumentPicker.pick(documentPickerOptions).catch((error) => {
+ RNDocumentPicker.pick(getDocumentPickerOptions(type)).catch((error) => {
if (RNDocumentPicker.isCancel(error)) {
return;
}
@@ -166,7 +177,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
showGeneralAlert(error.message);
throw error;
}),
- [showGeneralAlert],
+ [showGeneralAlert, type],
);
const menuItemData = useMemo(() => {
@@ -181,7 +192,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
textTranslationKey: 'attachmentPicker.chooseFromGallery',
pickAttachment: () => showImagePicker(launchImageLibrary),
},
- type !== CONST.ATTACHMENT_PICKER_TYPE.IMAGE && {
+ {
icon: Expensicons.Paperclip,
textTranslationKey: 'attachmentPicker.chooseDocument',
pickAttachment: showDocumentPicker,
@@ -189,7 +200,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
]);
return data;
- }, [showDocumentPicker, showImagePicker, type, shouldHideCameraOption]);
+ }, [showDocumentPicker, showImagePicker, shouldHideCameraOption]);
const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible});
@@ -232,22 +243,23 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
onCanceled.current();
return Promise.resolve();
}
-
const fileData = _.first(attachments);
-
- if (fileData.width === -1 || fileData.height === -1) {
- showImageCorruptionAlert();
- return Promise.resolve();
- }
-
- return getDataForUpload(fileData)
- .then((result) => {
- completeAttachmentSelection.current(result);
- })
- .catch((error) => {
- showGeneralAlert(error.message);
- throw error;
- });
+ RNImage.getSize(fileData.uri, (width, height) => {
+ fileData.width = width;
+ fileData.height = height;
+ if (fileData.width === -1 || fileData.height === -1) {
+ showImageCorruptionAlert();
+ return Promise.resolve();
+ }
+ return getDataForUpload(fileData)
+ .then((result) => {
+ completeAttachmentSelection.current(result);
+ })
+ .catch((error) => {
+ showGeneralAlert(error.message);
+ throw error;
+ });
+ });
},
[showGeneralAlert, showImageCorruptionAlert],
);
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts
index fd9b57511cc4..f1b9d16de654 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts
+++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts
@@ -26,6 +26,7 @@ type AttachmentCarouselPagerContextValue = {
isScrollEnabled: SharedValue;
onTap: () => void;
onScaleChanged: (scale: number) => void;
+ onSwipeDown: () => void;
};
const AttachmentCarouselPagerContext = createContext(null);
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
index 8704584c3e18..33d9f20b5e57 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
+++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx
@@ -42,9 +42,15 @@ type AttachmentCarouselPagerProps = {
* @param showArrows If set, it will show/hide the arrows. If not set, it will toggle the arrows.
*/
onRequestToggleArrows: (showArrows?: boolean) => void;
+
+ /** A callback that is called when swipe-down-to-close gesture happens */
+ onClose: () => void;
};
-function AttachmentCarouselPager({items, activeSource, initialPage, onPageSelected, onRequestToggleArrows}: AttachmentCarouselPagerProps, ref: ForwardedRef) {
+function AttachmentCarouselPager(
+ {items, activeSource, initialPage, onPageSelected, onRequestToggleArrows, onClose}: AttachmentCarouselPagerProps,
+ ref: ForwardedRef,
+) {
const styles = useThemeStyles();
const pagerRef = useRef(null);
@@ -114,9 +120,10 @@ function AttachmentCarouselPager({items, activeSource, initialPage, onPageSelect
isScrollEnabled,
pagerRef,
onTap: handleTap,
+ onSwipeDown: onClose,
onScaleChanged: handleScaleChange,
}),
- [pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange],
+ [pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, onClose, handleScaleChange],
);
const animatedProps = useAnimatedProps(() => ({
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js
index 228f0d597a32..a4d3e1392095 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.js
+++ b/src/components/Attachments/AttachmentCarousel/index.native.js
@@ -102,6 +102,10 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
[setShouldShowArrows],
);
+ const goBack = useCallback(() => {
+ Navigation.goBack();
+ }, []);
+
return (
{page == null ? (
@@ -133,6 +137,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
activeSource={activeSource}
onRequestToggleArrows={toggleArrows}
onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)}
+ onClose={goBack}
ref={pagerRef}
/>
>
diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.tsx
similarity index 77%
rename from src/components/AvatarWithImagePicker.js
rename to src/components/AvatarWithImagePicker.tsx
index 26d41ea82e00..fa8a6d71516f 100644
--- a/src/components/AvatarWithImagePicker.js
+++ b/src/components/AvatarWithImagePicker.tsx
@@ -1,18 +1,20 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useEffect, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
-import _ from 'underscore';
+import type {StyleProp, ViewStyle} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
+import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import getImageResolution from '@libs/fileDownload/getImageResolution';
-import stylePropTypes from '@styles/stylePropTypes';
+import type {AvatarSource} from '@libs/UserUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+import type IconAsset from '@src/types/utils/IconAsset';
import AttachmentModal from './AttachmentModal';
import AttachmentPicker from './AttachmentPicker';
import Avatar from './Avatar';
@@ -20,167 +22,140 @@ import AvatarCropModal from './AvatarCropModal/AvatarCropModal';
import DotIndicatorMessage from './DotIndicatorMessage';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
-import sourcePropTypes from './Image/sourcePropTypes';
import OfflineWithFeedback from './OfflineWithFeedback';
import PopoverMenu from './PopoverMenu';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import Tooltip from './Tooltip';
import withNavigationFocus from './withNavigationFocus';
-const propTypes = {
+type ErrorData = {
+ validationError?: TranslationPaths | null | '';
+ phraseParam: Record;
+};
+
+type OpenPickerParams = {
+ onPicked: (image: File) => void;
+};
+type OpenPicker = (args: OpenPickerParams) => void;
+
+type MenuItem = {
+ icon: IconAsset;
+ text: string;
+ onSelected: () => void;
+};
+
+type AvatarWithImagePickerProps = {
/** Avatar source to display */
- source: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]),
+ source?: AvatarSource;
/** Additional style props */
- style: stylePropTypes,
+ style?: StyleProp;
/** Additional style props for disabled picker */
- disabledStyle: stylePropTypes,
+ disabledStyle?: StyleProp;
/** Executed once an image has been selected */
- onImageSelected: PropTypes.func,
+ onImageSelected?: (file: File | CustomRNImageManipulatorResult) => void;
/** Execute when the user taps "remove" */
- onImageRemoved: PropTypes.func,
+ onImageRemoved?: () => void;
/** A default avatar component to display when there is no source */
- DefaultAvatar: PropTypes.func,
+ DefaultAvatar?: () => React.ReactNode;
/** Whether we are using the default avatar */
- isUsingDefaultAvatar: PropTypes.bool,
+ isUsingDefaultAvatar?: boolean;
/** Size of Indicator */
- size: PropTypes.oneOf([CONST.AVATAR_SIZE.XLARGE, CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]),
+ size?: typeof CONST.AVATAR_SIZE.XLARGE | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT;
/** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */
- fallbackIcon: sourcePropTypes,
+ fallbackIcon?: AvatarSource;
/** Denotes whether it is an avatar or a workspace avatar */
- type: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_WORKSPACE]),
+ type?: typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE;
/** Image crop vector mask */
- editorMaskImage: sourcePropTypes,
+ editorMaskImage?: IconAsset;
/** Additional style object for the error row */
- errorRowStyles: stylePropTypes,
+ errorRowStyles?: StyleProp;
/** A function to run when the X button next to the error is clicked */
- onErrorClose: PropTypes.func,
+ onErrorClose?: () => void;
/** The type of action that's pending */
- pendingAction: PropTypes.oneOf(['add', 'update', 'delete']),
+ pendingAction?: OnyxCommon.PendingAction;
/** The errors to display */
- // eslint-disable-next-line react/forbid-prop-types
- errors: PropTypes.object,
+ errors?: OnyxCommon.Errors | null;
/** Title for avatar preview modal */
- headerTitle: PropTypes.string,
+ headerTitle?: string;
/** Avatar source for avatar preview modal */
- previewSource: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]),
+ previewSource?: AvatarSource;
/** File name of the avatar */
- originalFileName: PropTypes.string,
+ originalFileName?: string;
/** Whether navigation is focused */
- isFocused: PropTypes.bool.isRequired,
+ isFocused: boolean;
/** Style applied to the avatar */
- avatarStyle: stylePropTypes.isRequired,
+ avatarStyle: StyleProp;
/** Indicates if picker feature should be disabled */
- disabled: PropTypes.bool,
+ disabled?: boolean;
/** Executed once click on view photo option */
- onViewPhotoPress: PropTypes.func,
-
- /** Where the popover should be positioned relative to the anchor points. */
- anchorAlignment: PropTypes.shape({
- horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
- vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
- }),
+ onViewPhotoPress?: () => void;
/** Allows to open an image without Attachment Picker. */
- enablePreview: PropTypes.bool,
-};
-
-const defaultProps = {
- source: '',
- onImageSelected: () => {},
- onImageRemoved: () => {},
- style: [],
- disabledStyle: [],
- DefaultAvatar: () => {},
- isUsingDefaultAvatar: false,
- size: CONST.AVATAR_SIZE.DEFAULT,
- fallbackIcon: Expensicons.FallbackAvatar,
- type: CONST.ICON_TYPE_AVATAR,
- editorMaskImage: undefined,
- errorRowStyles: [],
- onErrorClose: () => {},
- pendingAction: null,
- errors: null,
- headerTitle: '',
- previewSource: '',
- originalFileName: '',
- disabled: false,
- onViewPhotoPress: undefined,
- anchorAlignment: {
- horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
- vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
- },
- enablePreview: false,
+ enablePreview?: boolean;
};
function AvatarWithImagePicker({
isFocused,
- DefaultAvatar,
+ DefaultAvatar = () => null,
style,
disabledStyle,
pendingAction,
errors,
errorRowStyles,
- onErrorClose,
- source,
- fallbackIcon,
- size,
- type,
- headerTitle,
- previewSource,
- originalFileName,
- isUsingDefaultAvatar,
- onImageRemoved,
- onImageSelected,
+ onErrorClose = () => {},
+ source = '',
+ fallbackIcon = Expensicons.FallbackAvatar,
+ size = CONST.AVATAR_SIZE.DEFAULT,
+ type = CONST.ICON_TYPE_AVATAR,
+ headerTitle = '',
+ previewSource = '',
+ originalFileName = '',
+ isUsingDefaultAvatar = false,
+ onImageSelected = () => {},
+ onImageRemoved = () => {},
editorMaskImage,
avatarStyle,
- disabled,
+ disabled = false,
onViewPhotoPress,
- enablePreview,
-}) {
+ enablePreview = false,
+}: AvatarWithImagePickerProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {windowWidth} = useWindowDimensions();
const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0});
const [isMenuVisible, setIsMenuVisible] = useState(false);
- const [errorData, setErrorData] = useState({
- validationError: null,
- phraseParam: {},
- });
+ const [errorData, setErrorData] = useState({validationError: null, phraseParam: {}});
const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false);
const [imageData, setImageData] = useState({
uri: '',
name: '',
type: '',
});
- const anchorRef = useRef();
+ const anchorRef = useRef(null);
const {translate} = useLocalize();
- /**
- * @param {String} error
- * @param {Object} phraseParam
- */
- const setError = (error, phraseParam) => {
+ const setError = (error: TranslationPaths | null, phraseParam: Record) => {
setErrorData({
validationError: error,
phraseParam,
@@ -198,40 +173,29 @@ function AvatarWithImagePicker({
/**
* Check if the attachment extension is allowed.
- *
- * @param {Object} image
- * @returns {Boolean}
*/
- const isValidExtension = (image) => {
- const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', ''));
- return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase());
+ const isValidExtension = (image: File): boolean => {
+ const {fileExtension} = FileUtils.splitExtensionFromFileName(image?.name ?? '');
+ return CONST.AVATAR_ALLOWED_EXTENSIONS.some((extension) => extension === fileExtension.toLowerCase());
};
/**
* Check if the attachment size is less than allowed size.
- *
- * @param {Object} image
- * @returns {Boolean}
*/
- const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
+ const isValidSize = (image: File): boolean => (image?.size ?? 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
/**
* Check if the attachment resolution matches constraints.
- *
- * @param {Object} image
- * @returns {Promise}
*/
- const isValidResolution = (image) =>
+ const isValidResolution = (image: File): Promise =>
getImageResolution(image).then(
({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX,
);
/**
* Validates if an image has a valid resolution and opens an avatar crop modal
- *
- * @param {Object} image
*/
- const showAvatarCropModal = (image) => {
+ const showAvatarCropModal = (image: File) => {
if (!isValidExtension(image)) {
setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS});
return;
@@ -269,11 +233,8 @@ function AvatarWithImagePicker({
/**
* Create menu items list for avatar menu
- *
- * @param {Function} openPicker
- * @returns {Array}
*/
- const createMenuItems = (openPicker) => {
+ const createMenuItems = (openPicker: OpenPicker): MenuItem[] => {
const menuItems = [
{
icon: Expensicons.Upload,
@@ -318,6 +279,7 @@ function AvatarWithImagePicker({
vertical: y + height + variables.spacing2,
});
});
+
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMenuVisible, windowWidth]);
@@ -383,7 +345,11 @@ function AvatarWithImagePicker({
maybeIcon={isUsingDefaultAvatar}
>
{({show}) => (
-
+
+ {/* @ts-expect-error TODO: Remove this once AttachmentPicker (https://github.com/Expensify/App/issues/25134) is migrated to TypeScript. */}
{({openPicker}) => {
const menuItems = createMenuItems(openPicker);
@@ -432,7 +398,8 @@ function AvatarWithImagePicker({
{errorData.validationError && (
)}
@@ -449,8 +416,6 @@ function AvatarWithImagePicker({
);
}
-AvatarWithImagePicker.propTypes = propTypes;
-AvatarWithImagePicker.defaultProps = defaultProps;
AvatarWithImagePicker.displayName = 'AvatarWithImagePicker';
export default withNavigationFocus(AvatarWithImagePicker);
diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js
index 0e57bcf4db03..2374fc9e5d0c 100644
--- a/src/components/CategoryPicker/index.js
+++ b/src/components/CategoryPicker/index.js
@@ -3,6 +3,7 @@ import React, {useMemo} from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import * as OptionsListUtils from '@libs/OptionsListUtils';
@@ -67,6 +68,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC
textInputLabel={shouldShowTextInput && translate('common.search')}
onChangeText={setSearchValue}
onSelectRow={onSubmit}
+ ListItem={RadioListItem}
initiallyFocusedOptionKey={selectedOptionKey}
/>
);
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js
index b263885f0a60..1d2d15e4564c 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js
@@ -131,6 +131,7 @@ function BaseEmojiPickerMenu({headerEmojis, scrollToHeader, isFiltered, listWrap
ref={forwardedRef}
keyboardShouldPersistTaps="handled"
data={data}
+ drawDistance={CONST.EMOJI_DRAW_AMOUNT}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={CONST.EMOJI_NUM_PER_ROW}
diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts
index ae98978ffcad..37d0f730c9e9 100644
--- a/src/components/Form/types.ts
+++ b/src/components/Form/types.ts
@@ -7,6 +7,7 @@ import type AmountTextInput from '@components/AmountTextInput';
import type CheckboxWithLabel from '@components/CheckboxWithLabel';
import type CountrySelector from '@components/CountrySelector';
import type Picker from '@components/Picker';
+import type RadioButtons from '@components/RadioButtons';
import type SingleChoiceQuestion from '@components/SingleChoiceQuestion';
import type StatePicker from '@components/StatePicker';
import type TextInput from '@components/TextInput';
@@ -34,7 +35,8 @@ type ValidInputs =
| typeof AmountForm
| typeof BusinessTypePicker
| typeof StatePicker
- | typeof ValuePicker;
+ | typeof ValuePicker
+ | typeof RadioButtons;
type ValueTypeKey = 'string' | 'boolean' | 'date';
type ValueTypeMap = {
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx
index 2e47b97ec7af..43d1be85d21a 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx
@@ -1,8 +1,8 @@
import React from 'react';
import type {CustomRendererProps, TBlock} from 'react-native-render-html';
+import {ShowContextMenuContext} from '@components/ShowContextMenuContext';
import VideoPlayerPreview from '@components/VideoPlayerPreview';
import * as FileUtils from '@libs/fileDownload/FileUtils';
-import {parseReportRouteParams} from '@libs/ReportUtils';
import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
import Navigation from '@navigation/Navigation';
import CONST from '@src/CONST';
@@ -22,22 +22,24 @@ function VideoRenderer({tnode, key}: VideoRendererProps) {
const width = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE]);
const height = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE]);
const duration = Number(htmlAttribs[CONST.ATTACHMENT_DURATION_ATTRIBUTE]);
- const activeRoute = Navigation.getActiveRoute();
- const {reportID} = parseReportRouteParams(activeRoute);
return (
- {
- const route = ROUTES.REPORT_ATTACHMENTS.getRoute(reportID, sourceURL);
- Navigation.navigate(route);
- }}
- />
+
+ {({report}) => (
+ {
+ const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report?.reportID ?? '', sourceURL);
+ Navigation.navigate(route);
+ }}
+ />
+ )}
+
);
}
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 553a60e568ec..2a7ed30abf1a 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -68,6 +68,7 @@ import Flag from '@assets/images/flag.svg';
import FlagLevelOne from '@assets/images/flag_level_01.svg';
import FlagLevelTwo from '@assets/images/flag_level_02.svg';
import FlagLevelThree from '@assets/images/flag_level_03.svg';
+import Folder from '@assets/images/folder.svg';
import Fullscreen from '@assets/images/fullscreen.svg';
import Gallery from '@assets/images/gallery.svg';
import Gear from '@assets/images/gear.svg';
@@ -144,6 +145,7 @@ import Users from '@assets/images/users.svg';
import VolumeHigh from '@assets/images/volume-high.svg';
import VolumeLow from '@assets/images/volume-low.svg';
import Wallet from '@assets/images/wallet.svg';
+import Workflows from '@assets/images/workflows.svg';
import Workspace from '@assets/images/workspace-default-avatar.svg';
import Wrench from '@assets/images/wrench.svg';
import Zoom from '@assets/images/zoom.svg';
@@ -216,6 +218,7 @@ export {
FlagLevelTwo,
FlagLevelThree,
Fullscreen,
+ Folder,
Gallery,
Gear,
Globe,
@@ -287,6 +290,7 @@ export {
VolumeHigh,
VolumeLow,
Wallet,
+ Workflows,
Workspace,
Zoom,
Twitter,
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 3f6b6ca20540..e03b393dc81f 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -5,6 +5,7 @@ import BankUserGreen from '@assets/images/product-illustrations/bank-user--green
import ConciergeBlue from '@assets/images/product-illustrations/concierge--blue.svg';
import ConciergeExclamation from '@assets/images/product-illustrations/concierge--exclamation.svg';
import CreditCardsBlue from '@assets/images/product-illustrations/credit-cards--blue.svg';
+import EmptyStateExpenses from '@assets/images/product-illustrations/emptystate__expenses.svg';
import GpsTrackOrange from '@assets/images/product-illustrations/gps-track--orange.svg';
import Hands from '@assets/images/product-illustrations/home-illustration-hands.svg';
import InvoiceOrange from '@assets/images/product-illustrations/invoice--orange.svg';
@@ -15,6 +16,7 @@ import JewelBoxYellow from '@assets/images/product-illustrations/jewel-box--yell
import MagicCode from '@assets/images/product-illustrations/magic-code.svg';
import MoneyEnvelopeBlue from '@assets/images/product-illustrations/money-envelope--blue.svg';
import MoneyMousePink from '@assets/images/product-illustrations/money-mouse--pink.svg';
+import MushroomTopHat from '@assets/images/product-illustrations/mushroom-top-hat.svg';
import PaymentHands from '@assets/images/product-illustrations/payment-hands.svg';
import ReceiptYellow from '@assets/images/product-illustrations/receipt--yellow.svg';
import ReceiptsSearchYellow from '@assets/images/product-illustrations/receipts-search--yellow.svg';
@@ -26,6 +28,7 @@ import TadaBlue from '@assets/images/product-illustrations/tada--blue.svg';
import TadaYellow from '@assets/images/product-illustrations/tada--yellow.svg';
import TeleScope from '@assets/images/product-illustrations/telescope.svg';
import ToddBehindCloud from '@assets/images/product-illustrations/todd-behind-cloud.svg';
+import Approval from '@assets/images/simple-illustrations/simple-illustration__approval.svg';
import BankArrow from '@assets/images/simple-illustrations/simple-illustration__bank-arrow.svg';
import BigRocket from '@assets/images/simple-illustrations/simple-illustration__bigrocket.svg';
import PinkBill from '@assets/images/simple-illustrations/simple-illustration__bill.svg';
@@ -36,6 +39,7 @@ import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustra
import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg';
import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg';
import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg';
+import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg';
import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg';
import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg';
import HandEarth from '@assets/images/simple-illustrations/simple-illustration__handearth.svg';
@@ -55,6 +59,7 @@ import OpenSafe from '@assets/images/simple-illustrations/simple-illustration__o
import PalmTree from '@assets/images/simple-illustrations/simple-illustration__palmtree.svg';
import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg';
import QRCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg';
+import ReceiptEnvelope from '@assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg';
import ReceiptWrangler from '@assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg';
import SanFrancisco from '@assets/images/simple-illustrations/simple-illustration__sanfrancisco.svg';
import ShieldYellow from '@assets/images/simple-illustrations/simple-illustration__shield.svg';
@@ -63,6 +68,8 @@ import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustrati
import TrackShoe from '@assets/images/simple-illustrations/simple-illustration__track-shoe.svg';
import TrashCan from '@assets/images/simple-illustrations/simple-illustration__trashcan.svg';
import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg';
+import WalletAlt from '@assets/images/simple-illustrations/simple-illustration__wallet-alt.svg';
+import Workflows from '@assets/images/simple-illustrations/simple-illustration__workflows.svg';
export {
Abracadabra,
@@ -76,6 +83,8 @@ export {
ConciergeExclamation,
CreditCardsBlue,
EmailAddress,
+ EmptyStateExpenses,
+ FolderOpen,
HandCard,
HotDogStand,
InvoiceOrange,
@@ -88,6 +97,7 @@ export {
Mailbox,
MoneyEnvelopeBlue,
MoneyMousePink,
+ MushroomTopHat,
ReceiptsSearchYellow,
ReceiptYellow,
ReceiptWrangler,
@@ -129,5 +139,9 @@ export {
LockClosed,
Gears,
QRCode,
+ ReceiptEnvelope,
+ Approval,
+ WalletAlt,
+ Workflows,
House,
};
diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx
index ae225b3db9e9..923337ba9ada 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.tsx
+++ b/src/components/LHNOptionsList/OptionRowLHN.tsx
@@ -52,8 +52,13 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti
return null;
}
- const isHidden = optionItem?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
- if (isHidden && !isFocused && !optionItem?.isPinned) {
+ const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
+ const shouldShowGreenDotIndicator = !hasBrickError && ReportUtils.requiresAttentionFromCurrentUser(optionItem, optionItem.parentReportAction);
+
+ const isHidden = optionItem.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
+
+ const shouldOverrideHidden = hasBrickError || isFocused || optionItem.isPinned;
+ if (isHidden && !shouldOverrideHidden) {
return null;
}
@@ -74,8 +79,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti
const hoveredBackgroundColor = !!styles.sidebarLinkHover && 'backgroundColor' in styles.sidebarLinkHover ? styles.sidebarLinkHover.backgroundColor : theme.sidebar;
const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
- const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
- const shouldShowGreenDotIndicator = !hasBrickError && ReportUtils.requiresAttentionFromCurrentUser(optionItem, optionItem.parentReportAction);
/**
* Show the ReportActionContextMenu modal popover.
*
diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx
index 36cb175e3c45..69fa0d5e6e41 100644
--- a/src/components/Lightbox/index.tsx
+++ b/src/components/Lightbox/index.tsx
@@ -59,6 +59,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
activePage,
onTap,
onScaleChanged: onScaleChangedContext,
+ onSwipeDown,
pagerRef,
} = useMemo(() => {
if (attachmentCarouselPagerContext === null) {
@@ -70,6 +71,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
activePage: 0,
onTap: () => {},
onScaleChanged: () => {},
+ onSwipeDown: () => {},
pagerRef: undefined,
};
}
@@ -212,6 +214,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
shouldDisableTransformationGestures={isPagerScrolling}
onTap={onTap}
onScaleChanged={scaleChange}
+ onSwipeDown={onSwipeDown}
>
{
- if (props.isEditingSplitBill) {
- Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.CATEGORY));
- return;
- }
- Navigation.navigate(ROUTES.MONEY_REQUEST_CATEGORY.getRoute(props.iouType, props.reportID));
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ props.iouType,
+ props.transaction.transactionID,
+ props.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
}}
style={[styles.moneyRequestMenuItem]}
titleStyle={styles.flex1}
diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
index 3939e847707d..c69b0476f9c7 100755
--- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
+++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
@@ -747,7 +747,11 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
title={iouCategory}
description={translate('common.category')}
numberOfLinesTitle={2}
- onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))}
+ onPress={() =>
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()),
+ )
+ }
style={[styles.moneyRequestMenuItem]}
titleStyle={styles.flex1}
disabled={didConfirm}
@@ -763,7 +767,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx
index 1efbe1827b85..0bdd53719173 100644
--- a/src/components/MultiGestureCanvas/index.tsx
+++ b/src/components/MultiGestureCanvas/index.tsx
@@ -10,7 +10,7 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import {DEFAULT_ZOOM_RANGE, SPRING_CONFIG, ZOOM_RANGE_BOUNCE_FACTORS} from './constants';
-import type {CanvasSize, ContentSize, OnScaleChangedCallback, OnTapCallback, ZoomRange} from './types';
+import type {CanvasSize, ContentSize, OnScaleChangedCallback, OnSwipeDownCallback, OnTapCallback, ZoomRange} from './types';
import usePanGesture from './usePanGesture';
import usePinchGesture from './usePinchGesture';
import useTapGestures from './useTapGestures';
@@ -47,6 +47,8 @@ type MultiGestureCanvasProps = ChildrenProps & {
/** Handles scale changed event */
onTap?: OnTapCallback;
+
+ onSwipeDown?: OnSwipeDownCallback;
};
function MultiGestureCanvas({
@@ -59,6 +61,7 @@ function MultiGestureCanvas({
shouldDisableTransformationGestures: shouldDisableTransformationGesturesProp,
onTap,
onScaleChanged,
+ onSwipeDown,
}: MultiGestureCanvasProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -88,6 +91,7 @@ function MultiGestureCanvas({
const panTranslateX = useSharedValue(0);
const panTranslateY = useSharedValue(0);
+ const isSwipingDownToClose = useSharedValue(false);
const panGestureRef = useRef(Gesture.Pan());
const pinchScale = useSharedValue(1);
@@ -113,8 +117,8 @@ function MultiGestureCanvas({
stopAnimation();
if (animated) {
- offsetX.value = withSpring(0, SPRING_CONFIG);
- offsetY.value = withSpring(0, SPRING_CONFIG);
+ offsetX.value = 0;
+ offsetY.value = 0;
panTranslateX.value = withSpring(0, SPRING_CONFIG);
panTranslateY.value = withSpring(0, SPRING_CONFIG);
pinchTranslateX.value = withSpring(0, SPRING_CONFIG);
@@ -172,6 +176,8 @@ function MultiGestureCanvas({
panTranslateY,
stopAnimation,
shouldDisableTransformationGestures,
+ isSwipingDownToClose,
+ onSwipeDown,
})
.simultaneousWithExternalGesture(...panGestureSimultaneousList)
.withRef(panGestureRef);
diff --git a/src/components/MultiGestureCanvas/types.ts b/src/components/MultiGestureCanvas/types.ts
index 40fcc1462a09..fbb2f3deb88c 100644
--- a/src/components/MultiGestureCanvas/types.ts
+++ b/src/components/MultiGestureCanvas/types.ts
@@ -24,6 +24,9 @@ type OnScaleChangedCallback = (zoomScale: number) => void;
/** Triggered when the canvas is tapped (single tap) */
type OnTapCallback = () => void;
+/** Triggered when the swipe down gesture on canvas occurs */
+type OnSwipeDownCallback = () => void;
+
/** Types used of variables used within the MultiGestureCanvas component and it's hooks */
type MultiGestureCanvasVariables = {
canvasSize: CanvasSize;
@@ -32,6 +35,7 @@ type MultiGestureCanvasVariables = {
minContentScale: number;
maxContentScale: number;
shouldDisableTransformationGestures: SharedValue;
+ isSwipingDownToClose: SharedValue;
zoomScale: SharedValue;
totalScale: SharedValue;
pinchScale: SharedValue;
@@ -45,6 +49,7 @@ type MultiGestureCanvasVariables = {
reset: (animated: boolean, callback: () => void) => void;
onTap: OnTapCallback | undefined;
onScaleChanged: OnScaleChangedCallback | undefined;
+ onSwipeDown: OnSwipeDownCallback | undefined;
};
-export type {CanvasSize, ContentSize, ZoomRange, OnScaleChangedCallback, OnTapCallback, MultiGestureCanvasVariables};
+export type {CanvasSize, ContentSize, ZoomRange, OnScaleChangedCallback, OnTapCallback, MultiGestureCanvasVariables, OnSwipeDownCallback};
diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts
index a3f9c7d62df0..97843e118871 100644
--- a/src/components/MultiGestureCanvas/usePanGesture.ts
+++ b/src/components/MultiGestureCanvas/usePanGesture.ts
@@ -1,7 +1,8 @@
/* eslint-disable no-param-reassign */
+import {Dimensions} from 'react-native';
import type {PanGesture} from 'react-native-gesture-handler';
import {Gesture} from 'react-native-gesture-handler';
-import {useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated';
+import {runOnJS, useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated';
import {SPRING_CONFIG} from './constants';
import type {MultiGestureCanvasVariables} from './types';
import * as MultiGestureCanvasUtils from './utils';
@@ -10,10 +11,24 @@ import * as MultiGestureCanvasUtils from './utils';
// We're using a "withDecay" animation to smoothly phase out the pan animation
// https://docs.swmansion.com/react-native-reanimated/docs/animations/withDecay/
const PAN_DECAY_DECELARATION = 0.9915;
+const SCREEN_HEIGHT = Dimensions.get('screen').height;
+const SNAP_POINT = SCREEN_HEIGHT / 4;
+const SNAP_POINT_HIDDEN = SCREEN_HEIGHT / 1.2;
type UsePanGestureProps = Pick<
MultiGestureCanvasVariables,
- 'canvasSize' | 'contentSize' | 'zoomScale' | 'totalScale' | 'offsetX' | 'offsetY' | 'panTranslateX' | 'panTranslateY' | 'shouldDisableTransformationGestures' | 'stopAnimation'
+ | 'canvasSize'
+ | 'contentSize'
+ | 'zoomScale'
+ | 'totalScale'
+ | 'offsetX'
+ | 'offsetY'
+ | 'panTranslateX'
+ | 'panTranslateY'
+ | 'shouldDisableTransformationGestures'
+ | 'stopAnimation'
+ | 'onSwipeDown'
+ | 'isSwipingDownToClose'
>;
const usePanGesture = ({
@@ -27,16 +42,24 @@ const usePanGesture = ({
panTranslateY,
shouldDisableTransformationGestures,
stopAnimation,
+ isSwipingDownToClose,
+ onSwipeDown,
}: UsePanGestureProps): PanGesture => {
// The content size after fitting it to the canvas and zooming
const zoomedContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]);
const zoomedContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]);
+ // Used to track previous touch position for the "swipe down to close" gesture
+ const previousTouch = useSharedValue<{x: number; y: number} | null>(null);
+
// Velocity of the pan gesture
// We need to keep track of the velocity to properly phase out/decay the pan animation
const panVelocityX = useSharedValue(0);
const panVelocityY = useSharedValue(0);
+ // Disable "swipe down to close" gesture when content is bigger than the canvas
+ const enableSwipeDownToClose = useDerivedValue(() => canvasSize.height < zoomedContentHeight.value, [canvasSize.height]);
+
// Calculates bounds of the scaled content
// Can we pan left/right/up/down
// Can be used to limit gesture or implementing tension effect
@@ -113,8 +136,22 @@ const usePanGesture = ({
});
}
} else {
- // Animated back to the boundary
- offsetY.value = withSpring(clampedOffset.y, SPRING_CONFIG);
+ const finalTranslateY = offsetY.value + panVelocityY.value * 0.2;
+
+ if (finalTranslateY > SNAP_POINT && zoomScale.value <= 1) {
+ offsetY.value = withSpring(SNAP_POINT_HIDDEN, SPRING_CONFIG, () => {
+ isSwipingDownToClose.value = false;
+ });
+
+ if (onSwipeDown) {
+ runOnJS(onSwipeDown)();
+ }
+ } else {
+ // Animated back to the boundary
+ offsetY.value = withSpring(clampedOffset.y, SPRING_CONFIG, () => {
+ isSwipingDownToClose.value = false;
+ });
+ }
}
// Reset velocity variables after we finished the pan gesture
@@ -125,14 +162,36 @@ const usePanGesture = ({
const panGesture = Gesture.Pan()
.manualActivation(true)
.averageTouches(true)
- // eslint-disable-next-line @typescript-eslint/naming-convention
- .onTouchesMove((_evt, state) => {
+ .onTouchesUp(() => {
+ previousTouch.value = null;
+ })
+ .onTouchesMove((evt, state) => {
// We only allow panning when the content is zoomed in
- if (zoomScale.value <= 1 || shouldDisableTransformationGestures.value) {
- return;
+ if (zoomScale.value > 1 && !shouldDisableTransformationGestures.value) {
+ state.activate();
}
- state.activate();
+ // TODO: this needs tuning to work properly
+ if (!shouldDisableTransformationGestures.value && zoomScale.value === 1 && previousTouch.value !== null) {
+ const velocityX = Math.abs(evt.allTouches[0].x - previousTouch.value.x);
+ const velocityY = evt.allTouches[0].y - previousTouch.value.y;
+
+ if (Math.abs(velocityY) > velocityX && velocityY > 20) {
+ state.activate();
+
+ isSwipingDownToClose.value = true;
+ previousTouch.value = null;
+
+ return;
+ }
+ }
+
+ if (previousTouch.value === null) {
+ previousTouch.value = {
+ x: evt.allTouches[0].x,
+ y: evt.allTouches[0].y,
+ };
+ }
})
.onStart(() => {
stopAnimation();
@@ -147,15 +206,23 @@ const usePanGesture = ({
panVelocityX.value = evt.velocityX;
panVelocityY.value = evt.velocityY;
- panTranslateX.value += evt.changeX;
- panTranslateY.value += evt.changeY;
+ if (!isSwipingDownToClose.value) {
+ panTranslateX.value += evt.changeX;
+ }
+
+ if (enableSwipeDownToClose.value || isSwipingDownToClose.value) {
+ panTranslateY.value += evt.changeY;
+ }
})
.onEnd(() => {
// Add pan translation to total offset and reset gesture variables
offsetX.value += panTranslateX.value;
offsetY.value += panTranslateY.value;
+
+ // Reset pan gesture variables
panTranslateX.value = 0;
panTranslateY.value = 0;
+ previousTouch.value = null;
// If we are swiping (in the pager), we don't want to return to boundaries
if (shouldDisableTransformationGestures.value) {
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 5310163a7433..a391ff061baa 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -79,7 +79,7 @@ type PopoverMenuProps = Partial & {
anchorPosition: AnchorPosition;
/** Ref of the anchor */
- anchorRef: RefObject;
+ anchorRef: RefObject;
/** Where the popover should be positioned relative to the anchor points. */
anchorAlignment?: AnchorAlignment;
diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx
index 69728d7be126..b8d4efbd916d 100644
--- a/src/components/PopoverProvider/index.tsx
+++ b/src/components/PopoverProvider/index.tsx
@@ -21,14 +21,15 @@ function PopoverContextProvider(props: PopoverContextProps) {
const [isOpen, setIsOpen] = useState(false);
const activePopoverRef = useRef(null);
- const closePopover = useCallback((anchorRef?: RefObject) => {
+ const closePopover = useCallback((anchorRef?: RefObject): boolean => {
if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) {
- return;
+ return false;
}
activePopoverRef.current.close();
activePopoverRef.current = null;
setIsOpen(false);
+ return true;
}, []);
useEffect(() => {
@@ -63,11 +64,13 @@ function PopoverContextProvider(props: PopoverContextProps) {
if (e.key !== 'Escape') {
return;
}
- closePopover();
+ if (closePopover()) {
+ e.stopImmediatePropagation();
+ }
};
- document.addEventListener('keydown', listener, true);
+ document.addEventListener('keyup', listener, true);
return () => {
- document.removeEventListener('keydown', listener, true);
+ document.removeEventListener('keyup', listener, true);
};
}, [closePopover]);
diff --git a/src/components/Pressable/PressableWithFeedback.tsx b/src/components/Pressable/PressableWithFeedback.tsx
index b717c4890a2d..74ea4596046e 100644
--- a/src/components/Pressable/PressableWithFeedback.tsx
+++ b/src/components/Pressable/PressableWithFeedback.tsx
@@ -2,6 +2,7 @@ import React, {forwardRef, useState} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import type {AnimatedStyle} from 'react-native-reanimated';
import OpacityView from '@components/OpacityView';
+import type {Color} from '@styles/theme/types';
import variables from '@styles/variables';
import GenericPressable from './GenericPressable';
import type {PressableRef} from './GenericPressable/types';
@@ -27,6 +28,9 @@ type PressableWithFeedbackProps = PressableProps & {
/** Whether the view needs to be rendered offscreen (for Android only) */
needsOffscreenAlphaCompositing?: boolean;
+
+ /** The color of the underlay that will show through when the Pressable is active. */
+ underlayColor?: Color;
};
function PressableWithFeedback(
diff --git a/src/components/RadioButtonWithLabel.tsx b/src/components/RadioButtonWithLabel.tsx
index 52464a1453a1..cfcd6acba41f 100644
--- a/src/components/RadioButtonWithLabel.tsx
+++ b/src/components/RadioButtonWithLabel.tsx
@@ -55,7 +55,7 @@ function RadioButtonWithLabel({LabelComponent, style, label = '', hasError = fal
accessible={false}
onPress={onPress}
style={[styles.flexRow, styles.flexWrap, styles.flexShrink1, styles.alignItemsCenter]}
- wrapperStyle={[styles.ml3, styles.pr2, styles.w100]}
+ wrapperStyle={[styles.flex1, styles.ml3, styles.pr2]}
// disable hover style when disabled
hoverDimmingValue={0.8}
pressDimmingValue={0.5}
diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx
index 3407c5ad9afa..90c7d8580b5c 100644
--- a/src/components/RadioButtons.tsx
+++ b/src/components/RadioButtons.tsx
@@ -1,12 +1,16 @@
-import React, {useState} from 'react';
-import type {StyleProp, ViewStyle} from 'react-native';
+import React, {forwardRef, useEffect, useState} from 'react';
+import type {ForwardedRef} from 'react';
import {View} from 'react-native';
+import type {StyleProp, ViewStyle} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
+import type {MaybePhraseKey} from '@libs/Localize';
+import FormHelpMessage from './FormHelpMessage';
import RadioButtonWithLabel from './RadioButtonWithLabel';
type Choice = {
label: string;
value: string;
+ style?: StyleProp;
};
type RadioButtonsProps = {
@@ -19,33 +23,55 @@ type RadioButtonsProps = {
/** Callback to fire when selecting a radio button */
onPress: (value: string) => void;
+ /** Potential error text provided by a form InputWrapper */
+ errorText?: MaybePhraseKey;
+
/** Style for radio button */
radioButtonStyle?: StyleProp;
+
+ /** Callback executed when input value changes (same as onPress, but required by FormProvider for the sake of saving drafts) */
+ onInputChange?: (value: string) => void;
+
+ /** The checked value, if you're using this component as a controlled input. */
+ value?: string;
};
-function RadioButtons({items, onPress, defaultCheckedValue = '', radioButtonStyle}: RadioButtonsProps) {
+function RadioButtons({items, onPress, defaultCheckedValue = '', radioButtonStyle, errorText, onInputChange = () => {}, value}: RadioButtonsProps, ref: ForwardedRef) {
const styles = useThemeStyles();
const [checkedValue, setCheckedValue] = useState(defaultCheckedValue);
+ useEffect(() => {
+ if (value === checkedValue) {
+ return;
+ }
+ setCheckedValue(value ?? '');
+ }, [checkedValue, value]);
return (
-
- {items.map((item) => (
- {
- setCheckedValue(item.value);
- return onPress(item.value);
- }}
- label={item.label}
- />
- ))}
-
+ <>
+
+ {items.map((item) => (
+ {
+ setCheckedValue(item.value);
+ onInputChange(item.value);
+ return onPress(item.value);
+ }}
+ label={item.label}
+ />
+ ))}
+
+ {!!errorText && }
+ >
);
}
RadioButtons.displayName = 'RadioButtons';
export type {Choice};
-export default RadioButtons;
+export default forwardRef(RadioButtons);
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
index 4137b259f362..d768fe8e5d90 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
+++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
@@ -86,7 +86,7 @@ function MoneyRequestPreviewContent({
const requestMerchant = truncate(merchant, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH});
const hasReceipt = TransactionUtils.hasReceipt(transaction);
const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction);
- const hasViolations = TransactionUtils.hasViolation(transaction, transactionViolations);
+ const hasViolations = TransactionUtils.hasViolation(transaction?.transactionID ?? '', transactionViolations);
const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(transaction);
const shouldShowRBR = hasViolations || hasFieldErrors;
const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction);
@@ -152,13 +152,13 @@ function MoneyRequestPreviewContent({
let message = translate('iou.cash');
if (hasViolations && transaction) {
- const violations = TransactionUtils.getTransactionViolations(transaction, transactionViolations);
+ const violations = TransactionUtils.getTransactionViolations(transaction.transactionID, transactionViolations);
if (violations?.[0]) {
const violationMessage = ViolationsUtils.getViolationTranslation(violations[0], translate);
const isTooLong = violations.filter((v) => v.type === 'violation').length > 1 || violationMessage.length > 15;
message += ` • ${isTooLong ? translate('violations.reviewRequired') : violationMessage}`;
}
- } else if (ReportUtils.isPaidGroupPolicyExpenseReport(iouReport) && ReportUtils.isReportApproved(iouReport) && !ReportUtils.isSettled(iouReport)) {
+ } else if (ReportUtils.isPaidGroupPolicyExpenseReport(iouReport) && ReportUtils.isReportApproved(iouReport) && !ReportUtils.isSettled(iouReport?.reportID)) {
message += ` • ${translate('iou.approved')}`;
} else if (iouReport?.isWaitingOnBankAccount) {
message += ` • ${translate('iou.pending')}`;
@@ -275,7 +275,11 @@ function MoneyRequestPreviewContent({
{!isCurrentUserManager && shouldShowPendingConversionMessage && (
{translate('iou.pendingConversionMessage')}
)}
- {shouldShowDescription && ${parser.replace(merchantOrDescription)}`} />}
+ {shouldShowDescription && (
+
+ ${parser.replace(merchantOrDescription)}`} />
+
+ )}
{shouldShowMerchant && {merchantOrDescription}}
{isBillSplit && participantAccountIDs.length > 0 && !!requestAmount && requestAmount > 0 && (
diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index f125235affca..5e869ac15e1e 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -145,10 +145,7 @@ function MoneyRequestView({
const shouldShowBillable = isPolicyExpenseChat && (!!transactionBillable || !(policy?.disabledFields?.defaultBillable ?? true));
const {getViolationsForField} = useViolations(transactionViolations ?? []);
- const hasViolations = useCallback(
- (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']): boolean => !!canUseViolations && getViolationsForField(field, data).length > 0,
- [canUseViolations, getViolationsForField],
- );
+ const hasViolations = useCallback((field: ViolationField): boolean => !!canUseViolations && getViolationsForField(field).length > 0, [canUseViolations, getViolationsForField]);
let amountDescription = `${translate('iou.amount')}`;
@@ -202,7 +199,7 @@ function MoneyRequestView({
const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction;
const getErrorForField = useCallback(
- (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']) => {
+ (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], shouldShowViolations = true) => {
// Checks applied when creating a new money request
// NOTE: receipt field can return multiple violations, so we need to handle it separately
const fieldChecks: Partial> = {
@@ -228,8 +225,9 @@ function MoneyRequestView({
}
// Return violations if there are any
- if (canUseViolations && hasViolations(field, data)) {
- const violations = getViolationsForField(field, data);
+ // At the moment, we only return violations for tags for workspaces with single-level tags
+ if (canUseViolations && shouldShowViolations && hasViolations(field)) {
+ const violations = getViolationsForField(field);
return ViolationsUtils.getViolationTranslation(violations[0], translate);
}
@@ -373,7 +371,11 @@ function MoneyRequestView({
interactive={canEdit}
shouldShowRightIcon={canEdit}
titleStyle={styles.flex1}
- onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))}
+ onPress={() =>
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID),
+ )
+ }
brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
error={getErrorForField('category')}
/>
@@ -387,7 +389,7 @@ function MoneyRequestView({
>
))}
diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx
index 8b6a894cdd51..198b47cb4259 100644
--- a/src/components/ScreenWrapper.tsx
+++ b/src/components/ScreenWrapper.tsx
@@ -25,7 +25,7 @@ import SafeAreaConsumer from './SafeAreaConsumer';
import TestToolsModal from './TestToolsModal';
type ChildrenProps = {
- insets?: EdgeInsets;
+ insets: EdgeInsets;
safeAreaPaddingBottomStyle?: {
paddingBottom?: DimensionValue;
};
@@ -201,7 +201,17 @@ function ScreenWrapper(
return (
- {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => {
+ {({
+ insets = {
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ },
+ paddingTop,
+ paddingBottom,
+ safeAreaPaddingBottomStyle,
+ }) => {
const paddingStyle: StyleProp = {};
if (includePaddingTop) {
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index c39d7a05a4f7..2f853dc55839 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -12,6 +12,7 @@ import type {BaseListItemProps, ListItem} from './types';
function BaseListItem({
item,
+ pressableStyle,
wrapperStyle,
selectMultipleStyle,
isDisabled = false,
@@ -59,13 +60,14 @@ function BaseListItem({
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined}
nativeID={keyForList}
+ style={pressableStyle}
>
{({hovered}) => (
<>
{canSelectMultiple && (
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index b0996a08895a..1c69d00b3910 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -61,6 +61,8 @@ function BaseSelectionList(
rightHandSideComponent,
isLoadingNewOptions = false,
onLayout,
+ customListHeader,
+ listHeaderWrapperStyle,
}: BaseSelectionListProps,
inputRef: ForwardedRef,
) {
@@ -287,7 +289,7 @@ function BaseSelectionList(
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
onSelectRow={() => selectRow(item)}
- onDismissError={onDismissError}
+ onDismissError={() => onDismissError?.(item)}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
rightHandSideComponent={rightHandSideComponent}
keyForList={item.keyForList}
@@ -428,7 +430,7 @@ function BaseSelectionList(
<>
{!headerMessage && canSelectMultiple && shouldShowSelectAll && (
(
onPress={selectAllRow}
disabled={flattenedSections.allOptions.length === flattenedSections.disabledOptionsIndexes.length}
/>
-
- {translate('workspace.people.selectAll')}
-
+ {customListHeader ?? (
+
+ {translate('workspace.people.selectAll')}
+
+ )}
)}
+ {!headerMessage && !canSelectMultiple && customListHeader}
-
-
-
- {!!item.alternateText && (
+ <>
+
- )}
-
+
+ {!!item.alternateText && (
+
+ )}
+
+ {!!item.rightElement && item.rightElement}
+ >
);
}
diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx
new file mode 100644
index 000000000000..922937c72219
--- /dev/null
+++ b/src/components/SelectionList/TableListItem.tsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import {View} from 'react-native';
+import MultipleAvatars from '@components/MultipleAvatars';
+import TextWithTooltip from '@components/TextWithTooltip';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import BaseListItem from './BaseListItem';
+import type {TableListItemProps} from './types';
+
+function TableListItem({
+ item,
+ isFocused,
+ showTooltip,
+ isDisabled,
+ canSelectMultiple,
+ onSelectRow,
+ onDismissError,
+ shouldPreventDefaultFocusOnSelectRow,
+ rightHandSideComponent,
+}: TableListItemProps) {
+ const styles = useThemeStyles();
+ const theme = useTheme();
+ const StyleUtils = useStyleUtils();
+
+ const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
+ const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar;
+
+ return (
+
+ {(hovered) => (
+ <>
+ {!!item.icons && (
+
+ )}
+
+
+ {!!item.alternateText && (
+
+ )}
+
+ {!!item.rightElement && item.rightElement}
+ >
+ )}
+
+ );
+}
+
+TableListItem.displayName = 'TableListItem';
+
+export default TableListItem;
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index 403ccd91a26b..59f6b14cfb1f 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -4,6 +4,7 @@ import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
import type {ReceiptErrors} from '@src/types/onyx/Transaction';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import type RadioListItem from './RadioListItem';
+import type TableListItem from './TableListItem';
import type UserListItem from './UserListItem';
type CommonListItemProps = {
@@ -28,6 +29,9 @@ type CommonListItemProps = {
/** Component to display on the right side */
rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null;
+ /** Styles for the pressable component */
+ pressableStyle?: StyleProp;
+
/** Styles for the wrapper view */
wrapperStyle?: StyleProp;
@@ -121,6 +125,8 @@ type UserListItemProps = ListItemProps & {
type RadioListItemProps = ListItemProps;
+type TableListItemProps = ListItemProps;
+
type Section = {
/** Title of the section */
title?: string;
@@ -143,7 +149,7 @@ type BaseSelectionListProps = Partial & {
sections: Array>>;
/** Default renderer for every item in the list */
- ListItem: typeof RadioListItem | typeof UserListItem;
+ ListItem: typeof RadioListItem | typeof UserListItem | typeof TableListItem;
/** Whether this is a multi-select list */
canSelectMultiple?: boolean;
@@ -155,7 +161,7 @@ type BaseSelectionListProps = Partial & {
onSelectAll?: () => void;
/** Callback to fire when an error is dismissed */
- onDismissError?: () => void;
+ onDismissError?: (item: TItem) => void;
/** Label for the text input */
textInputLabel?: string;
@@ -246,6 +252,12 @@ type BaseSelectionListProps = Partial & {
/** Fired when the list is displayed with the items */
onLayout?: (event: LayoutChangeEvent) => void;
+
+ /** Custom header to show right above list */
+ customListHeader?: ReactNode;
+
+ /** Styles for the list header wrapper */
+ listHeaderWrapperStyle?: StyleProp;
};
type ItemLayout = {
@@ -272,6 +284,7 @@ export type {
BaseListItemProps,
UserListItemProps,
RadioListItemProps,
+ TableListItemProps,
ListItem,
ListItemProps,
FlattenedSectionsReturn,
diff --git a/src/components/SingleChoiceQuestion.tsx b/src/components/SingleChoiceQuestion.tsx
index c8bf783032ad..3ff844dd80e9 100644
--- a/src/components/SingleChoiceQuestion.tsx
+++ b/src/components/SingleChoiceQuestion.tsx
@@ -4,7 +4,6 @@ import React, {forwardRef} from 'react';
import type {Text as RNText} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import type {MaybePhraseKey} from '@libs/Localize';
-import FormHelpMessage from './FormHelpMessage';
import type {Choice} from './RadioButtons';
import RadioButtons from './RadioButtons';
import Text from './Text';
@@ -32,8 +31,8 @@ function SingleChoiceQuestion({prompt, errorText, possibleAnswers, currentQuesti
items={possibleAnswers}
key={currentQuestionIndex}
onPress={onInputChange}
+ errorText={errorText}
/>
-
>
);
}
diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts
index ce0f0e126252..a0f3d62c3547 100644
--- a/src/components/TextInput/BaseTextInput/types.ts
+++ b/src/components/TextInput/BaseTextInput/types.ts
@@ -51,15 +51,11 @@ type CustomBaseTextInputProps = {
/**
* Autogrow input container length based on the entered text.
- * Note: If you use this prop, the text input has to be controlled
- * by a value prop.
*/
autoGrow?: boolean;
/**
* Autogrow input container height based on the entered text
- * Note: If you use this prop, the text input has to be controlled
- * by a value prop.
*/
autoGrowHeight?: boolean;
diff --git a/src/components/VideoPlayer/BaseVideoPlayer.js b/src/components/VideoPlayer/BaseVideoPlayer.js
index 73dbf8407c0c..df79c7ef18da 100644
--- a/src/components/VideoPlayer/BaseVideoPlayer.js
+++ b/src/components/VideoPlayer/BaseVideoPlayer.js
@@ -13,6 +13,7 @@ import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
import * as Browser from '@libs/Browser';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import {videoPlayerDefaultProps, videoPlayerPropTypes} from './propTypes';
+import shouldReplayVideo from './shouldReplayVideo';
import VideoPlayerControls from './VideoPlayerControls';
const isMobileSafari = Browser.isMobileSafari();
@@ -95,6 +96,9 @@ function BaseVideoPlayer({
const handlePlaybackStatusUpdate = useCallback(
(e) => {
+ if (shouldReplayVideo(e, isPlaying, duration, position)) {
+ videoPlayerRef.current.setStatusAsync({positionMillis: 0, shouldPlay: true});
+ }
const isVideoPlaying = e.isPlaying || false;
preventPausingWhenExitingFullscreen(isVideoPlaying);
setIsPlaying(isVideoPlaying);
@@ -105,7 +109,7 @@ function BaseVideoPlayer({
onPlaybackStatusUpdate(e);
},
- [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration],
+ [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration, isPlaying, duration, position],
);
const handleFullscreenUpdate = useCallback(
diff --git a/src/components/VideoPlayer/shouldReplayVideo.android.ts b/src/components/VideoPlayer/shouldReplayVideo.android.ts
new file mode 100644
index 000000000000..c1c3de034aac
--- /dev/null
+++ b/src/components/VideoPlayer/shouldReplayVideo.android.ts
@@ -0,0 +1,11 @@
+import type {AVPlaybackStatusSuccess} from 'expo-av';
+
+/**
+ * Whether to replay the video when users press play button
+ */
+export default function shouldReplayVideo(e: AVPlaybackStatusSuccess, isPlaying: boolean, duration: number, position: number): boolean {
+ if (!isPlaying && !e.didJustFinish && duration === position) {
+ return true;
+ }
+ return false;
+}
diff --git a/src/components/VideoPlayer/shouldReplayVideo.ios.ts b/src/components/VideoPlayer/shouldReplayVideo.ios.ts
new file mode 100644
index 000000000000..0a923d430699
--- /dev/null
+++ b/src/components/VideoPlayer/shouldReplayVideo.ios.ts
@@ -0,0 +1,11 @@
+import type {AVPlaybackStatusSuccess} from 'expo-av';
+
+/**
+ * Whether to replay the video when users press play button
+ */
+export default function shouldReplayVideo(e: AVPlaybackStatusSuccess, isPlaying: boolean, duration: number, position: number): boolean {
+ if (!isPlaying && e.isPlaying && duration === position) {
+ return true;
+ }
+ return false;
+}
diff --git a/src/components/VideoPlayer/shouldReplayVideo.ts b/src/components/VideoPlayer/shouldReplayVideo.ts
new file mode 100644
index 000000000000..3a55562d4bd2
--- /dev/null
+++ b/src/components/VideoPlayer/shouldReplayVideo.ts
@@ -0,0 +1,9 @@
+import type {AVPlaybackStatusSuccess} from 'expo-av';
+
+/**
+ * Whether to replay the video when users press play button
+ */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export default function shouldReplayVideo(e: AVPlaybackStatusSuccess, isPlaying: boolean, duration: number, position: number): boolean {
+ return false;
+}
diff --git a/src/components/VideoPlayerContexts/PlaybackContext.js b/src/components/VideoPlayerContexts/PlaybackContext.js
index b77068f3aea2..8cf09f81c614 100644
--- a/src/components/VideoPlayerContexts/PlaybackContext.js
+++ b/src/components/VideoPlayerContexts/PlaybackContext.js
@@ -67,6 +67,9 @@ function PlaybackContextProvider({children}) {
const checkVideoPlaying = useCallback(
(statusCallback) => {
+ if (!(currentVideoPlayerRef && currentVideoPlayerRef.current && currentVideoPlayerRef.current.getStatusAsync)) {
+ return;
+ }
currentVideoPlayerRef.current.getStatusAsync().then((status) => {
statusCallback(status.isPlaying);
});
diff --git a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.js b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.js
index 23d1aec1817c..801c1b2f44ca 100644
--- a/src/components/VideoPlayerContexts/VideoPopoverMenuContext.js
+++ b/src/components/VideoPlayerContexts/VideoPopoverMenuContext.js
@@ -3,6 +3,7 @@ import React, {useCallback, useContext, useMemo, useState} from 'react';
import _ from 'underscore';
import * as Expensicons from '@components/Icon/Expensicons';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import fileDownload from '@libs/fileDownload';
import * as Url from '@libs/Url';
import CONST from '@src/CONST';
@@ -14,6 +15,7 @@ function VideoPopoverMenuContextProvider({children}) {
const {currentVideoPlayerRef} = usePlaybackContext();
const {translate} = useLocalize();
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS[2]);
+ const {isOffline} = useNetwork();
const updatePlaybackSpeed = useCallback(
(speed) => {
@@ -30,32 +32,36 @@ function VideoPopoverMenuContextProvider({children}) {
});
}, [currentVideoPlayerRef]);
- const menuItems = useMemo(
- () => [
- {
+ const menuItems = useMemo(() => {
+ const items = [];
+
+ if (!isOffline) {
+ items.push({
icon: Expensicons.Download,
text: translate('common.download'),
onSelected: () => {
downloadAttachment();
},
- },
- {
- icon: Expensicons.Meter,
- text: translate('videoPlayer.playbackSpeed'),
- subMenuItems: [
- ..._.map(CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS, (speed) => ({
- icon: currentPlaybackSpeed === speed ? Expensicons.Checkmark : null,
- text: speed.toString(),
- onSelected: () => {
- updatePlaybackSpeed(speed);
- },
- shouldPutLeftPaddingWhenNoIcon: true,
- })),
- ],
- },
- ],
- [currentPlaybackSpeed, downloadAttachment, translate, updatePlaybackSpeed],
- );
+ });
+ }
+
+ items.push({
+ icon: Expensicons.Meter,
+ text: translate('videoPlayer.playbackSpeed'),
+ subMenuItems: [
+ ..._.map(CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS, (speed) => ({
+ icon: currentPlaybackSpeed === speed ? Expensicons.Checkmark : null,
+ text: speed.toString(),
+ onSelected: () => {
+ updatePlaybackSpeed(speed);
+ },
+ shouldPutLeftPaddingWhenNoIcon: true,
+ })),
+ ],
+ });
+
+ return items;
+ }, [currentPlaybackSpeed, downloadAttachment, translate, updatePlaybackSpeed, isOffline]);
const contextValue = useMemo(() => ({menuItems, updatePlaybackSpeed}), [menuItems, updatePlaybackSpeed]);
return {children};
diff --git a/src/components/WorkspaceEmptyStateSection.tsx b/src/components/WorkspaceEmptyStateSection.tsx
new file mode 100644
index 000000000000..330f8e1ebbf5
--- /dev/null
+++ b/src/components/WorkspaceEmptyStateSection.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import {View} from 'react-native';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import type IconAsset from '@src/types/utils/IconAsset';
+import Icon from './Icon';
+import Text from './Text';
+
+type WorkspaceEmptyStateSectionProps = {
+ /** The text to display in the title of the section */
+ title: string;
+
+ /** The text to display in the subtitle of the section */
+ subtitle?: string;
+
+ /** The icon to display along with the title */
+ icon: IconAsset;
+};
+
+function WorkspaceEmptyStateSection({icon, subtitle, title}: WorkspaceEmptyStateSectionProps) {
+ const styles = useThemeStyles();
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ return (
+ <>
+
+
+
+
+
+ {title}
+
+
+ {!!subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ >
+ );
+}
+WorkspaceEmptyStateSection.displayName = 'WorkspaceEmptyStateSection';
+
+export default WorkspaceEmptyStateSection;
diff --git a/src/components/withKeyboardState.tsx b/src/components/withKeyboardState.tsx
index 74d10945fbcb..560576fdbf5c 100755
--- a/src/components/withKeyboardState.tsx
+++ b/src/components/withKeyboardState.tsx
@@ -8,27 +8,34 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps';
type KeyboardStateContextValue = {
/** Whether the keyboard is open */
isKeyboardShown: boolean;
+
+ /** Height of the keyboard in pixels */
+ keyboardHeight: number;
};
// TODO: Remove - left for backwards compatibility with existing components (https://github.com/Expensify/App/issues/25151)
const keyboardStatePropTypes = {
/** Whether the keyboard is open */
isKeyboardShown: PropTypes.bool.isRequired,
+
+ /** Height of the keyboard in pixels */
+ keyboardHeight: PropTypes.number.isRequired,
};
const KeyboardStateContext = createContext({
isKeyboardShown: false,
+ keyboardHeight: 0,
});
function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null {
- const [isKeyboardShown, setIsKeyboardShown] = useState(false);
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
- const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => {
- setIsKeyboardShown(true);
+ const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', (e) => {
+ setKeyboardHeight(e.endCoordinates.height);
});
const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
- setIsKeyboardShown(false);
+ setKeyboardHeight(0);
});
return () => {
@@ -39,9 +46,10 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null {
const contextValue = useMemo(
() => ({
- isKeyboardShown,
+ keyboardHeight,
+ isKeyboardShown: keyboardHeight !== 0,
}),
- [isKeyboardShown],
+ [keyboardHeight],
);
return {children};
}
diff --git a/src/hooks/useLocationBias.ts b/src/hooks/useLocationBias.ts
index b95ffbb57e9d..e18aba4a907c 100644
--- a/src/hooks/useLocationBias.ts
+++ b/src/hooks/useLocationBias.ts
@@ -1,15 +1,18 @@
import {useMemo} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import type {UserLocation} from '@src/types/onyx';
+import type {WaypointCollection} from '@src/types/onyx/Transaction';
/**
* Construct the rectangular boundary based on user location and waypoints
*/
-export default function useLocationBias(allWaypoints: Record, userLocation?: {latitude: number; longitude: number}) {
+export default function useLocationBias(allWaypoints: WaypointCollection, userLocation?: OnyxEntry) {
return useMemo(() => {
const hasFilledWaypointCount = Object.values(allWaypoints).some((waypoint) => Object.keys(waypoint).length > 0);
// If there are no filled wayPoints and if user's current location cannot be retrieved,
// it is futile to arrive at a biased location. Let's return
if (!hasFilledWaypointCount && userLocation === undefined) {
- return null;
+ return undefined;
}
// Gather the longitudes and latitudes from filled waypoints.
@@ -29,8 +32,8 @@ export default function useLocationBias(allWaypoints: Record void;
};
-type UseNetwork = {isOffline?: boolean};
+type UseNetwork = {isOffline: boolean};
export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork {
const callback = useRef(onReconnect);
callback.current = onReconnect;
- const {isOffline} = useContext(NetworkContext) ?? {};
+ const {isOffline} = useContext(NetworkContext) ?? CONST.DEFAULT_NETWORK_DATA;
const prevOfflineStatusRef = useRef(isOffline);
useEffect(() => {
// If we were offline before and now we are not offline then we just reconnected
@@ -28,5 +29,5 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {
prevOfflineStatusRef.current = isOffline;
}, [isOffline]);
- return {isOffline};
+ return {isOffline: isOffline ?? false};
}
diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts
index ea825b45bc0b..29b2dcb86718 100644
--- a/src/hooks/useViolations.ts
+++ b/src/hooks/useViolations.ts
@@ -58,19 +58,7 @@ function useViolations(violations: TransactionViolation[]) {
}
return violationGroups ?? new Map();
}, [violations]);
-
- const getViolationsForField = useCallback(
- (field: ViolationField, data?: TransactionViolation['data']) => {
- const currentViolations = violationsByField.get(field) ?? [];
-
- if (data?.tagName) {
- return currentViolations.filter((violation) => violation.data?.tagName === data.tagName);
- }
-
- return currentViolations;
- },
- [violationsByField],
- );
+ const getViolationsForField = useCallback((field: ViolationField) => violationsByField.get(field) ?? [], [violationsByField]);
return {
getViolationsForField,
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 0553d6470ddc..4d7041d4a791 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -121,6 +121,7 @@ export default {
no: 'No',
ok: 'OK',
buttonConfirm: 'Got it',
+ name: 'Name',
attachment: 'Attachment',
to: 'To',
optional: 'Optional',
@@ -205,6 +206,7 @@ export default {
iAcceptThe: 'I accept the ',
remove: 'Remove',
admin: 'Admin',
+ owner: 'Owner',
dateFormat: 'YYYY-MM-DD',
send: 'Send',
notifications: 'Notifications',
@@ -308,6 +310,8 @@ export default {
of: 'of',
default: 'Default',
update: 'Update',
+ member: 'Member',
+ role: 'Role',
},
location: {
useCurrent: 'Use current location',
@@ -856,7 +860,6 @@ export default {
noLogsAvailable: 'No logs available',
logSizeTooLarge: ({size}: LogSizeParams) => `Log size exceeds the limit of ${size} MB. Please use "Save log" to download the log file instead.`,
},
- goToExpensifyClassic: 'Go to Expensify Classic',
security: 'Security',
signOut: 'Sign out',
signOutConfirmationText: "You'll lose any offline changes if you sign-out.",
@@ -1026,6 +1029,25 @@ export default {
},
cardDetailsLoadingFailure: 'An error occurred while loading the card details. Please check your internet connection and try again.',
},
+ workflowsPage: {
+ workflowTitle: 'Spend',
+ workflowDescription: 'Configure a workflow from the moment spend occurs, including approval and payment.',
+ delaySubmissionTitle: 'Delay submissions',
+ delaySubmissionDescription: 'Expenses are shared right away for better spend visibility. Set a slower cadence if needed.',
+ submissionFrequency: 'Submission frequency',
+ weeklyFrequency: 'Weekly',
+ monthlyFrequency: 'Monthly',
+ twiceAMonthFrequency: 'Twice a month',
+ byTripFrequency: 'By trip',
+ manuallyFrequency: 'Manually',
+ dailyFrequency: 'Daily',
+ addApprovalsTitle: 'Add approvals',
+ approver: 'Approver',
+ connectBankAccount: 'Connect bank account',
+ addApprovalsDescription: 'Require additional approval before authorizing a payment.',
+ makeOrTrackPaymentsTitle: 'Make or track payments',
+ makeOrTrackPaymentsDescription: 'Add an authorized payer for payments made in Expensify, or simply track payments made elsewhere.',
+ },
reportFraudPage: {
title: 'Report virtual card fraud',
description: 'If your virtual card details have been stolen or compromised, we’ll permanently deactivate your existing card and provide you with a new virtual card and number.',
@@ -1679,11 +1701,15 @@ export default {
workspace: {
common: {
card: 'Cards',
+ workflows: 'Workflows',
workspace: 'Workspace',
edit: 'Edit workspace',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
delete: 'Delete workspace',
settings: 'Settings',
reimburse: 'Reimbursements',
+ categories: 'Categories',
bills: 'Bills',
invoices: 'Invoices',
travel: 'Travel',
@@ -1712,6 +1738,13 @@ export default {
control: 'Control',
collect: 'Collect',
},
+ categories: {
+ subtitle: 'Get a better overview of where money is being spent. Use our default categories or add your own.',
+ emptyCategories: {
+ title: "You haven't created any categories",
+ subtitle: 'Add a category to organize your spend.',
+ },
+ },
emptyWorkspace: {
title: 'Create a workspace',
subtitle: 'Workspaces are where you’ll chat with your team, reimburse expenses, issue cards, send invoices, pay bills, and more - all in one place.',
@@ -1745,6 +1778,7 @@ export default {
},
addedWithPrimary: 'Some users were added with their primary logins.',
invitedBySecondaryLogin: ({secondaryLogin}) => `Added by secondary login ${secondaryLogin}.`,
+ membersListTitle: 'Directory of all workspace members.',
},
card: {
header: 'Unlock free Expensify Cards',
@@ -2344,4 +2378,28 @@ export default {
mute: 'Mute',
unmute: 'Unmute',
},
+ exitSurvey: {
+ header: 'Before you go',
+ reasonPage: {
+ title: "Please tell us why you're leaving",
+ subtitle: 'Before you go, please tell us why you’d like to switch to Expensify Classic.',
+ },
+ reasons: {
+ [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "I need a feature that's only available in Expensify Classic.",
+ [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: "I don't understand how to use New Expensify.",
+ [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'I understand how to use New Expensify, but I prefer Expensify Classic.',
+ },
+ prompts: {
+ [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "What feature do you need that isn't available in New Expensify?",
+ [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'What are you trying to do?',
+ [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Why do you prefer Expensify Classic?',
+ },
+ responsePlaceholder: 'Your response',
+ thankYou: 'Thanks for the feedback!',
+ thankYouSubtitle: 'Your responses will help us build a better product to get stuff done. Thank you so much!',
+ goToExpensifyClassic: 'Switch to Expensify Classic',
+ offlineTitle: "Looks like you're stuck here...",
+ offline:
+ "You appear to be offline. Unfortunately, Expensify Classic doesn't work offline, but New Expensify does. If you prefer to use Expensify Classic, try again when you have an internet connection.",
+ },
} satisfies TranslationBase;
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 2a2eb96bd488..c9ff087d0de7 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -111,6 +111,7 @@ export default {
no: 'No',
ok: 'OK',
buttonConfirm: 'Ok, entendido',
+ name: 'Nombre',
attachment: 'Archivo adjunto',
to: 'A',
optional: 'Opcional',
@@ -195,6 +196,7 @@ export default {
iAcceptThe: 'Acepto los ',
remove: 'Eliminar',
admin: 'Administrador',
+ owner: 'Dueño',
dateFormat: 'AAAA-MM-DD',
send: 'Enviar',
notifications: 'Notificaciones',
@@ -298,6 +300,8 @@ export default {
of: 'de',
default: 'Predeterminado',
update: 'Actualizar',
+ member: 'Miembro',
+ role: 'Role',
},
location: {
useCurrent: 'Usar ubicación actual',
@@ -855,7 +859,6 @@ export default {
signOut: 'Desconectar',
signOutConfirmationText: 'Si cierras sesión perderás los cambios hechos mientras estabas desconectado',
versionLetter: 'v',
- goToExpensifyClassic: 'Ir a Expensify Classic',
readTheTermsAndPrivacy: {
phrase1: 'Leer los',
phrase2: 'Términos de Servicio',
@@ -1022,6 +1025,25 @@ export default {
},
cardDetailsLoadingFailure: 'Se ha producido un error al cargar los datos de la tarjeta. Comprueba tu conexión a Internet e inténtalo de nuevo.',
},
+ workflowsPage: {
+ workflowTitle: 'Gasto',
+ workflowDescription: 'Configure un flujo de trabajo desde el momento en que se produce el gasto, incluida la aprobación y el pago',
+ delaySubmissionTitle: 'Retrasar envíos',
+ delaySubmissionDescription: 'Los gastos se comparten de inmediato para una mejor visibilidad del gasto. Establece una cadencia más lenta si es necesario.',
+ submissionFrequency: 'Frecuencia de envíos',
+ weeklyFrequency: 'Semanal',
+ monthlyFrequency: 'Mensual',
+ twiceAMonthFrequency: 'Dos veces al mes',
+ byTripFrequency: 'Por viaje',
+ manuallyFrequency: 'Manual',
+ dailyFrequency: 'Diaria',
+ addApprovalsTitle: 'Requerir aprobaciones',
+ approver: 'Aprobador',
+ connectBankAccount: 'Conectar cuenta bancaria',
+ addApprovalsDescription: 'Requiere una aprobación adicional antes de autorizar un pago.',
+ makeOrTrackPaymentsTitle: 'Realizar o seguir pagos',
+ makeOrTrackPaymentsDescription: 'Añade un pagador autorizado para los pagos realizados en Expensify, o simplemente realiza un seguimiento de los pagos realizados en otro lugar.',
+ },
reportFraudPage: {
title: 'Reportar fraude con la tarjeta virtual',
description:
@@ -1703,11 +1725,15 @@ export default {
workspace: {
common: {
card: 'Tarjetas',
+ workflows: 'Flujos de trabajo',
workspace: 'Espacio de trabajo',
edit: 'Editar espacio de trabajo',
+ enabled: 'Activada',
+ disabled: 'Desactivada',
delete: 'Eliminar espacio de trabajo',
settings: 'Configuración',
reimburse: 'Reembolsos',
+ categories: 'Categorías',
bills: 'Pagar facturas',
invoices: 'Enviar facturas',
travel: 'Viajes',
@@ -1736,6 +1762,13 @@ export default {
control: 'Control',
collect: 'Recolectar',
},
+ categories: {
+ subtitle: 'Obtén una visión general de dónde te gastas el dinero. Utiliza las categorías predeterminadas o añade las tuyas propias.',
+ emptyCategories: {
+ title: 'No has creado ninguna categoría',
+ subtitle: 'Añade una categoría para organizar tu gasto.',
+ },
+ },
emptyWorkspace: {
title: 'Crea un espacio de trabajo',
subtitle: 'En los espacios de trabajo podrás chatear con tu equipo, reembolsar gastos, emitir tarjetas, enviar y pagar facturas, y mucho más - todo en un mismo lugar.',
@@ -1769,6 +1802,7 @@ export default {
},
addedWithPrimary: 'Se agregaron algunos usuarios con sus nombres de usuario principales.',
invitedBySecondaryLogin: ({secondaryLogin}) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`,
+ membersListTitle: 'Directorio de todos los miembros del espacio de trabajo.',
},
card: {
header: 'Desbloquea Tarjetas Expensify gratis',
@@ -2836,4 +2870,28 @@ export default {
mute: 'Silenciar',
unmute: 'Activar sonido',
},
+ exitSurvey: {
+ header: 'Antes de irte',
+ reasonPage: {
+ title: 'Dinos por qué te vas',
+ subtitle: 'Antes de irte, por favor dinos por qué te gustaría cambiarte a Expensify Classic.',
+ },
+ reasons: {
+ [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: 'Necesito una función que sólo está disponible en Expensify Classic.',
+ [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'No entiendo cómo usar New Expensify.',
+ [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Entiendo cómo usar New Expensify, pero prefiero Expensify Classic.',
+ },
+ prompts: {
+ [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: '¿Qué función necesitas que no esté disponible en New Expensify?',
+ [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: '¿Qué estás tratando de hacer?',
+ [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: '¿Por qué prefieres Expensify Classic?',
+ },
+ responsePlaceholder: 'Su respuesta',
+ thankYou: '¡Gracias por tus comentarios!',
+ thankYouSubtitle: 'Sus respuestas nos ayudarán a crear un mejor producto para hacer las cosas bien. ¡Muchas gracias!',
+ goToExpensifyClassic: 'Cambiar a Expensify Classic',
+ offlineTitle: 'Parece que estás atrapado aquí...',
+ offline:
+ 'Parece que estás desconectado. Desafortunadamente, Expensify Classic no funciona sin conexión, pero New Expensify sí. Si prefieres utilizar Expensify Classic, inténtalo de nuevo cuando tengas conexión a internet.',
+ },
} satisfies EnglishTranslation;
diff --git a/src/libs/API/parameters/CreateDistanceRequestParams.ts b/src/libs/API/parameters/CreateDistanceRequestParams.ts
index c1eb1003a698..62f90a64cf05 100644
--- a/src/libs/API/parameters/CreateDistanceRequestParams.ts
+++ b/src/libs/API/parameters/CreateDistanceRequestParams.ts
@@ -12,6 +12,8 @@ type CreateDistanceRequestParams = {
category?: string;
tag?: string;
billable?: boolean;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
};
export default CreateDistanceRequestParams;
diff --git a/src/libs/API/parameters/RequestMoneyParams.ts b/src/libs/API/parameters/RequestMoneyParams.ts
index 983394008ba7..b55f9fd7a2a9 100644
--- a/src/libs/API/parameters/RequestMoneyParams.ts
+++ b/src/libs/API/parameters/RequestMoneyParams.ts
@@ -25,6 +25,8 @@ type RequestMoneyParams = {
taxAmount: number;
billable?: boolean;
gpsPoints?: string;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
};
export default RequestMoneyParams;
diff --git a/src/libs/API/parameters/SendMoneyParams.ts b/src/libs/API/parameters/SendMoneyParams.ts
index b737ba2ea48b..ac6f42de5aa5 100644
--- a/src/libs/API/parameters/SendMoneyParams.ts
+++ b/src/libs/API/parameters/SendMoneyParams.ts
@@ -9,6 +9,8 @@ type SendMoneyParams = {
newIOUReportDetails: string;
createdReportActionID: string;
reportPreviewReportActionID: string;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
};
export default SendMoneyParams;
diff --git a/src/libs/API/parameters/SetWorkspaceApprovalModeParams.ts b/src/libs/API/parameters/SetWorkspaceApprovalModeParams.ts
new file mode 100644
index 000000000000..df84fbabbf95
--- /dev/null
+++ b/src/libs/API/parameters/SetWorkspaceApprovalModeParams.ts
@@ -0,0 +1,6 @@
+type SetWorkspaceApprovalModeParams = {
+ policyID: string;
+ value: string;
+};
+
+export default SetWorkspaceApprovalModeParams;
diff --git a/src/libs/API/parameters/SetWorkspaceAutoReportingParams.ts b/src/libs/API/parameters/SetWorkspaceAutoReportingParams.ts
new file mode 100644
index 000000000000..a87817986ffa
--- /dev/null
+++ b/src/libs/API/parameters/SetWorkspaceAutoReportingParams.ts
@@ -0,0 +1,6 @@
+type SetWorkspaceAutoReportingParams = {
+ policyID: string;
+ enabled: boolean;
+};
+
+export default SetWorkspaceAutoReportingParams;
diff --git a/src/libs/API/parameters/SwitchToOldDotParams.ts b/src/libs/API/parameters/SwitchToOldDotParams.ts
new file mode 100644
index 000000000000..95449a123dc9
--- /dev/null
+++ b/src/libs/API/parameters/SwitchToOldDotParams.ts
@@ -0,0 +1,9 @@
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+
+type SwitchToOldDotParams = {
+ reason?: ValueOf;
+ surveyResponse?: string;
+};
+
+export default SwitchToOldDotParams;
diff --git a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts
index f5cc3f664d12..dedc45d0365f 100644
--- a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts
+++ b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts
@@ -1,5 +1,5 @@
-import type {BeneficialOwnersStepProps} from '@src/types/form/ReimbursementAccountForm';
+import type {ACHContractStepProps} from '@src/types/form/ReimbursementAccountForm';
-type UpdateBeneficialOwnersForBankAccountParams = BeneficialOwnersStepProps & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean};
+type UpdateBeneficialOwnersForBankAccountParams = Partial & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean};
export default UpdateBeneficialOwnersForBankAccountParams;
diff --git a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts
index 21ca49839aec..6421fe02f571 100644
--- a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts
+++ b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts
@@ -1,5 +1,7 @@
-import type {CompanyStepProps} from '@src/types/form/ReimbursementAccountForm';
+import type {BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps} from '@src/types/form/ReimbursementAccountForm';
-type UpdateCompanyInformationForBankAccountParams = CompanyStepProps & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean};
+type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps;
+
+type UpdateCompanyInformationForBankAccountParams = Partial & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean};
export default UpdateCompanyInformationForBankAccountParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 2633d795b561..0b0a81eb21f8 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -145,3 +145,6 @@ export type {default as UnHoldMoneyRequestParams} from './UnHoldMoneyRequestPara
export type {default as CancelPaymentParams} from './CancelPaymentParams';
export type {default as AcceptACHContractForBankAccount} from './AcceptACHContractForBankAccount';
export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams';
+export type {default as SetWorkspaceAutoReportingParams} from './SetWorkspaceAutoReportingParams';
+export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams';
+export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 35b03f21c841..17cc366ba3b7 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -8,6 +8,8 @@ import type UpdateBeneficialOwnersForBankAccountParams from './parameters/Update
type ApiRequest = ValueOf;
const WRITE_COMMANDS = {
+ SET_WORKSPACE_AUTO_REPORTING: 'SetWorkspaceAutoReporting',
+ SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode',
DISMISS_REFERRAL_BANNER: 'DismissReferralBanner',
UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale',
RECONNECT_APP: 'ReconnectApp',
@@ -147,6 +149,7 @@ const WRITE_COMMANDS = {
PAY_MONEY_REQUEST: 'PayMoneyRequest',
CANCEL_PAYMENT: 'CancelPayment',
ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT: 'AcceptACHContractForBankAccount',
+ SWITCH_TO_OLD_DOT: 'SwitchToOldDot',
} as const;
type WriteCommand = ValueOf;
@@ -292,6 +295,9 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.CANCEL_PAYMENT]: Parameters.CancelPaymentParams;
[WRITE_COMMANDS.ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT]: Parameters.AcceptACHContractForBankAccount;
[WRITE_COMMANDS.UPDATE_WORKSPACE_DESCRIPTION]: Parameters.UpdateWorkspaceDescriptionParams;
+ [WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING]: Parameters.SetWorkspaceAutoReportingParams;
+ [WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams;
+ [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams;
};
const READ_COMMANDS = {
diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts
index 14c880edc593..204e78aa5458 100644
--- a/src/libs/Environment/Environment.ts
+++ b/src/libs/Environment/Environment.ts
@@ -24,6 +24,13 @@ function isDevelopment(): boolean {
return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV;
}
+/**
+ * Are we running the app in production?
+ */
+function isProduction(): Promise {
+ return getEnvironment().then((environment) => environment === CONST.ENVIRONMENT.PRODUCTION);
+}
+
/**
* Are we running an internal test build?
*/
@@ -47,4 +54,4 @@ function getOldDotEnvironmentURL(): Promise {
return getEnvironment().then((environment) => OLDDOT_ENVIRONMENT_URLS[environment]);
}
-export {getEnvironment, isInternalTestBuild, isDevelopment, getEnvironmentURL, getOldDotEnvironmentURL};
+export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL};
diff --git a/src/libs/FormUtils.ts b/src/libs/FormUtils.ts
deleted file mode 100644
index 4d0571ada6f2..000000000000
--- a/src/libs/FormUtils.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type {OnyxFormDraftKey, OnyxFormKey} from '@src/ONYXKEYS';
-
-function getDraftKey(formID: OnyxFormKey): OnyxFormDraftKey {
- return `${formID}Draft`;
-}
-
-export default {getDraftKey};
diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts
index 24437da48953..3f8a7d191f4b 100644
--- a/src/libs/GetPhysicalCardUtils.ts
+++ b/src/libs/GetPhysicalCardUtils.ts
@@ -54,7 +54,11 @@ function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDe
* @param privatePersonalDetails
* @returns
*/
-function getUpdatedDraftValues(draftValues: OnyxEntry, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry): GetPhysicalCardForm {
+function getUpdatedDraftValues(
+ draftValues: OnyxEntry,
+ privatePersonalDetails: OnyxEntry,
+ loginList: OnyxEntry,
+): Partial {
const {address, legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {};
return {
diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts
index 5d925ae1c684..58a82de3df53 100644
--- a/src/libs/GroupChatUtils.ts
+++ b/src/libs/GroupChatUtils.ts
@@ -1,5 +1,6 @@
import type {OnyxEntry} from 'react-native-onyx';
import type {Report} from '@src/types/onyx';
+import localeCompare from './LocaleCompare';
import * as ReportUtils from './ReportUtils';
/**
@@ -11,7 +12,7 @@ function getGroupChatName(report: OnyxEntry): string | undefined {
return participants
.map((participant) => ReportUtils.getDisplayNameForParticipant(participant, isMultipleParticipantReport))
- .sort((first, second) => first?.localeCompare(second ?? '') ?? 0)
+ .sort((first, second) => localeCompare(first ?? '', second ?? ''))
.filter(Boolean)
.join(', ');
}
diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts
index 0bd5d3162236..56ac47676a37 100644
--- a/src/libs/IOUUtils.ts
+++ b/src/libs/IOUUtils.ts
@@ -109,18 +109,18 @@ function isValidMoneyRequestType(iouType: string): boolean {
}
/**
- * Inserts a newly selected tag into the already existed report tags like a string
+ * Inserts a newly selected tag into the already existing tags like a string
*
- * @param reportTags - currently selected tags for a report
- * @param tag - a newly selected tag, that should be added to the reportTags
+ * @param transactionTags - currently selected tags for a report
+ * @param tag - a newly selected tag, that should be added to the transactionTags
* @param tagIndex - the index of a tag list
* @returns
*/
-function insertTagIntoReportTagsString(reportTags: string, tag: string, tagIndex: number): string {
- const splittedReportTags = reportTags.split(CONST.COLON);
- splittedReportTags[tagIndex] = tag;
+function insertTagIntoTransactionTagsString(transactionTags: string, tag: string, tagIndex: number): string {
+ const tagArray = TransactionUtils.getTagArrayFromName(transactionTags);
+ tagArray[tagIndex] = tag;
- return splittedReportTags.join(CONST.COLON).replace(/:*$/, '');
+ return tagArray.join(CONST.COLON).replace(/:*$/, '');
}
-export {calculateAmount, updateIOUOwnerAndTotal, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType, navigateToStartMoneyRequestStep, insertTagIntoReportTagsString};
+export {calculateAmount, updateIOUOwnerAndTotal, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType, navigateToStartMoneyRequestStep, insertTagIntoTransactionTagsString};
diff --git a/src/libs/KeyboardShortcut/index.ts b/src/libs/KeyboardShortcut/index.ts
index 44ba54953c40..0571f5e271ab 100644
--- a/src/libs/KeyboardShortcut/index.ts
+++ b/src/libs/KeyboardShortcut/index.ts
@@ -1,6 +1,7 @@
import Str from 'expensify-common/lib/str';
import * as KeyCommand from 'react-native-key-command';
import getOperatingSystem from '@libs/getOperatingSystem';
+import localeCompare from '@libs/LocaleCompare';
import CONST from '@src/CONST';
import bindHandlerToKeydownEvent from './bindHandlerToKeydownEvent';
@@ -32,7 +33,7 @@ type Shortcut = {
const documentedShortcuts: Record = {};
function getDocumentedShortcuts(): Shortcut[] {
- return Object.values(documentedShortcuts).sort((a, b) => a.displayName.localeCompare(b.displayName));
+ return Object.values(documentedShortcuts).sort((a, b) => localeCompare(a.displayName, b.displayName));
}
const keyInputEnter = KeyCommand?.constants?.keyInputEnter?.toString() ?? 'keyInputEnter';
diff --git a/src/libs/LocaleCompare.ts b/src/libs/LocaleCompare.ts
index 5142c5b43d9a..b2c48b410d32 100644
--- a/src/libs/LocaleCompare.ts
+++ b/src/libs/LocaleCompare.ts
@@ -1,19 +1,26 @@
import Onyx from 'react-native-onyx';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-const DEFAULT_LOCALE = 'en';
-
const COLLATOR_OPTIONS: Intl.CollatorOptions = {usage: 'sort', sensitivity: 'base'};
-let collator = new Intl.Collator(DEFAULT_LOCALE, COLLATOR_OPTIONS);
+let collator = new Intl.Collator(CONST.LOCALES.DEFAULT, COLLATOR_OPTIONS);
Onyx.connect({
key: ONYXKEYS.NVP_PREFERRED_LOCALE,
callback: (locale) => {
- collator = new Intl.Collator(locale ?? DEFAULT_LOCALE, COLLATOR_OPTIONS);
+ collator = new Intl.Collator(locale ?? CONST.LOCALES.DEFAULT, COLLATOR_OPTIONS);
},
});
+/**
+ * This is a wrapper around the localeCompare function that uses the preferred locale from the user's settings.
+ *
+ * It re-uses Intl.Collator with static options for performance reasons. See https://github.com/facebook/hermes/issues/867 for more details.
+ * @param a
+ * @param b
+ * @returns -1 if a < b, 1 if a > b, 0 if a === b
+ */
function localeCompare(a: string, b: string) {
return collator.compare(a, b);
}
diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts
index 13a58834860b..f501244a725d 100644
--- a/src/libs/ModifiedExpenseMessage.ts
+++ b/src/libs/ModifiedExpenseMessage.ts
@@ -9,6 +9,7 @@ import * as Localize from './Localize';
import * as PolicyUtils from './PolicyUtils';
import * as ReportUtils from './ReportUtils';
import type {ExpenseOriginalMessage} from './ReportUtils';
+import * as TransactionUtils from './TransactionUtils';
let allPolicyTags: OnyxCollection = {};
Onyx.connect({
@@ -189,8 +190,8 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr
const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {};
const transactionTag = reportActionOriginalMessage?.tag ?? '';
const oldTransactionTag = reportActionOriginalMessage?.oldTag ?? '';
- const splittedTag = transactionTag.split(CONST.COLON);
- const splittedOldTag = oldTransactionTag.split(CONST.COLON);
+ const splittedTag = TransactionUtils.getTagArrayFromName(transactionTag);
+ const splittedOldTag = TransactionUtils.getTagArrayFromName(oldTransactionTag);
const localizedTagListName = Localize.translateLocal('common.tag');
Object.keys(policyTags).forEach((policyTagKey, index) => {
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
index 3af123a74910..2be262aa5f0f 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
@@ -1,8 +1,8 @@
import type {ParamListBase} from '@react-navigation/routers';
import type {StackNavigationOptions} from '@react-navigation/stack';
-import {CardStyleInterpolators, createStackNavigator} from '@react-navigation/stack';
import React, {useMemo} from 'react';
import useThemeStyles from '@hooks/useThemeStyles';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import type {
AddPersonalBankAccountNavigatorParamList,
DetailsNavigatorParamList,
@@ -35,6 +35,7 @@ import type {
import type {ThemeStyles} from '@styles/index';
import type {Screen} from '@src/SCREENS';
import SCREENS from '@src/SCREENS';
+import subRouteOptions from './modalStackNavigatorOptions';
type Screens = Partial React.ComponentType>>;
@@ -45,16 +46,15 @@ type Screens = Partial React.ComponentType>>;
* @param getScreenOptions optional function that returns the screen options, override the default options
*/
function createModalStackNavigator(screens: Screens, getScreenOptions?: (styles: ThemeStyles) => StackNavigationOptions): React.ComponentType {
- const ModalStackNavigator = createStackNavigator();
+ const ModalStackNavigator = createPlatformStackNavigator();
function ModalStack() {
const styles = useThemeStyles();
const defaultSubRouteOptions = useMemo(
(): StackNavigationOptions => ({
+ ...subRouteOptions,
cardStyle: styles.navigationScreenCardStyle,
- headerShown: false,
- cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS,
}),
[styles],
);
@@ -100,7 +100,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.HOLD]: () => require('../../../pages/iou/HoldReasonPage').default as React.ComponentType,
- [SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').default as React.ComponentType,
[SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType,
[SCREENS.IOU_SEND.ADD_DEBIT_CARD]: () => require('../../../pages/settings/Wallet/AddDebitCardPage').default as React.ComponentType,
[SCREENS.IOU_SEND.ENABLE_PAYMENTS]: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default as React.ComponentType,
@@ -247,12 +246,16 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType,
[SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType,
[SCREENS.WORKSPACE.DESCRIPTION]: () => require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.SHARE]: () => require('../../../pages/workspace/WorkspaceProfileSharePage').default as React.ComponentType,
[SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceProfileCurrencyPage').default as React.ComponentType,
[SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType,
[SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType,
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType,
[SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: () => require('../../../pages/settings/Wallet/ReportCardLostPage').default as React.ComponentType,
[SCREENS.KEYBOARD_SHORTCUTS]: () => require('../../../pages/KeyboardShortcutsPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyReasonPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyResponsePage').default as React.ComponentType,
+ [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyConfirmPage').default as React.ComponentType,
});
const EnablePaymentsStackNavigator = createModalStackNavigator({
diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx
index 087e963b3892..262a93da9e33 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx
@@ -1,12 +1,12 @@
-import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import useThemeStyles from '@hooks/useThemeStyles';
import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper';
import getCurrentUrl from '@libs/Navigation/currentUrl';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import type {CentralPaneNavigatorParamList} from '@navigation/types';
import SCREENS from '@src/SCREENS';
-const Stack = createStackNavigator();
+const Stack = createPlatformStackNavigator();
const url = getCurrentUrl();
const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined;
@@ -17,11 +17,13 @@ const workspaceSettingsScreens = {
[SCREENS.SETTINGS.WORKSPACES]: () => require('../../../../../pages/workspace/WorkspacesListPage').default as React.ComponentType,
[SCREENS.WORKSPACE.PROFILE]: () => require('../../../../../pages/workspace/WorkspaceProfilePage').default as React.ComponentType,
[SCREENS.WORKSPACE.CARD]: () => require('../../../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType,
[SCREENS.WORKSPACE.BILLS]: () => require('../../../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.INVOICES]: () => require('../../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default as React.ComponentType,
[SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../../pages/workspace/travel/WorkspaceTravelPage').default as React.ComponentType,
[SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../../pages/workspace/categories/WorkspaceCategoriesPage').default as React.ComponentType,
} satisfies Screens;
function BaseCentralPaneNavigator() {
diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx
new file mode 100644
index 000000000000..30651e32cbd6
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx
@@ -0,0 +1,7 @@
+function Overlay() {
+ return null;
+}
+
+Overlay.displayName = 'Overlay';
+
+export default Overlay;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx
similarity index 100%
rename from src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx
rename to src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
index c421bdc82028..550fb947a4e6 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -1,5 +1,4 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import {createStackNavigator} from '@react-navigation/stack';
import React, {useMemo, useRef} from 'react';
import {View} from 'react-native';
import NoDropZone from '@components/DragAndDrop/NoDropZone';
@@ -7,6 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions';
import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import type {AuthScreensParamList, RightModalNavigatorParamList} from '@navigation/types';
import type NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
@@ -14,7 +14,7 @@ import Overlay from './Overlay';
type RightModalNavigatorProps = StackScreenProps;
-const Stack = createStackNavigator();
+const Stack = createPlatformStackNavigator();
function RightModalNavigator({navigation}: RightModalNavigatorProps) {
const styles = useThemeStyles();
diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.tsx b/src/libs/Navigation/AppNavigator/PublicScreens.tsx
index 6b1557994627..792a538cfd39 100644
--- a/src/libs/Navigation/AppNavigator/PublicScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/PublicScreens.tsx
@@ -1,5 +1,5 @@
-import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
+import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import type {PublicScreensParamList} from '@navigation/types';
import LogInWithShortLivedAuthTokenPage from '@pages/LogInWithShortLivedAuthTokenPage';
import AppleSignInDesktopPage from '@pages/signin/AppleSignInDesktopPage';
@@ -12,7 +12,7 @@ import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import defaultScreenOptions from './defaultScreenOptions';
-const RootStack = createStackNavigator();
+const RootStack = createPlatformStackNavigator();
function PublicScreens() {
return (
diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts
new file mode 100644
index 000000000000..17100bc71bda
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts
@@ -0,0 +1,11 @@
+const defaultScreenOptions = {
+ contentStyle: {
+ overflow: 'visible',
+ flex: 1,
+ },
+ headerShown: false,
+ animationTypeForReplace: 'push',
+ animation: 'slide_from_right',
+};
+
+export default defaultScreenOptions;
diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts
new file mode 100644
index 000000000000..4015c43c679e
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts
@@ -0,0 +1,12 @@
+import type {StackNavigationOptions} from '@react-navigation/stack';
+
+const defaultScreenOptions: StackNavigationOptions = {
+ cardStyle: {
+ overflow: 'visible',
+ flex: 1,
+ },
+ headerShown: false,
+ animationTypeForReplace: 'push',
+};
+
+export default defaultScreenOptions;
diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts
new file mode 100644
index 000000000000..2b062fd2f2be
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts
@@ -0,0 +1,8 @@
+import type {NativeStackNavigationOptions} from '@react-navigation/native-stack';
+
+const rightModalNavigatorOptions = (): NativeStackNavigationOptions => ({
+ presentation: 'card',
+ animation: 'slide_from_right',
+});
+
+export default rightModalNavigatorOptions;
diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts
new file mode 100644
index 000000000000..935c0041b794
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts
@@ -0,0 +1,20 @@
+import type {StackNavigationOptions} from '@react-navigation/stack';
+// eslint-disable-next-line no-restricted-imports
+import getNavigationModalCardStyle from '@styles/utils/getNavigationModalCardStyles';
+
+const rightModalNavigatorOptions = (isSmallScreenWidth: boolean): StackNavigationOptions => ({
+ presentation: 'transparentModal',
+
+ // We want pop in RHP since there are some flows that would work weird otherwise
+ animationTypeForReplace: 'pop',
+ cardStyle: {
+ ...getNavigationModalCardStyle(),
+
+ // This is necessary to cover translated sidebar with overlay.
+ width: isSmallScreenWidth ? '100%' : '200%',
+ // Excess space should be on the left so we need to position from right.
+ right: 0,
+ },
+});
+
+export default rightModalNavigatorOptions;
diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
index c3a69bbd7ccf..5685afec5459 100644
--- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
+++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts
@@ -4,6 +4,7 @@ import type {StyleUtilsType} from '@styles/utils';
import variables from '@styles/variables';
import CONFIG from '@src/CONFIG';
import createModalCardStyleInterpolator from './createModalCardStyleInterpolator';
+import getRightModalNavigatorOptions from './getRightModalNavigatorOptions';
type ScreenOptions = Record;
@@ -25,23 +26,12 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr
return {
rightModalNavigator: {
...commonScreenOptions,
+ ...getRightModalNavigatorOptions(isSmallScreenWidth),
cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props),
- presentation: 'transparentModal',
-
- // We want pop in RHP since there are some flows that would work weird otherwise
- animationTypeForReplace: 'pop',
- cardStyle: {
- ...StyleUtils.getNavigationModalCardStyle(),
-
- // This is necessary to cover translated sidebar with overlay.
- width: isSmallScreenWidth ? '100%' : '200%',
- // Excess space should be on the left so we need to position from right.
- right: 0,
- },
},
leftModalNavigator: {
...commonScreenOptions,
- cardStyleInterpolator: (props) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER),
+ cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER),
presentation: 'transparentModal',
// We want pop in LHP since there are some flows that would work weird otherwise
@@ -59,8 +49,8 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr
homeScreen: {
title: CONFIG.SITE_TITLE,
...commonScreenOptions,
+ // Note: The card* properties won't be applied on mobile platforms, as they use the native defaults.
cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props),
-
cardStyle: {
...StyleUtils.getNavigationModalCardStyle(),
width: isSmallScreenWidth ? '100%' : variables.sideBarWidth,
@@ -73,6 +63,7 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr
fullScreen: {
...commonScreenOptions,
+
cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props),
cardStyle: {
...StyleUtils.getNavigationModalCardStyle(),
@@ -87,7 +78,9 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr
...commonScreenOptions,
animationEnabled: isSmallScreenWidth,
cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props),
-
+ // temporary solution - better to hide a keyboard than see keyboard flickering
+ // see https://github.com/software-mansion/react-native-screens/issues/2021 for more details
+ keyboardHandlingEnabled: true,
cardStyle: {
...StyleUtils.getNavigationModalCardStyle(),
paddingRight: isSmallScreenWidth ? 0 : variables.sideBarWidth,
diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts
new file mode 100644
index 000000000000..ca9769fa9972
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts
@@ -0,0 +1,8 @@
+import type {NativeStackNavigationOptions} from '@react-navigation/native-stack';
+
+const defaultSubRouteOptions: NativeStackNavigationOptions = {
+ headerShown: false,
+ animation: 'slide_from_right',
+};
+
+export default defaultSubRouteOptions;
diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts
new file mode 100644
index 000000000000..280a38b263b7
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts
@@ -0,0 +1,9 @@
+import type {StackNavigationOptions} from '@react-navigation/stack';
+import {CardStyleInterpolators} from '@react-navigation/stack';
+
+const defaultSubRouteOptions: StackNavigationOptions = {
+ headerShown: false,
+ cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS,
+};
+
+export default defaultSubRouteOptions;
diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts
new file mode 100644
index 000000000000..ef44cefc13c9
--- /dev/null
+++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts
@@ -0,0 +1,7 @@
+import {createNativeStackNavigator} from '@react-navigation/native-stack';
+
+function createPlatformStackNavigator() {
+ return createNativeStackNavigator();
+}
+
+export default createPlatformStackNavigator;
diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts
new file mode 100644
index 000000000000..51228295572f
--- /dev/null
+++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts
@@ -0,0 +1,5 @@
+import {createStackNavigator} from '@react-navigation/stack';
+
+const createPlatformStackNavigator: typeof createStackNavigator = () => createStackNavigator();
+
+export default createPlatformStackNavigator;
diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts
index 3a4abe225120..371ea89df2e2 100644
--- a/src/libs/Navigation/linkTo.ts
+++ b/src/libs/Navigation/linkTo.ts
@@ -215,7 +215,7 @@ export default function linkTo(navigation: NavigationContainerRef> = {
- [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION],
+ [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE],
[SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT],
[SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE],
};
diff --git a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts
index 446fb479ea09..f4316009b70b 100755
--- a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts
@@ -7,11 +7,13 @@ const TAB_TO_CENTRAL_PANE_MAPPING: Record = {
[SCREENS.WORKSPACE.INITIAL]: [
SCREENS.WORKSPACE.PROFILE,
SCREENS.WORKSPACE.CARD,
+ SCREENS.WORKSPACE.WORKFLOWS,
SCREENS.WORKSPACE.REIMBURSE,
SCREENS.WORKSPACE.BILLS,
SCREENS.WORKSPACE.INVOICES,
SCREENS.WORKSPACE.TRAVEL,
SCREENS.WORKSPACE.MEMBERS,
+ SCREENS.WORKSPACE.CATEGORIES,
],
};
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 2640025efa09..48d649cc4dd9 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -46,6 +46,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.CARD]: {
path: ROUTES.WORKSPACE_CARD.route,
},
+ [SCREENS.WORKSPACE.WORKFLOWS]: {
+ path: ROUTES.WORKSPACE_WORKFLOWS.route,
+ },
[SCREENS.WORKSPACE.REIMBURSE]: {
path: ROUTES.WORKSPACE_REIMBURSE.route,
},
@@ -61,6 +64,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.MEMBERS]: {
path: ROUTES.WORKSPACE_MEMBERS.route,
},
+ [SCREENS.WORKSPACE.CATEGORIES]: {
+ path: ROUTES.WORKSPACE_CATEGORIES.route,
+ },
},
},
[SCREENS.NOT_FOUND]: '*',
@@ -235,6 +241,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.DESCRIPTION]: {
path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route,
},
+ [SCREENS.WORKSPACE.SHARE]: {
+ path: ROUTES.WORKSPACE_PROFILE_SHARE.route,
+ },
[SCREENS.WORKSPACE.RATE_AND_UNIT]: {
path: ROUTES.WORKSPACE_RATE_AND_UNIT.route,
},
@@ -264,6 +273,15 @@ const config: LinkingOptions['config'] = {
[SCREENS.SETTINGS.SHARE_CODE]: {
path: ROUTES.SETTINGS_SHARE_CODE,
},
+ [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: {
+ path: ROUTES.SETTINGS_EXIT_SURVEY_REASON,
+ },
+ [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: {
+ path: ROUTES.SETTINGS_EXIT_SURVEY_RESPONSE.route,
+ },
+ [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: {
+ path: ROUTES.SETTINGS_EXIT_SURVEY_CONFIRM.route,
+ },
},
},
[SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: {
@@ -428,7 +446,6 @@ const config: LinkingOptions['config'] = {
[SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route,
[SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route,
[SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route,
- [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route,
[SCREENS.MONEY_REQUEST.RECEIPT]: ROUTES.MONEY_REQUEST_RECEIPT.route,
[SCREENS.MONEY_REQUEST.DISTANCE]: ROUTES.MONEY_REQUEST_DISTANCE.route,
[SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS,
diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
index 55ccca73a389..02ad78a4c044 100644
--- a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
+++ b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts
@@ -1,9 +1,11 @@
import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute';
-import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
+import type {CentralPaneName, CentralPaneNavigatorParamList, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types';
import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING';
+const WORKSPACES_SCREENS = TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.WORKSPACE.INITIAL].concat(TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.ALL_SETTINGS]);
+
/**
* @param state - react-navigation state
*/
@@ -31,8 +33,47 @@ const getTopMostReportIDFromRHP = (state: State): string => {
return '';
};
+// Check if the given route has a policyID equal to the id provided in the function params
+function hasRouteMatchingPolicyID(route: NavigationPartialRoute, policyID?: string) {
+ if (!route.params) {
+ return false;
+ }
+
+ const params = `params` in route?.params ? (route.params.params as Record) : undefined;
+
+ // If params are not defined, then we need to check if the policyID exists
+ if (!params) {
+ return !policyID;
+ }
+
+ return 'policyID' in params && params.policyID === policyID;
+}
+
+// Get already opened settings screen within the policy
+function getAlreadyOpenedSettingsScreen(rootState?: State, policyID?: string): keyof CentralPaneNavigatorParamList | undefined {
+ if (!rootState) {
+ return undefined;
+ }
+
+ // If one of the screen from WORKSPACES_SCREENS is now in the navigation state, we can decide which screen we should display.
+ // A screen from the navigation state can be pushed to the navigation state again only if it has a matching policyID with the currently selected workspace.
+ // Otherwise, when we switch the workspace, we want to display the initial screen in the settings tab.
+ const alreadyOpenedSettingsTab = rootState.routes
+ .filter((item) => item.params && 'screen' in item.params && WORKSPACES_SCREENS.includes(item.params.screen as keyof CentralPaneNavigatorParamList))
+ .at(-1);
+
+ if (!hasRouteMatchingPolicyID(alreadyOpenedSettingsTab as NavigationPartialRoute, policyID)) {
+ return undefined;
+ }
+
+ const settingsScreen =
+ alreadyOpenedSettingsTab?.params && 'screen' in alreadyOpenedSettingsTab?.params ? (alreadyOpenedSettingsTab?.params?.screen as keyof CentralPaneNavigatorParamList) : undefined;
+
+ return settingsScreen;
+}
+
// Get matching central pane route for bottom tab navigator. e.g HOME -> REPORT
-function getMatchingCentralPaneRouteForState(state: State): NavigationPartialRoute | undefined {
+function getMatchingCentralPaneRouteForState(state: State, rootState?: State): NavigationPartialRoute | undefined {
const topmostBottomTabRoute = getTopmostBottomTabRoute(state);
if (!topmostBottomTabRoute) {
@@ -42,7 +83,10 @@ function getMatchingCentralPaneRouteForState(state: State):
const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0];
if (topmostBottomTabRoute.name === SCREENS.WORKSPACE.INITIAL) {
- return {name: centralPaneName, params: topmostBottomTabRoute.params};
+ // When we go back to the settings tab without switching the workspace id, we want to return to the previously opened screen
+ const policyID = topmostBottomTabRoute?.params && 'policyID' in topmostBottomTabRoute?.params ? (topmostBottomTabRoute.params.policyID as string) : undefined;
+ const screen = getAlreadyOpenedSettingsScreen(rootState, policyID) ?? centralPaneName;
+ return {name: screen, params: topmostBottomTabRoute.params};
}
if (topmostBottomTabRoute.name === SCREENS.HOME) {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 81229f353e52..f02bb3bd2aca 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -15,6 +15,7 @@ import type CONST from '@src/CONST';
import type NAVIGATORS from '@src/NAVIGATORS';
import type {HybridAppRoute, Route as Routes} from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
+import type EXIT_SURVEY_REASON_FORM_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm';
type NavigationRef = NavigationContainerRefWithCurrent;
@@ -59,6 +60,9 @@ type CentralPaneNavigatorParamList = {
[SCREENS.WORKSPACE.CARD]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.WORKFLOWS]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.REIMBURSE]: {
policyID: string;
};
@@ -74,6 +78,9 @@ type CentralPaneNavigatorParamList = {
[SCREENS.WORKSPACE.MEMBERS]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.CATEGORIES]: {
+ policyID: string;
+ };
};
type WorkspaceSwitcherNavigatorParamList = {
@@ -92,9 +99,15 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: undefined;
[SCREENS.SETTINGS.PROFILE.ADDRESS]: undefined;
[SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: undefined;
- [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: undefined;
- [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: undefined;
- [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: undefined;
+ [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: {
+ backTo: Routes;
+ };
+ [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: {
+ contactMethod: string;
+ };
+ [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: {
+ backTo: Routes;
+ };
[SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined;
[SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined;
[SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined;
@@ -146,6 +159,7 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.CURRENCY]: undefined;
[SCREENS.WORKSPACE.NAME]: undefined;
[SCREENS.WORKSPACE.DESCRIPTION]: undefined;
+ [SCREENS.WORKSPACE.SHARE]: undefined;
[SCREENS.WORKSPACE.RATE_AND_UNIT]: {
policyID: string;
};
@@ -167,6 +181,14 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: undefined;
[SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: undefined;
[SCREENS.KEYBOARD_SHORTCUTS]: undefined;
+ [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: undefined;
+ [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: {
+ [EXIT_SURVEY_REASON_FORM_INPUT_IDS.REASON]: ValueOf;
+ backTo: Routes;
+ };
+ [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: {
+ backTo: Routes;
+ };
} & ReimbursementAccountNavigatorParamList;
type NewChatNavigatorParamList = {
@@ -257,9 +279,12 @@ type MoneyRequestNavigatorParamList = {
reportID: string;
backTo: string;
};
- [SCREENS.MONEY_REQUEST.CATEGORY]: {
- iouType: string;
+ [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: {
+ action: ValueOf;
+ iouType: ValueOf;
+ transactionID: string;
reportID: string;
+ backTo: string;
};
[SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: {
iouType: string;
@@ -280,6 +305,13 @@ type MoneyRequestNavigatorParamList = {
reportID: string;
backTo: string;
};
+ [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: {
+ iouType: ValueOf;
+ reportID: string;
+ backTo: Routes | undefined;
+ action: ValueOf;
+ pageIndex: string;
+ };
[SCREENS.MONEY_REQUEST.STEP_MERCHANT]: {
action: ValueOf;
iouType: ValueOf;
diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts
index 8da35232112d..f03c34b1696e 100644
--- a/src/libs/NextStepUtils.ts
+++ b/src/libs/NextStepUtils.ts
@@ -73,7 +73,7 @@ function buildNextStep(
const {policyID = '', ownerAccountID = -1, managerID = -1} = report;
const policy = ReportUtils.getPolicy(policyID);
- const {submitsTo, harvesting, isPreventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy;
+ const {submitsTo, harvesting, isPreventSelfApprovalEnabled, preventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy;
const isOwner = currentUserAccountID === ownerAccountID;
const isManager = currentUserAccountID === managerID;
const isSelfApproval = currentUserAccountID === submitsTo;
@@ -164,7 +164,7 @@ function buildNextStep(
}
// Prevented self submitting
- if (isPreventSelfApprovalEnabled && isSelfApproval) {
+ if ((isPreventSelfApprovalEnabled ?? preventSelfApprovalEnabled) && isSelfApproval) {
optimisticNextStep.message = [
{
text: "Oops! Looks like you're submitting to ",
diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts
index d7eb87a2ed1e..60e5246f5ed2 100644
--- a/src/libs/NumberUtils.ts
+++ b/src/libs/NumberUtils.ts
@@ -69,4 +69,11 @@ function parseFloatAnyLocale(value: string): number {
return parseFloat(value ? value.replace(',', '.') : value);
}
-export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale};
+/**
+ * Given an input number p and another number q, returns the largest number that's less than p and divisible by q.
+ */
+function roundDownToLargestMultiple(p: number, q: number) {
+ return Math.floor(p / q) * q;
+}
+
+export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 97b4fc0144c8..8698cd21729c 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -34,6 +34,7 @@ import times from '@src/utils/times';
import Timing from './actions/Timing';
import * as CollectionUtils from './CollectionUtils';
import * as ErrorUtils from './ErrorUtils';
+import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
import * as Localize from './Localize';
import * as LoginUtils from './LoginUtils';
@@ -871,9 +872,9 @@ function sortTags(tags: Record | Tag[]) {
let sortedTags;
if (Array.isArray(tags)) {
- sortedTags = tags.sort((a, b) => a.name.localeCompare(b.name));
+ sortedTags = tags.sort((a, b) => localeCompare(a.name, b.name));
} else {
- sortedTags = Object.values(tags).sort((a, b) => a.name.localeCompare(b.name));
+ sortedTags = Object.values(tags).sort((a, b) => localeCompare(a.name, b.name));
}
return sortedTags;
@@ -1061,7 +1062,8 @@ function getTagsOptions(tags: Category[]): Option[] {
function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOptions: Category[], searchInputValue: string, maxRecentReportsToShow: number) {
const tagSections = [];
const sortedTags = sortTags(tags);
- const enabledTags = sortedTags.filter((tag) => tag.enabled);
+ const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name);
+ const enabledTags = [...selectedOptions, ...sortedTags.filter((tag) => tag.enabled && !selectedOptionNames.includes(tag.name))];
const numberOfTags = enabledTags.length;
let indexOffset = 0;
@@ -1109,7 +1111,6 @@ function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOpt
return tagSections;
}
- const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name);
const filteredRecentlyUsedTags = recentlyUsedTags
.filter((recentlyUsedTag) => {
const tagObject = tags.find((tag) => tag.name === recentlyUsedTag);
@@ -1119,13 +1120,11 @@ function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOpt
const filteredTags = enabledTags.filter((tag) => !selectedOptionNames.includes(tag.name));
if (selectedOptions.length) {
- const selectedTagOptions = selectedOptions.map((option) => {
- const tagObject = tags.find((tag) => tag.name === option.name);
- return {
- name: option.name,
- enabled: !!tagObject?.enabled,
- };
- });
+ const selectedTagOptions = selectedOptions.map((option) => ({
+ name: option.name,
+ // Should be marked as enabled to be able to unselect even though the selected category is disabled
+ enabled: true,
+ }));
tagSections.push({
// "Selected" section
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index a85e97a4cf05..70f87a8c7373 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -3,9 +3,11 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
import type {PersonalDetailsList, Policy, PolicyMembers, PolicyTagList, PolicyTags} from '@src/types/onyx';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import Navigation from './Navigation/Navigation';
type MemberEmailsToAccountIDs = Record;
type UnitRate = {rate: number};
@@ -93,7 +95,7 @@ function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean): boolea
);
}
-function isExpensifyTeam(email: string): boolean {
+function isExpensifyTeam(email: string | undefined): boolean {
const emailDomain = Str.extractEmailDomain(email ?? '');
return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN;
}
@@ -250,6 +252,13 @@ function getPolicyMembersByIdWithoutCurrentUser(policyMembers: OnyxCollection): boolean {
return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED;
}
-function isDeletedAction(reportAction: OnyxEntry): boolean {
+function isDeletedAction(reportAction: OnyxEntry): boolean {
// A deleted comment has either an empty array or an object with html field with empty string as value
const message = reportAction?.message ?? [];
return message.length === 0 || message[0]?.html === '';
@@ -103,8 +104,8 @@ function isDeletedParentAction(reportAction: OnyxEntry): boolean {
return (reportAction?.message?.[0]?.isDeletedParentAction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
}
-function isReversedTransaction(reportAction: OnyxEntry) {
- return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
+function isReversedTransaction(reportAction: OnyxEntry) {
+ return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && ((reportAction as ReportAction)?.childVisibleActionCount ?? 0) > 0;
}
function isPendingRemove(reportAction: OnyxEntry | EmptyObject): boolean {
@@ -184,9 +185,11 @@ function getParentReportAction(report: OnyxEntry | EmptyObject): ReportA
/**
* Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object.
*/
-function isSentMoneyReportAction(reportAction: OnyxEntry): boolean {
+function isSentMoneyReportAction(reportAction: OnyxEntry): boolean {
return (
- reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction?.originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!reportAction?.originalMessage?.IOUDetails
+ reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
+ (reportAction?.originalMessage as IOUMessage)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY &&
+ !!(reportAction?.originalMessage as IOUMessage)?.IOUDetails
);
}
@@ -517,7 +520,7 @@ function filterOutDeprecatedReportActions(reportActions: ReportActions | null):
* to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp).
* This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY.
*/
-function getSortedReportActionsForDisplay(reportActions: ReportActions | null, shouldMarkTheFirstItemAsNewest = false): ReportAction[] {
+function getSortedReportActionsForDisplay(reportActions: ReportActions | ReportAction[] | null, shouldMarkTheFirstItemAsNewest = false): ReportAction[] {
const filteredReportActions = Object.entries(reportActions ?? {})
.filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key))
.map((entry) => entry[1]);
@@ -800,14 +803,6 @@ function getMemberChangeMessageFragment(reportAction: OnyxEntry):
};
}
-/**
- * MARKEDREIMBURSED reportActions come from marking a report as reimbursed in OldDot. For now, we just
- * concat all of the text elements of the message to create the full message.
- */
-function getMarkedReimbursedMessage(reportAction: OnyxEntry): string {
- return reportAction?.message?.map((element) => element.text).join('') ?? '';
-}
-
function getMemberChangeMessagePlainText(reportAction: OnyxEntry): string {
const messageElements = getMemberChangeMessageElements(reportAction);
return messageElements.map((element) => element.content).join('');
@@ -935,7 +930,6 @@ export {
hasRequestFromCurrentAccount,
getFirstVisibleReportActionID,
isMemberChangeAction,
- getMarkedReimbursedMessage,
getMemberChangeMessageFragment,
getMemberChangeMessagePlainText,
isReimbursementDeQueuedAction,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index ae6e02e70d29..d202a6440a5d 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -52,6 +52,7 @@ import * as CollectionUtils from './CollectionUtils';
import * as CurrencyUtils from './CurrencyUtils';
import DateUtils from './DateUtils';
import isReportMessageAttachment from './isReportMessageAttachment';
+import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
import * as Localize from './Localize';
import linkingConfig from './Navigation/linkingConfig';
@@ -722,12 +723,10 @@ function hasParticipantInArray(report: Report, policyMemberAccountIDs: number[])
/**
* Whether the Money Request report is settled
*/
-function isSettled(reportOrID: Report | OnyxEntry | string | undefined): boolean {
- if (!allReports || !reportOrID) {
+function isSettled(reportID: string | undefined): boolean {
+ if (!allReports || !reportID) {
return false;
}
- const reportID = typeof reportOrID === 'string' ? reportOrID : reportOrID?.reportID;
-
const report: Report | EmptyObject = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? {};
if (isEmptyObject(report) || report.isWaitingOnBankAccount) {
return false;
@@ -1443,7 +1442,7 @@ function getIconsForParticipants(participants: number[], personalDetails: OnyxCo
const sortedParticipantDetails = participantDetails.sort((first, second) => {
// First sort by displayName/login
- const displayNameLoginOrder = first[1].localeCompare(second[1]);
+ const displayNameLoginOrder = localeCompare(first[1], second[1]);
if (displayNameLoginOrder !== 0) {
return displayNameLoginOrder;
}
@@ -1618,22 +1617,6 @@ function getPersonalDetailsForAccountID(accountID: number): Partial {
// First sort by displayName/login
- const displayNameLoginOrder = first.displayName.localeCompare(second.displayName);
+ const displayNameLoginOrder = localeCompare(first.displayName, second.displayName);
if (displayNameLoginOrder !== 0) {
return displayNameLoginOrder;
}
@@ -2259,7 +2242,7 @@ function hasMissingSmartscanFields(iouReportID: string): boolean {
/**
* Given a parent IOU report action get report name for the LHN.
*/
-function getTransactionReportName(reportAction: OnyxEntry): string {
+function getTransactionReportName(reportAction: OnyxEntry): string {
if (ReportActionsUtils.isReversedTransaction(reportAction)) {
return Localize.translateLocal('parentReportAction.reversedTransaction');
}
@@ -3763,7 +3746,7 @@ function buildOptimisticTaskReport(
*
* @param moneyRequestReportID - the reportID which the report action belong to
*/
-function buildTransactionThread(reportAction: OnyxEntry, moneyRequestReportID: string): OptimisticChatReport {
+function buildTransactionThread(reportAction: OnyxEntry, moneyRequestReportID: string): OptimisticChatReport {
const participantAccountIDs = [...new Set([currentUserAccountID, Number(reportAction?.actorAccountID)])].filter(Boolean) as number[];
return buildOptimisticChatReport(
participantAccountIDs,
@@ -3967,6 +3950,13 @@ function shouldReportBeInOptionList({
return true;
}
+ const reportIsSettled = report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED;
+
+ // Always show IOU reports with violations unless they are reimbursed
+ if (isExpenseRequest(report) && doesReportHaveViolations && !reportIsSettled) {
+ return true;
+ }
+
// Hide only chat threads that haven't been commented on (other threads are actionable)
if (isChatThread(report) && canHideReport && isEmptyChat) {
return false;
@@ -3978,11 +3968,6 @@ function shouldReportBeInOptionList({
return true;
}
- // Always show IOU reports with violations
- if (isExpenseRequest(report) && doesReportHaveViolations) {
- return true;
- }
-
// All unread chats (even archived ones) in GSD mode will be shown. This is because GSD mode is specifically for focusing the user on the most relevant chats, primarily, the unread ones
if (isInGSDMode) {
return isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE;
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 0a13d561891c..ee0ec6d9755b 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -5,6 +5,7 @@ import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {RecentWaypoint, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx';
+import type {IOUMessage} from '@src/types/onyx/OriginalMessage';
import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates';
import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
@@ -12,6 +13,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import {isCorporateCard, isExpensifyCard} from './CardUtils';
import DateUtils from './DateUtils';
import * as NumberUtils from './NumberUtils';
+import type {OptimisticIOUReportAction} from './ReportUtils';
let allTransactions: OnyxCollection = {};
@@ -367,18 +369,50 @@ function getBillable(transaction: OnyxEntry): boolean {
return transaction?.billable ?? false;
}
+/**
+ * Return a colon-delimited tag string as an array, considering escaped colons and double backslashes.
+ */
+function getTagArrayFromName(tagName: string): string[] {
+ // WAIT!!!!!!!!!!!!!!!!!!
+ // You need to keep this in sync with TransactionUtils.php
+
+ // We need to be able to preserve double backslashes in the original string
+ // and not have it interfere with splitting on a colon (:).
+ // So, let's replace it with something absurd to begin with, do our split, and
+ // then replace the double backslashes in the end.
+ const tagWithoutDoubleSlashes = tagName.replace(/\\\\/g, '☠');
+ const tagWithoutEscapedColons = tagWithoutDoubleSlashes.replace(/\\:/g, '☢');
+
+ // Do our split
+ const matches = tagWithoutEscapedColons.split(':');
+ const newMatches: string[] = [];
+
+ for (const item of matches) {
+ const tagWithEscapedColons = item.replace(/☢/g, '\\:');
+ const tagWithDoubleSlashes = tagWithEscapedColons.replace(/☠/g, '\\\\');
+ newMatches.push(tagWithDoubleSlashes);
+ }
+
+ return newMatches;
+}
+
/**
* Return the tag from the transaction. When the tagIndex is passed, return the tag based on the index.
* This "tag" field has no "modified" complement.
*/
function getTag(transaction: OnyxEntry, tagIndex?: number): string {
if (tagIndex !== undefined) {
- return transaction?.tag?.split(CONST.COLON)[tagIndex] ?? '';
+ const tagsArray = getTagArrayFromName(transaction?.tag ?? '');
+ return tagsArray[tagIndex] ?? '';
}
return transaction?.tag ?? '';
}
+function getTagForDisplay(transaction: OnyxEntry, tagIndex?: number): string {
+ return getTag(transaction, tagIndex).replace(/[\\\\]:/g, ':');
+}
+
/**
* Return the created field from the transaction, return the modifiedCreated if present.
*/
@@ -463,11 +497,11 @@ function hasRoute(transaction: Transaction): boolean {
*
* @deprecated Use Onyx.connect() or withOnyx() instead
*/
-function getLinkedTransaction(reportAction: OnyxEntry): Transaction | EmptyObject {
+function getLinkedTransaction(reportAction: OnyxEntry): Transaction | EmptyObject {
let transactionID = '';
if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) {
- transactionID = reportAction.originalMessage?.IOUTransactionID ?? '';
+ transactionID = (reportAction?.originalMessage as IOUMessage)?.IOUTransactionID ?? '';
}
return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {};
@@ -553,20 +587,11 @@ function isOnHold(transaction: OnyxEntry): boolean {
/**
* Checks if any violations for the provided transaction are of type 'violation'
*/
-function hasViolation(transactionOrID: Transaction | OnyxEntry | string, transactionViolations: OnyxCollection): boolean {
- if (!transactionOrID) {
- return false;
- }
- const transactionID = typeof transactionOrID === 'string' ? transactionOrID : transactionOrID.transactionID;
+function hasViolation(transactionID: string, transactionViolations: OnyxCollection): boolean {
return Boolean(transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === 'violation'));
}
-function getTransactionViolations(transactionOrID: OnyxEntry | string, transactionViolations: OnyxCollection): TransactionViolation[] | null {
- if (!transactionOrID) {
- return null;
- }
- const transactionID = typeof transactionOrID === 'string' ? transactionOrID : transactionOrID.transactionID;
-
+function getTransactionViolations(transactionID: string, transactionViolations: OnyxCollection): TransactionViolation[] | null {
return transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID] ?? null;
}
@@ -608,6 +633,8 @@ export {
getCategory,
getBillable,
getTag,
+ getTagArrayFromName,
+ getTagForDisplay,
getTransactionViolations,
getLinkedTransaction,
getAllReportTransactions,
diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts
index 4ca11459fdb6..147343e99ceb 100644
--- a/src/libs/UserUtils.ts
+++ b/src/libs/UserUtils.ts
@@ -174,7 +174,7 @@ function getAvatarUrl(avatarSource: AvatarSource | undefined, accountID: number)
* Avatars uploaded by users will have a _128 appended so that the asset server returns a small version.
* This removes that part of the URL so the full version of the image can load.
*/
-function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID: number): AvatarSource {
+function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID?: number): AvatarSource {
const source = getAvatar(avatarSource, accountID);
if (typeof source !== 'string') {
return source;
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 02ae638a41d3..0a46acbea102 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -5,7 +5,7 @@ import isDate from 'lodash/isDate';
import isEmpty from 'lodash/isEmpty';
import isObject from 'lodash/isObject';
import type {OnyxCollection} from 'react-native-onyx';
-import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types';
+import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormValue} from '@components/Form/types';
import CONST from '@src/CONST';
import type {OnyxFormKey} from '@src/ONYXKEYS';
import type {Report} from '@src/types/onyx';
@@ -37,7 +37,11 @@ function validateCardNumber(value: string): boolean {
/**
* Validating that this is a valid address (PO boxes are not allowed)
*/
-function isValidAddress(value: string): boolean {
+function isValidAddress(value: FormValue): boolean {
+ if (typeof value !== 'string') {
+ return false;
+ }
+
if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.EMOJIS)) {
return false;
}
@@ -77,7 +81,7 @@ function isValidPastDate(date: string | Date): boolean {
* Used to validate a value that is "required".
* @param value - field value
*/
-function isRequiredFulfilled(value?: string | boolean | Date): boolean {
+function isRequiredFulfilled(value?: FormValue): boolean {
if (!value) {
return false;
}
@@ -103,7 +107,7 @@ function getFieldRequiredErrors(values: FormOnyxVal
const errors: FormInputErrors = {};
requiredFields.forEach((fieldKey) => {
- if (isRequiredFulfilled(values[fieldKey] as keyof FormOnyxValues)) {
+ if (isRequiredFulfilled(values[fieldKey] as FormValue)) {
return;
}
diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts
index a1cd001badee..6153ea62cd0d 100644
--- a/src/libs/Violations/ViolationsUtils.ts
+++ b/src/libs/Violations/ViolationsUtils.ts
@@ -30,7 +30,7 @@ const ViolationsUtils = {
// Add 'categoryOutOfPolicy' violation if category is not in policy
if (!hasCategoryOutOfPolicyViolation && categoryKey && !isCategoryInPolicy) {
- newTransactionViolations.push({name: 'categoryOutOfPolicy', type: 'violation', userMessage: ''});
+ newTransactionViolations.push({name: 'categoryOutOfPolicy', type: 'violation'});
}
// Remove 'categoryOutOfPolicy' violation if category is in policy
@@ -45,72 +45,40 @@ const ViolationsUtils = {
// Add 'missingCategory' violation if category is required and not set
if (!hasMissingCategoryViolation && policyRequiresCategories && !categoryKey) {
- newTransactionViolations.push({name: 'missingCategory', type: 'violation', userMessage: ''});
+ newTransactionViolations.push({name: 'missingCategory', type: 'violation'});
}
}
if (policyRequiresTags) {
- const selectedTags = updatedTransaction.tag?.split(CONST.COLON) ?? [];
const policyTagKeys = Object.keys(policyTagList);
- if (policyTagKeys.length === 0) {
- newTransactionViolations.push({
- name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY,
- type: 'violation',
- userMessage: '',
- });
- }
-
- policyTagKeys.forEach((key, index) => {
- const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && violation.data?.tagName === key);
- const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG && violation.data?.tagName === key);
- const selectedTag = selectedTags[index];
- const isTagInPolicy = Boolean(policyTagList[key]?.tags[selectedTag]?.enabled);
+ // At the moment, we only return violations for tags for workspaces with single-level tags
+ if (policyTagKeys.length === 1) {
+ const policyTagListName = policyTagKeys[0];
+ const policyTags = policyTagList[policyTagListName]?.tags;
+ const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY);
+ const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG);
+ const isTagInPolicy = policyTags ? !!policyTags[updatedTransaction.tag ?? '']?.enabled : false;
// Add 'tagOutOfPolicy' violation if tag is not in policy
- if (!hasTagOutOfPolicyViolation && selectedTag && !isTagInPolicy) {
- newTransactionViolations.push({
- name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY,
- type: 'violation',
- userMessage: '',
- data: {
- tagName: key,
- },
- });
+ if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy) {
+ newTransactionViolations.push({name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, type: 'violation'});
}
// Remove 'tagOutOfPolicy' violation if tag is in policy
- if (hasTagOutOfPolicyViolation && selectedTag && isTagInPolicy) {
- newTransactionViolations = reject(newTransactionViolations, {
- name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY,
- data: {
- tagName: key,
- },
- });
+ if (hasTagOutOfPolicyViolation && updatedTransaction.tag && isTagInPolicy) {
+ newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY});
}
// Remove 'missingTag' violation if tag is valid according to policy
if (hasMissingTagViolation && isTagInPolicy) {
- newTransactionViolations = reject(newTransactionViolations, {
- name: CONST.VIOLATIONS.MISSING_TAG,
- data: {
- tagName: key,
- },
- });
+ newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_TAG});
}
-
// Add 'missingTag violation' if tag is required and not set
- if (!hasMissingTagViolation && !selectedTag && policyRequiresTags) {
- newTransactionViolations.push({
- name: CONST.VIOLATIONS.MISSING_TAG,
- type: 'violation',
- userMessage: '',
- data: {
- tagName: key,
- },
- });
+ if (!hasMissingTagViolation && !updatedTransaction.tag && policyRequiresTags) {
+ newTransactionViolations.push({name: CONST.VIOLATIONS.MISSING_TAG, type: 'violation'});
}
- });
+ }
}
return {
diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts
index 30dd03b6e780..0f4e1aed36a7 100644
--- a/src/libs/actions/BankAccounts.ts
+++ b/src/libs/actions/BankAccounts.ts
@@ -366,7 +366,7 @@ function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subS
* Updates the bank account in the database with the company step data
* @param params - Business step form data
*/
-function updateCompanyInformationForBankAccount(bankAccountID: number, params: CompanyStepProps, policyID: string) {
+function updateCompanyInformationForBankAccount(bankAccountID: number, params: Partial, policyID: string) {
API.write(
WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT,
{
@@ -383,7 +383,7 @@ function updateCompanyInformationForBankAccount(bankAccountID: number, params: C
* Add beneficial owners for the bank account and verify the accuracy of the information provided
* @param params - Beneficial Owners step form params
*/
-function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: BeneficialOwnersStepProps, policyID: string) {
+function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: Partial, policyID: string) {
API.write(
WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT,
{
diff --git a/src/libs/actions/ExitSurvey.ts b/src/libs/actions/ExitSurvey.ts
new file mode 100644
index 000000000000..ef3ecd6d3e31
--- /dev/null
+++ b/src/libs/actions/ExitSurvey.ts
@@ -0,0 +1,78 @@
+import type {OnyxUpdate} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
+import * as API from '@libs/API';
+import ONYXKEYS from '@src/ONYXKEYS';
+import REASON_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm';
+import type {ExitReason} from '@src/types/form/ExitSurveyReasonForm';
+import RESPONSE_INPUT_IDS from '@src/types/form/ExitSurveyResponseForm';
+
+let exitReason: ExitReason | undefined;
+let exitSurveyResponse: string | undefined;
+Onyx.connect({
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM,
+ callback: (value) => (exitReason = value?.[REASON_INPUT_IDS.REASON]),
+});
+Onyx.connect({
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM,
+ callback: (value) => (exitSurveyResponse = value?.[RESPONSE_INPUT_IDS.RESPONSE]),
+});
+
+function saveExitReason(reason: ExitReason) {
+ Onyx.set(ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM, {[REASON_INPUT_IDS.REASON]: reason});
+}
+
+function saveResponse(response: string) {
+ Onyx.set(ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM, {[RESPONSE_INPUT_IDS.RESPONSE]: response});
+}
+
+/**
+ * Save the user's response to the mandatory exit survey in the back-end.
+ */
+function switchToOldDot() {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.IS_SWITCHING_TO_OLD_DOT,
+ value: true,
+ },
+ ];
+
+ const finallyData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.IS_SWITCHING_TO_OLD_DOT,
+ value: false,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM_DRAFT,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM_DRAFT,
+ value: null,
+ },
+ ];
+
+ API.write(
+ 'SwitchToOldDot',
+ {
+ reason: exitReason,
+ surveyResponse: exitSurveyResponse,
+ },
+ {optimisticData, finallyData},
+ );
+}
+
+export {saveExitReason, saveResponse, switchToOldDot};
diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts
index 3a0bdb94d5f5..8207b78e8759 100644
--- a/src/libs/actions/FormActions.ts
+++ b/src/libs/actions/FormActions.ts
@@ -1,6 +1,5 @@
import Onyx from 'react-native-onyx';
import type {NullishDeep} from 'react-native-onyx';
-import FormUtils from '@libs/FormUtils';
import type {OnyxFormDraftKey, OnyxFormKey, OnyxValue} from '@src/ONYXKEYS';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
@@ -25,11 +24,11 @@ function clearErrorFields(formID: OnyxFormKey) {
}
function setDraftValues(formID: OnyxFormKey, draftValues: NullishDeep>) {
- Onyx.merge(FormUtils.getDraftKey(formID), draftValues);
+ Onyx.merge(`${formID}Draft`, draftValues);
}
function clearDraftValues(formID: OnyxFormKey) {
- Onyx.set(FormUtils.getDraftKey(formID), null);
+ Onyx.set(`${formID}Draft`, null);
}
export {setDraftValues, setErrorFields, setErrors, clearErrors, clearErrorFields, setIsLoading, clearDraftValues};
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index f39728e7d31c..37308c73e724 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -60,7 +60,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import * as Policy from './Policy';
import * as Report from './Report';
-type MoneyRequestRoute = StackScreenProps['route'];
+type MoneyRequestRoute = StackScreenProps['route'];
type IOURequestType = ValueOf;
@@ -76,6 +76,8 @@ type MoneyRequestInformation = {
createdChatReportActionID: string;
createdIOUReportActionID: string;
reportPreviewAction: OnyxTypes.ReportAction;
+ transactionThreadReportID: string;
+ createdReportActionIDForThread: string;
onyxData: OnyxData;
};
@@ -304,16 +306,10 @@ function setMoneyRequestPendingFields(transactionID: string, pendingFields: Pend
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields});
}
-// eslint-disable-next-line @typescript-eslint/naming-convention
-function setMoneyRequestCategory_temporaryForRefactor(transactionID: string, category: string) {
+function setMoneyRequestCategory(transactionID: string, category: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category});
}
-// eslint-disable-next-line @typescript-eslint/naming-convention
-function resetMoneyRequestCategory_temporaryForRefactor(transactionID: string) {
- Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category: null});
-}
-
function setMoneyRequestTag(transactionID: string, tag: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {tag});
}
@@ -396,6 +392,8 @@ function buildOnyxDataForMoneyRequest(
optimisticPolicyRecentlyUsedCategories: string[],
optimisticPolicyRecentlyUsedTags: OnyxTypes.RecentlyUsedTags,
isNewChatReport: boolean,
+ transactionThreadReport: OptimisticChatReport,
+ transactionThreadCreatedReportAction: OptimisticCreatedReportAction,
shouldCreateNewMoneyRequestReport: boolean,
policy?: OnyxEntry,
policyTagList?: OnyxEntry,
@@ -405,6 +403,7 @@ function buildOnyxDataForMoneyRequest(
): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] {
const isScanRequest = TransactionUtils.isScanRequest(transaction);
const outstandingChildRequest = getOutstandingChildRequest(needsToBeManuallySubmitted, policy);
+ const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null]));
const optimisticData: OnyxUpdate[] = [];
if (chatReport) {
@@ -474,6 +473,19 @@ function buildOnyxDataForMoneyRequest(
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`,
+ value: transactionThreadReport,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`,
+ value: {
+ [transactionThreadCreatedReportAction.reportActionID]: transactionThreadCreatedReportAction,
+ },
+ },
+
// Remove the temporary transaction used during the creation flow
{
onyxMethod: Onyx.METHOD.SET,
@@ -536,12 +548,20 @@ function buildOnyxDataForMoneyRequest(
errorFields: null,
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`,
+ value: {
+ pendingFields: null,
+ errorFields: null,
+ },
+ },
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
value: {
pendingAction: null,
- pendingFields: null,
+ pendingFields: clearedPendingFields,
},
},
@@ -580,6 +600,16 @@ function buildOnyxDataForMoneyRequest(
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`,
+ value: {
+ [transactionThreadCreatedReportAction.reportActionID]: {
+ pendingAction: null,
+ errors: null,
+ },
+ },
+ },
);
const failureData: OnyxUpdate[] = [
@@ -610,23 +640,24 @@ function buildOnyxDataForMoneyRequest(
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`,
+ value: {
+ errorFields: {
+ createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'),
+ },
+ },
+ },
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
value: {
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
pendingAction: null,
- pendingFields: null,
+ pendingFields: clearedPendingFields,
},
},
-
- // Remove the temporary transaction used during the creation flow
- {
- onyxMethod: Onyx.METHOD.SET,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`,
- value: null,
- },
-
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`,
@@ -676,6 +707,15 @@ function buildOnyxDataForMoneyRequest(
}),
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`,
+ value: {
+ [transactionThreadCreatedReportAction.reportActionID]: {
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ },
+ },
+ },
];
// We don't need to compute violations unless we're on a paid policy
@@ -830,7 +870,8 @@ function getMoneyRequestInformation(
// 1. CREATED action for the chatReport
// 2. CREATED action for the iouReport
// 3. IOU action for the iouReport
- // 4. REPORTPREVIEW action for the chatReport
+ // 4. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread
+ // 5. REPORTPREVIEW action for the chatReport
// Note: The CREATED action for the IOU report must be optimistically generated before the IOU action so there's no chance that it appears after the IOU action in the chat
const currentTime = DateUtils.getDBTime();
const optimisticCreatedActionForChat = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail);
@@ -850,6 +891,8 @@ function getMoneyRequestInformation(
false,
currentTime,
);
+ const optimisticTransactionThread = ReportUtils.buildTransactionThread(iouAction, iouReport.reportID);
+ const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail);
let reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID);
if (reportPreviewAction) {
@@ -893,6 +936,8 @@ function getMoneyRequestInformation(
optimisticPolicyRecentlyUsedCategories,
optimisticPolicyRecentlyUsedTags,
isNewChatReport,
+ optimisticTransactionThread,
+ optimisticCreatedActionForTransactionThread,
shouldCreateNewMoneyRequestReport,
policy,
policyTagList,
@@ -911,6 +956,8 @@ function getMoneyRequestInformation(
createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : '0',
createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOU.reportActionID : '0',
reportPreviewAction,
+ transactionThreadReportID: optimisticTransactionThread.reportID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
onyxData: {
optimisticData,
successData,
@@ -946,7 +993,18 @@ function createDistanceRequest(
source: ReceiptGeneric as ReceiptSource,
state: CONST.IOU.RECEIPT_STATE.OPEN,
};
- const {iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = getMoneyRequestInformation(
+ const {
+ iouReport,
+ chatReport,
+ transaction,
+ iouAction,
+ createdChatReportActionID,
+ createdIOUReportActionID,
+ reportPreviewAction,
+ transactionThreadReportID,
+ createdReportActionIDForThread,
+ onyxData,
+ } = getMoneyRequestInformation(
currentChatReport,
participant,
comment,
@@ -981,6 +1039,8 @@ function createDistanceRequest(
category,
tag,
billable,
+ transactionThreadReportID,
+ createdReportActionIDForThread,
};
API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData);
@@ -1445,27 +1505,39 @@ function requestMoney(
const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report;
const moneyRequestReportID = isMoneyRequestReport ? report.reportID : '';
const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created);
- const {payerAccountID, payerEmail, iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} =
- getMoneyRequestInformation(
- currentChatReport,
- participant,
- comment,
- amount,
- currency,
- currentCreated,
- merchant,
- receipt,
- undefined,
- category,
- tag,
- billable,
- policy,
- policyTagList,
- policyCategories,
- payeeAccountID,
- payeeEmail,
- moneyRequestReportID,
- );
+ const {
+ payerAccountID,
+ payerEmail,
+ iouReport,
+ chatReport,
+ transaction,
+ iouAction,
+ createdChatReportActionID,
+ createdIOUReportActionID,
+ reportPreviewAction,
+ transactionThreadReportID,
+ createdReportActionIDForThread,
+ onyxData,
+ } = getMoneyRequestInformation(
+ currentChatReport,
+ participant,
+ comment,
+ amount,
+ currency,
+ currentCreated,
+ merchant,
+ receipt,
+ undefined,
+ category,
+ tag,
+ billable,
+ policy,
+ policyTagList,
+ policyCategories,
+ payeeAccountID,
+ payeeEmail,
+ moneyRequestReportID,
+ );
const activeReportID = isMoneyRequestReport ? report.reportID : chatReport.reportID;
const parameters: RequestMoneyParams = {
@@ -1490,9 +1562,10 @@ function requestMoney(
taxCode,
taxAmount,
billable,
-
// This needs to be a string of JSON because of limitations with the fetch() API and nested objects
gpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined,
+ transactionThreadReportID,
+ createdReportActionIDForThread,
};
API.write(WRITE_COMMANDS.REQUEST_MONEY, parameters, onyxData);
@@ -1612,6 +1685,11 @@ function createSplitsAndOnyxData(
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`,
value: splitTransaction,
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`,
+ value: null,
+ },
];
const successData: OnyxUpdate[] = [
@@ -1628,11 +1706,6 @@ function createSplitsAndOnyxData(
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`,
value: {pendingAction: null},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`,
- value: null,
- },
];
if (!existingSplitChatReport) {
@@ -1651,11 +1724,6 @@ function createSplitsAndOnyxData(
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`,
- value: null,
- },
];
if (existingSplitChatReport) {
@@ -1820,6 +1888,10 @@ function createSplitsAndOnyxData(
// Add tag to optimistic policy recently used tags when a participant is a workspace
const optimisticPolicyRecentlyUsedTags = isPolicyExpenseChat ? Policy.buildOptimisticPolicyRecentlyUsedTags(participant.policyID, tag) : {};
+ // Create optimistic transactionThread
+ const optimisticTransactionThread = ReportUtils.buildTransactionThread(oneOnOneIOUAction, oneOnOneIOUReport.reportID);
+ const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit);
+
// STEP 5: Build Onyx Data
const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest(
oneOnOneChatReport,
@@ -1833,6 +1905,8 @@ function createSplitsAndOnyxData(
optimisticPolicyRecentlyUsedCategories,
optimisticPolicyRecentlyUsedTags,
isNewOneOnOneChatReport,
+ optimisticTransactionThread,
+ optimisticCreatedActionForTransactionThread,
shouldCreateNewOneOnOneIOUReport,
);
@@ -1847,6 +1921,8 @@ function createSplitsAndOnyxData(
createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID,
createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID,
reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID,
+ transactionThreadReportID: optimisticTransactionThread.reportID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
};
splits.push(individualSplit);
@@ -2417,6 +2493,9 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction);
}
+ const optimisticTransactionThread = ReportUtils.buildTransactionThread(oneOnOneIOUAction, oneOnOneIOUReport.reportID);
+ const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit);
+
const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest(
oneOnOneChatReport,
oneOnOneIOUReport,
@@ -2429,6 +2508,8 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
[],
{},
isNewOneOnOneChatReport,
+ optimisticTransactionThread,
+ optimisticCreatedActionForTransactionThread,
shouldCreateNewOneOnOneIOUReport,
);
@@ -2443,6 +2524,8 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA
createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID,
createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID,
reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID,
+ transactionThreadReportID: optimisticTransactionThread.reportID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
});
optimisticData.push(...oneOnOneOptimisticData);
@@ -3129,6 +3212,9 @@ function getSendMoneyParams(
const reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, optimisticIOUReport);
+ const optimisticTransactionThread = ReportUtils.buildTransactionThread(optimisticIOUReportAction, optimisticIOUReport.reportID);
+ const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(recipientEmail);
+
// Change the method to set for new reports because it doesn't exist yet, is faster,
// and we need the data to be available when we navigate to the chat page
const optimisticChatReportData: OnyxUpdate = isNewChat
@@ -3161,6 +3247,11 @@ function getSendMoneyParams(
lastMessageHtml: optimisticIOUReportAction.message?.[0].html,
},
};
+ const optimisticTransactionThreadData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThread.reportID}`,
+ value: optimisticTransactionThread,
+ };
const optimisticIOUReportActionsData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`,
@@ -3178,6 +3269,13 @@ function getSendMoneyParams(
[reportPreviewAction.reportActionID]: reportPreviewAction,
},
};
+ const optimisticTransactionThreadReportActionsData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`,
+ value: {
+ [optimisticCreatedActionForTransactionThread.reportActionID]: optimisticCreatedActionForTransactionThread,
+ },
+ };
const successData: OnyxUpdate[] = [
{
@@ -3203,6 +3301,15 @@ function getSendMoneyParams(
},
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`,
+ value: {
+ [optimisticCreatedActionForTransactionThread.reportActionID]: {
+ pendingAction: null,
+ },
+ },
+ },
];
const failureData: OnyxUpdate[] = [
@@ -3213,6 +3320,24 @@ function getSendMoneyParams(
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'),
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThread.reportID}`,
+ value: {
+ errorFields: {
+ createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'),
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`,
+ value: {
+ [optimisticCreatedActionForTransactionThread.reportActionID]: {
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ },
+ },
+ },
];
let optimisticPersonalDetailListData: OnyxUpdate | EmptyObject = {};
@@ -3277,7 +3402,16 @@ function getSendMoneyParams(
});
}
- const optimisticData: OnyxUpdate[] = [optimisticChatReportData, optimisticIOUReportData, optimisticChatReportActionsData, optimisticIOUReportActionsData, optimisticTransactionData];
+ const optimisticData: OnyxUpdate[] = [
+ optimisticChatReportData,
+ optimisticIOUReportData,
+ optimisticChatReportActionsData,
+ optimisticIOUReportActionsData,
+ optimisticTransactionData,
+ optimisticTransactionThreadData,
+ optimisticTransactionThreadReportActionsData,
+ ];
+
if (!isEmptyObject(optimisticPersonalDetailListData)) {
optimisticData.push(optimisticPersonalDetailListData);
}
@@ -3292,6 +3426,8 @@ function getSendMoneyParams(
newIOUReportDetails,
createdReportActionID: isNewChat ? optimisticCreatedAction.reportActionID : '0',
reportPreviewReportActionID: reportPreviewAction.reportActionID,
+ transactionThreadReportID: optimisticTransactionThread.reportID,
+ createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID,
},
optimisticData,
successData,
@@ -3877,14 +4013,6 @@ function setMoneyRequestCurrency(currency: string) {
Onyx.merge(ONYXKEYS.IOU, {currency});
}
-function setMoneyRequestCategory(category: string) {
- Onyx.merge(ONYXKEYS.IOU, {category});
-}
-
-function resetMoneyRequestCategory() {
- Onyx.merge(ONYXKEYS.IOU, {category: ''});
-}
-
function setMoneyRequestTaxRate(transactionID: string, taxRate: TaxRate) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxRate});
}
@@ -3942,7 +4070,6 @@ function navigateToNextPage(iou: OnyxEntry, iouType: string, repo
? [{reportID: chatReport?.reportID, isPolicyExpenseChat: true, selected: true}]
: (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true}));
setMoneyRequestParticipants(participants);
- resetMoneyRequestCategory();
}
Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID));
return;
@@ -4105,12 +4232,9 @@ export {
startMoneyRequest,
initMoneyRequest,
startMoneyRequest_temporaryForRefactor,
- resetMoneyRequestCategory,
- resetMoneyRequestCategory_temporaryForRefactor,
resetMoneyRequestInfo,
setMoneyRequestAmount_temporaryForRefactor,
setMoneyRequestBillable_temporaryForRefactor,
- setMoneyRequestCategory_temporaryForRefactor,
setMoneyRequestCreated,
setMoneyRequestCurrency_temporaryForRefactor,
setMoneyRequestDescription,
diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts
index ea675ff6b8f6..13e0a42e839f 100644
--- a/src/libs/actions/PaymentMethods.ts
+++ b/src/libs/actions/PaymentMethods.ts
@@ -12,6 +12,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Route} from '@src/ROUTES';
+import INPUT_IDS from '@src/types/form/AddDebitCardForm';
import type {BankAccountList, FundList} from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type PaymentMethod from '@src/types/onyx/PaymentMethod';
@@ -205,7 +206,15 @@ function clearDebitCardFormErrorAndSubmit() {
Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, {
isLoading: false,
errors: undefined,
- setupComplete: false,
+ [INPUT_IDS.SETUP_COMPLETE]: false,
+ [INPUT_IDS.NAME_ON_CARD]: '',
+ [INPUT_IDS.CARD_NUMBER]: '',
+ [INPUT_IDS.EXPIRATION_DATE]: '',
+ [INPUT_IDS.SECURITY_CODE]: '',
+ [INPUT_IDS.ADDRESS_STREET]: '',
+ [INPUT_IDS.ADDRESS_ZIP_CODE]: '',
+ [INPUT_IDS.ADDRESS_STATE]: '',
+ [INPUT_IDS.ACCEPT_TERMS]: '',
});
}
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index bc695911b910..57cd4a6fc071 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -6,6 +6,7 @@ import lodashClone from 'lodash/clone';
import lodashUnion from 'lodash/union';
import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import * as API from '@libs/API';
import type {
AddMembersToWorkspaceParams,
@@ -19,6 +20,8 @@ import type {
OpenWorkspaceMembersPageParams,
OpenWorkspaceParams,
OpenWorkspaceReimburseViewParams,
+ SetWorkspaceApprovalModeParams,
+ SetWorkspaceAutoReportingParams,
UpdateWorkspaceAvatarParams,
UpdateWorkspaceCustomUnitAndRateParams,
UpdateWorkspaceDescriptionParams,
@@ -381,6 +384,87 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]
return announceRoomMembers;
}
+function setWorkspaceAutoReporting(policyID: string, enabled: boolean) {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ autoReporting: enabled,
+ pendingFields: {isAutoApprovalEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ autoReporting: !enabled,
+ pendingFields: {isAutoApprovalEnabled: null},
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ pendingFields: {isAutoApprovalEnabled: null},
+ },
+ },
+ ];
+
+ const params: SetWorkspaceAutoReportingParams = {policyID, enabled};
+ API.write(WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING, params, {optimisticData, failureData, successData});
+}
+
+function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMode: ValueOf) {
+ const isAutoApprovalEnabled = approvalMode === CONST.POLICY.APPROVAL_MODE.BASIC;
+
+ const value = {
+ approver,
+ approvalMode,
+ isAutoApprovalEnabled,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ ...value,
+ pendingFields: {approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ pendingFields: {approvalMode: null},
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ pendingFields: {approvalMode: null},
+ },
+ },
+ ];
+
+ const params: SetWorkspaceApprovalModeParams = {policyID, value: JSON.stringify(value)};
+ API.write(WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE, params, {optimisticData, failureData, successData});
+}
+
/**
* Build optimistic data for removing users from the announcement room
*/
@@ -1635,8 +1719,8 @@ function buildOptimisticPolicyRecentlyUsedCategories(policyID?: string, category
return lodashUnion([category], policyRecentlyUsedCategories);
}
-function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, reportTags?: string): RecentlyUsedTags {
- if (!policyID || !reportTags) {
+function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, transactionTags?: string): RecentlyUsedTags {
+ if (!policyID || !transactionTags) {
return {};
}
@@ -1645,7 +1729,7 @@ function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, reportTags?: s
const policyRecentlyUsedTags = allRecentlyUsedTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`] ?? {};
const newOptimisticPolicyRecentlyUsedTags: RecentlyUsedTags = {};
- reportTags.split(CONST.COLON).forEach((tag, index) => {
+ TransactionUtils.getTagArrayFromName(transactionTags).forEach((tag, index) => {
if (!tag) {
return;
}
@@ -2134,5 +2218,7 @@ export {
buildOptimisticPolicyRecentlyUsedTags,
createDraftInitialWorkspace,
setWorkspaceInviteMessageDraft,
+ setWorkspaceAutoReporting,
+ setWorkspaceApprovalMode,
updateWorkspaceDescription,
};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 1276207e37c3..f29f8a4fbaab 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -67,6 +67,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
+import INPUT_IDS from '@src/types/form/NewRoomForm';
import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx';
import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage';
import type {NotificationPreference, RoomVisibility, WriteCapability} from '@src/types/onyx/Report';
@@ -2841,6 +2842,11 @@ function clearNewRoomFormError() {
isLoading: false,
errorFields: null,
errors: null,
+ [INPUT_IDS.ROOM_NAME]: '',
+ [INPUT_IDS.REPORT_DESCRIPTION]: '',
+ [INPUT_IDS.POLICY_ID]: '',
+ [INPUT_IDS.WRITE_CAPABILITY]: '',
+ [INPUT_IDS.VISIBILITY]: '',
});
}
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index 1d9af01f2fa0..5b178104d7c7 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -1,6 +1,7 @@
import {isEqual} from 'lodash';
import lodashClone from 'lodash/clone';
import lodashHas from 'lodash/has';
+import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
import type {GetRouteForDraftParams, GetRouteParams} from '@libs/API/parameters';
@@ -106,7 +107,7 @@ function saveWaypoint(transactionID: string, index: string, waypoint: RecentWayp
}
}
-function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: boolean) {
+function removeWaypoint(transaction: OnyxEntry, currentIndex: string, isDraft?: boolean) {
// Index comes from the route params and is a string
const index = Number(currentIndex);
const existingWaypoints = transaction?.comment?.waypoints ?? {};
@@ -134,9 +135,10 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft:
// to remove nested keys while also preserving other object keys
// Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set
let newTransaction: Transaction = {
- ...transaction,
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ ...(transaction as Transaction),
comment: {
- ...transaction.comment,
+ ...transaction?.comment,
waypoints: reIndexedWaypoints,
},
// We want to reset the amount only for draft transactions (when creating the request).
@@ -164,10 +166,10 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft:
};
}
if (isDraft) {
- Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction);
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`, newTransaction);
return;
}
- Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, newTransaction);
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, newTransaction);
}
function getOnyxDataForRouteRequest(transactionID: string, isDraft = false): OnyxData {
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index f2507a28d576..2280f6cdc0f5 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -779,6 +779,13 @@ function generateStatementPDF(period: string) {
function setContactMethodAsDefault(newDefaultContactMethod: string) {
const oldDefaultContactMethod = currentEmail;
const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ primaryLogin: newDefaultContactMethod,
+ },
+ },
{
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.SESSION,
@@ -825,6 +832,13 @@ function setContactMethodAsDefault(newDefaultContactMethod: string) {
},
];
const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ primaryLogin: oldDefaultContactMethod,
+ },
+ },
{
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.SESSION,
diff --git a/src/libs/shouldAllowDownloadQRCode/index.native.ts b/src/libs/shouldAllowDownloadQRCode/index.native.ts
new file mode 100644
index 000000000000..ea9b2b9c8aa1
--- /dev/null
+++ b/src/libs/shouldAllowDownloadQRCode/index.native.ts
@@ -0,0 +1,5 @@
+import type ShouldAllowDownloadQRCode from './types';
+
+const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = true;
+
+export default shouldAllowDownloadQRCode;
diff --git a/src/libs/shouldAllowDownloadQRCode/index.ts b/src/libs/shouldAllowDownloadQRCode/index.ts
new file mode 100644
index 000000000000..8331f7d4821f
--- /dev/null
+++ b/src/libs/shouldAllowDownloadQRCode/index.ts
@@ -0,0 +1,5 @@
+import type ShouldAllowDownloadQRCode from './types';
+
+const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = false;
+
+export default shouldAllowDownloadQRCode;
diff --git a/src/libs/shouldAllowDownloadQRCode/types.ts b/src/libs/shouldAllowDownloadQRCode/types.ts
new file mode 100644
index 000000000000..3bd6c5dc4dd7
--- /dev/null
+++ b/src/libs/shouldAllowDownloadQRCode/types.ts
@@ -0,0 +1,3 @@
+type ShouldAllowDownloadQRCode = boolean;
+
+export default ShouldAllowDownloadQRCode;
diff --git a/src/pages/DetailsPage.tsx b/src/pages/DetailsPage.tsx
index d4438d3141bf..a9adb5310e58 100755
--- a/src/pages/DetailsPage.tsx
+++ b/src/pages/DetailsPage.tsx
@@ -64,22 +64,13 @@ function DetailsPage({personalDetails, route, session}: DetailsPageProps) {
let details = Object.values(personalDetails ?? {}).find((personalDetail) => personalDetail?.login === login.toLowerCase());
if (!details) {
- if (login === CONST.EMAIL.CONCIERGE) {
- details = {
- accountID: CONST.ACCOUNT_ID.CONCIERGE,
- login,
- displayName: 'Concierge',
- avatar: UserUtils.getDefaultAvatar(CONST.ACCOUNT_ID.CONCIERGE),
- };
- } else {
- const optimisticAccountID = UserUtils.generateAccountID(login);
- details = {
- accountID: optimisticAccountID,
- login,
- displayName: login,
- avatar: UserUtils.getDefaultAvatar(optimisticAccountID),
- };
- }
+ const optimisticAccountID = UserUtils.generateAccountID(login);
+ details = {
+ accountID: optimisticAccountID,
+ login,
+ displayName: login,
+ avatar: UserUtils.getDefaultAvatar(optimisticAccountID),
+ };
}
const isSMSLogin = details.login ? Str.isSMSLogin(details.login) : false;
diff --git a/src/pages/EditRequestCategoryPage.js b/src/pages/EditRequestCategoryPage.js
deleted file mode 100644
index 205b4bf66dfa..000000000000
--- a/src/pages/EditRequestCategoryPage.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import CategoryPicker from '@components/CategoryPicker';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import ScreenWrapper from '@components/ScreenWrapper';
-import Text from '@components/Text';
-import useLocalize from '@hooks/useLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
-import Navigation from '@libs/Navigation/Navigation';
-
-const propTypes = {
- /** Transaction default category value */
- defaultCategory: PropTypes.string.isRequired,
-
- /** The policyID we are getting categories for */
- policyID: PropTypes.string.isRequired,
-
- /** Callback to fire when the Save button is pressed */
- onSubmit: PropTypes.func.isRequired,
-};
-
-function EditRequestCategoryPage({defaultCategory, policyID, onSubmit}) {
- const styles = useThemeStyles();
- const {translate} = useLocalize();
-
- const selectCategory = (category) => {
- onSubmit({
- category: category.searchText,
- });
- };
-
- return (
-
-
- {translate('iou.categorySelection')}
-
-
- );
-}
-
-EditRequestCategoryPage.propTypes = propTypes;
-EditRequestCategoryPage.displayName = 'EditRequestCategoryPage';
-
-export default EditRequestCategoryPage;
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index 29917154a527..de17d16a7c38 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -1,5 +1,4 @@
import lodashGet from 'lodash/get';
-import lodashValues from 'lodash/values';
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo} from 'react';
import {withOnyx} from 'react-native-onyx';
@@ -21,7 +20,6 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import EditRequestAmountPage from './EditRequestAmountPage';
-import EditRequestCategoryPage from './EditRequestCategoryPage';
import EditRequestDistancePage from './EditRequestDistancePage';
import EditRequestReceiptPage from './EditRequestReceiptPage';
import EditRequestTagPage from './EditRequestTagPage';
@@ -77,7 +75,7 @@ const defaultProps = {
function EditRequestPage({report, route, policy, policyCategories, policyTags, parentReportActions, transaction}) {
const parentReportActionID = lodashGet(report, 'parentReportActionID', '0');
const parentReportAction = lodashGet(parentReportActions, parentReportActionID, {});
- const {amount: transactionAmount, currency: transactionCurrency, category: transactionCategory, tag: transactionTag} = ReportUtils.getTransactionDetails(transaction);
+ const {amount: transactionAmount, currency: transactionCurrency, tag: transactionTag} = ReportUtils.getTransactionDetails(transaction);
const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency;
const fieldToEdit = lodashGet(route, ['params', 'field'], '');
@@ -90,9 +88,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p
// A flag for verifying that the current report is a sub-report of a workspace chat
const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report);
- // A flag for showing the categories page
- const shouldShowCategories = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories)));
-
// A flag for showing the tags page
const shouldShowTags = useMemo(() => isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists)), [isPolicyExpenseChat, policyTagLists, transactionTag]);
@@ -135,7 +130,7 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p
IOU.updateMoneyRequestTag(
transaction.transactionID,
report.reportID,
- IOUUtils.insertTagIntoReportTagsString(transactionTag, updatedTag, tagIndex),
+ IOUUtils.insertTagIntoTransactionTagsString(transactionTag, updatedTag, tagIndex),
policy,
policyTags,
policyCategories,
@@ -145,16 +140,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p
[tag, transaction.transactionID, report.reportID, transactionTag, tagIndex, policy, policyTags, policyCategories],
);
- const saveCategory = useCallback(
- ({category: newCategory}) => {
- // In case the same category has been selected, reset the category.
- const updatedCategory = newCategory === transactionCategory ? '' : newCategory;
- IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, policy, policyTags, policyCategories);
- Navigation.dismissModal();
- },
- [transactionCategory, transaction.transactionID, report.reportID, policy, policyTags, policyCategories],
- );
-
if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) {
return (
- );
- }
-
if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG && shouldShowTags) {
return (
{
- setDraftSplitTransaction({category: transactionChanges.category.trim()});
- }}
- />
- );
- }
-
if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG) {
return (
Navigation.dismissModal()}
+ onBackButtonPress={navigation.goBack}
/>
(
+ tabBar={({state, navigation: tabNavigation, position}) => (
)}
diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.tsx b/src/pages/PrivateNotes/PrivateNotesListPage.tsx
index 97ebc7dee2fb..0a6a2659ffb6 100644
--- a/src/pages/PrivateNotes/PrivateNotesListPage.tsx
+++ b/src/pages/PrivateNotes/PrivateNotesListPage.tsx
@@ -88,7 +88,12 @@ function PrivateNotesListPage({report, personalDetailsList, session}: PrivateNot
onCloseButtonPress={() => Navigation.dismissModal()}
/>
{translate('privateNotes.personalNoteMessage')}
- {privateNotes.map((item) => getMenuItem(item))}
+
+ {privateNotes.map((item) => getMenuItem(item))}
+
);
}
diff --git a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
index bb352acd4732..ed00fbcff422 100644
--- a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
+++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
@@ -136,7 +136,6 @@ function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkTok
testID={BankInfo.displayName}
includeSafeAreaPaddingBottom={false}
shouldEnablePickerAvoiding={false}
- shouldEnableMaxHeight
>
)}
{!requiresTwoFactorAuth && (
diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx
index 9eae888bdd74..b4272f094071 100644
--- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx
+++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx
@@ -8,6 +8,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import type {SubStepProps} from '@hooks/useSubStep/types';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
@@ -34,6 +35,7 @@ const PERSONAL_INFO_STEP_INDEXES = CONST.REIMBURSEMENT_ACCOUNT_SUBSTEP_INDEX.PER
function Confirmation({reimbursementAccount, reimbursementAccountDraft, onNext, onMove}: ConfirmationProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const {isOffline} = useNetwork();
const isLoading = reimbursementAccount?.isLoading ?? false;
const values = useMemo(() => getSubstepValues(PERSONAL_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]);
@@ -112,6 +114,7 @@ function Confirmation({reimbursementAccount, reimbursementAccountDraft, onNext,
/>
)}
{isPolicyAdmin && (
@@ -338,7 +302,7 @@ function WorkspaceNewRoomPage(props) {
label={translate('writeCapabilityPage.label')}
items={writeCapabilityOptions}
value={writeCapability}
- onValueChange={setWriteCapability}
+ onValueChange={(value) => setWriteCapability(value as typeof writeCapability)}
/>
)}
@@ -348,7 +312,7 @@ function WorkspaceNewRoomPage(props) {
inputID={INPUT_IDS.VISIBILITY}
label={translate('newRoomPage.visibility')}
items={visibilityOptions}
- onValueChange={setVisibility}
+ onValueChange={(value) => setVisibility(value as typeof visibility)}
value={visibility}
furtherDetails={visibilityDescription}
shouldShowTooltips={false}
@@ -363,32 +327,24 @@ function WorkspaceNewRoomPage(props) {
);
}
-WorkspaceNewRoomPage.propTypes = propTypes;
-WorkspaceNewRoomPage.defaultProps = defaultProps;
WorkspaceNewRoomPage.displayName = 'WorkspaceNewRoomPage';
-export default compose(
- withNavigationFocus,
- withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- },
- formState: {
- key: ONYXKEYS.FORMS.NEW_ROOM_FORM,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- activePolicyID: {
- key: ONYXKEYS.ACCOUNT,
- selector: (account) => (account && account.activePolicyID) || null,
- initialValue: null,
- },
- }),
-)(WorkspaceNewRoomPage);
+export default withOnyx({
+ policies: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ },
+ reports: {
+ key: ONYXKEYS.COLLECTION.REPORT,
+ },
+ formState: {
+ key: ONYXKEYS.FORMS.NEW_ROOM_FORM,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ activePolicyID: {
+ key: ONYXKEYS.ACCOUNT,
+ selector: (account) => account?.activePolicyID ?? null,
+ initialValue: null,
+ },
+})(WorkspaceNewRoomPage);
diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx
index 4a77adac7b37..b9b14e27d01d 100644
--- a/src/pages/workspace/WorkspacePageWithSections.tsx
+++ b/src/pages/workspace/WorkspacePageWithSections.tsx
@@ -42,7 +42,7 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps &
headerText: string;
/** Main content of the page */
- children: (hasVBA: boolean, policyID: string, isUsingECard: boolean) => ReactNode;
+ children: ((hasVBA: boolean, policyID: string, isUsingECard: boolean) => ReactNode) | ReactNode;
/** Content to be added as fixed footer */
footer?: ReactNode;
@@ -68,6 +68,9 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps &
/** Whether to show this page to non admin policy members */
shouldShowNonAdmin?: boolean;
+ /** Whether to show the not found page */
+ shouldShowNotFoundPage?: boolean;
+
/** Policy values needed in the component */
policy: OnyxEntry;
@@ -91,6 +94,7 @@ function WorkspacePageWithSections({
backButtonRoute,
children = () => null,
footer = null,
+ icon = undefined,
guidesCallTaskID = '',
headerText,
policy,
@@ -104,7 +108,7 @@ function WorkspacePageWithSections({
shouldShowLoading = true,
shouldShowOfflineIndicatorInWideScreen = false,
shouldShowNonAdmin = false,
- icon,
+ shouldShowNotFoundPage = false,
}: WorkspacePageWithSectionsProps) {
const styles = useThemeStyles();
const policyID = route.params?.policyID ?? '';
@@ -114,17 +118,10 @@ function WorkspacePageWithSections({
const achState = reimbursementAccount?.achData?.state ?? '';
const isUsingECard = user?.isUsingExpensifyCard ?? false;
const hasVBA = achState === BankAccount.STATE.OPEN;
- const content = children(hasVBA, policyID, isUsingECard);
+ const content = typeof children === 'function' ? children(hasVBA, policyID, isUsingECard) : children;
const {isSmallScreenWidth} = useWindowDimensions();
const firstRender = useRef(true);
- const goBack = () => {
- Navigation.goBack(ROUTES.SETTINGS_WORKSPACES);
-
- // Needed when workspace with given policyID does not exist
- Navigation.navigateWithSwitchPolicyID({route: ROUTES.ALL_SETTINGS});
- };
-
useEffect(() => {
// Because isLoading is false before merging in Onyx, we need firstRender ref to display loading page as well before isLoading is change to true
firstRender.current = false;
@@ -136,7 +133,7 @@ function WorkspacePageWithSections({
const shouldShow = useMemo(() => {
// If the policy object doesn't exist or contains only error data, we shouldn't display it.
- if ((isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors))) && isEmptyObject(policyDraft)) {
+ if (((isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors))) && isEmptyObject(policyDraft)) || shouldShowNotFoundPage) {
return true;
}
@@ -153,8 +150,8 @@ function WorkspacePageWithSections({
shouldShowOfflineIndicatorInWideScreen={shouldShowOfflineIndicatorInWideScreen && !shouldShow}
>
Navigation.goBack(backButtonRoute ?? ROUTES.WORKSPACE_INITIAL.getRoute(policyID))}
- icon={icon}
+ icon={icon ?? undefined}
/>
{(isLoading || firstRender.current) && shouldShowLoading ? (
diff --git a/src/pages/workspace/WorkspaceProfileCurrencyPage.js b/src/pages/workspace/WorkspaceProfileCurrencyPage.tsx
similarity index 50%
rename from src/pages/workspace/WorkspaceProfileCurrencyPage.js
rename to src/pages/workspace/WorkspaceProfileCurrencyPage.tsx
index bd13ce4687f5..cc73f4a64a80 100644
--- a/src/pages/workspace/WorkspaceProfileCurrencyPage.js
+++ b/src/pages/workspace/WorkspaceProfileCurrencyPage.tsx
@@ -1,65 +1,61 @@
-import PropTypes from 'prop-types';
import React, {useState} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
-import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as Policy from '@userActions/Policy';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import {policyDefaultProps, policyPropTypes} from './withPolicy';
+import type {CurrencyList} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading';
import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
-const propTypes = {
+type WorkspaceProfileCurrentPageOnyxProps = {
/** Constant, list of available currencies */
- currencyList: PropTypes.objectOf(
- PropTypes.shape({
- /** Symbol of the currency */
- symbol: PropTypes.string.isRequired,
- }),
- ),
- isLoadingReportData: PropTypes.bool,
- ...policyPropTypes,
+ currencyList: OnyxEntry;
};
-const defaultProps = {
- currencyList: {},
- isLoadingReportData: true,
- ...policyDefaultProps,
+type WorkspaceProfileCurrentPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceProfileCurrentPageOnyxProps;
+
+type WorkspaceProfileCurrencyPageSectionItem = {
+ text: string;
+ keyForList: string;
+ isSelected: boolean;
};
-const getDisplayText = (currencyCode, currencySymbol) => `${currencyCode} - ${currencySymbol}`;
+const getDisplayText = (currencyCode: string, currencySymbol: string) => `${currencyCode} - ${currencySymbol}`;
-function WorkspaceSettingsCurrencyPage({currencyList, policy, isLoadingReportData}) {
+function WorkspaceProfileCurrencyPage({currencyList = {}, policy, isLoadingReportData = true}: WorkspaceProfileCurrentPageProps) {
const {translate} = useLocalize();
const [searchText, setSearchText] = useState('');
const trimmedText = searchText.trim().toLowerCase();
- const currencyListKeys = _.keys(currencyList);
+ const currencyListKeys = Object.keys(currencyList ?? {});
- const filteredItems = _.filter(currencyListKeys, (currencyCode) => {
- const currency = currencyList[currencyCode];
- return getDisplayText(currencyCode, currency.symbol).toLowerCase().includes(trimmedText);
+ const filteredItems = currencyListKeys.filter((currencyCode: string) => {
+ const currency = currencyList?.[currencyCode];
+ return getDisplayText(currencyCode, currency?.symbol ?? '')
+ .toLowerCase()
+ .includes(trimmedText);
});
let initiallyFocusedOptionKey;
- const currencyItems = _.map(filteredItems, (currencyCode) => {
- const currency = currencyList[currencyCode];
- const isSelected = policy.outputCurrency === currencyCode;
+ const currencyItems: WorkspaceProfileCurrencyPageSectionItem[] = filteredItems.map((currencyCode: string) => {
+ const currency = currencyList?.[currencyCode];
+ const isSelected = policy?.outputCurrency === currencyCode;
if (isSelected) {
initiallyFocusedOptionKey = currencyCode;
}
return {
- text: getDisplayText(currencyCode, currency.symbol),
+ text: getDisplayText(currencyCode, currency?.symbol ?? ''),
keyForList: currencyCode,
isSelected,
};
@@ -69,20 +65,21 @@ function WorkspaceSettingsCurrencyPage({currencyList, policy, isLoadingReportDat
const headerMessage = searchText.trim() && !currencyItems.length ? translate('common.noResultsFound') : '';
- const onSelectCurrency = (item) => {
- Policy.updateGeneralSettings(policy.id, policy.name, item.keyForList);
+ const onSelectCurrency = (item: WorkspaceProfileCurrencyPageSectionItem) => {
+ Policy.updateGeneralSettings(policy?.id ?? '', policy?.name ?? '', item.keyForList);
Navigation.goBack();
};
return (
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- shouldShow={(_.isEmpty(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy)}
- subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'}
+ onBackButtonPress={PolicyUtils.goBackFromInvalidPolicy}
+ onLinkPress={PolicyUtils.goBackFromInvalidPolicy}
+ shouldShow={(isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy)}
+ subtitleKey={isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized'}
>
({
currencyList: {key: ONYXKEYS.CURRENCY_LIST},
- }),
-)(WorkspaceSettingsCurrencyPage);
+ })(WorkspaceProfileCurrencyPage),
+);
diff --git a/src/pages/workspace/WorkspaceProfilePage.js b/src/pages/workspace/WorkspaceProfilePage.tsx
similarity index 68%
rename from src/pages/workspace/WorkspaceProfilePage.js
rename to src/pages/workspace/WorkspaceProfilePage.tsx
index c91f7ed8fb44..48dfe10a2a0e 100644
--- a/src/pages/workspace/WorkspaceProfilePage.js
+++ b/src/pages/workspace/WorkspaceProfilePage.tsx
@@ -1,12 +1,12 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
+import type {ImageStyle, StyleProp} from 'react-native';
import {Image, ScrollView, StyleSheet, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import WorkspaceProfile from '@assets/images/workspace-profile.png';
import Avatar from '@components/Avatar';
import AvatarWithImagePicker from '@components/AvatarWithImagePicker';
+import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
@@ -16,59 +16,46 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import StringUtils from '@libs/StringUtils';
import * as UserUtils from '@libs/UserUtils';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import withPolicy, {policyDefaultProps, policyPropTypes} from './withPolicy';
+import type {CurrencyList} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import withPolicy from './withPolicy';
+import type {WithPolicyProps} from './withPolicy';
import WorkspacePageWithSections from './WorkspacePageWithSections';
-const propTypes = {
+type WorkSpaceProfilePageOnyxProps = {
/** Constant, list of available currencies */
- currencyList: PropTypes.objectOf(
- PropTypes.shape({
- /** Symbol of the currency */
- symbol: PropTypes.string.isRequired,
- }),
- ),
-
- /** The route object passed to this page from the navigator */
- route: PropTypes.shape({
- /** Each parameter passed via the URL */
- params: PropTypes.shape({
- /** The policyID that is being configured */
- policyID: PropTypes.string.isRequired,
- }).isRequired,
- }).isRequired,
-
- ...policyPropTypes,
+ currencyList: OnyxEntry;
};
-const defaultProps = {
- currencyList: {},
- ...policyDefaultProps,
-};
+type WorkSpaceProfilePageProps = WithPolicyProps & WorkSpaceProfilePageOnyxProps;
-function WorkspaceProfilePage({policy, currencyList, route}) {
+function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfilePageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
- const formattedCurrency = !_.isEmpty(policy) && !_.isEmpty(currencyList) && !!policy.outputCurrency ? `${policy.outputCurrency} - ${currencyList[policy.outputCurrency].symbol}` : '';
+ const outputCurrency = policy?.outputCurrency ?? '';
+ const currencySymbol = currencyList?.[outputCurrency]?.symbol ?? '';
+ const formattedCurrency = !isEmptyObject(policy) && !isEmptyObject(currencyList) ? `${outputCurrency} - ${currencySymbol}` : '';
- const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_CURRENCY.getRoute(policy.id)), [policy.id]);
- const onPressName = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_NAME.getRoute(policy.id)), [policy.id]);
- const onPressDescription = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy.id)), [policy.id]);
+ const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_CURRENCY.getRoute(policy?.id ?? '')), [policy?.id]);
+ const onPressName = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_NAME.getRoute(policy?.id ?? '')), [policy?.id]);
+ const onPressDescription = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy?.id ?? '')), [policy?.id]);
+ const onPressShare = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_SHARE.getRoute(policy?.id ?? '')), [policy?.id]);
- const policyName = lodashGet(policy, 'name', '');
- const policyDescription = lodashGet(policy, 'description', '');
+ const policyName = policy?.name ?? '';
+ const policyDescription = policy?.description ?? '';
const readOnly = !PolicyUtils.isPolicyAdmin(policy);
- const imageStyle = isSmallScreenWidth ? [styles.mhv12, styles.mhn5] : [styles.mhv8, styles.mhn8];
+ const imageStyle: StyleProp = isSmallScreenWidth ? [styles.mhv12, styles.mhn5] : [styles.mhv8, styles.mhn8];
return (
- {(hasVBA) => (
+ {(hasVBA?: boolean) => (
-
+
Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy.id))}
- source={lodashGet(policy, 'avatar')}
+ onViewPhotoPress={() => Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy?.id ?? ''))}
+ source={policy?.avatar ?? ''}
size={CONST.AVATAR_SIZE.XLARGE}
avatarStyle={styles.avatarXLarge}
enablePreview
@@ -100,7 +90,7 @@ function WorkspaceProfilePage({policy, currencyList, route}) {
Policy.updateWorkspaceAvatar(lodashGet(policy, 'id', ''), file)}
- onImageRemoved={() => Policy.deleteWorkspaceAvatar(lodashGet(policy, 'id', ''))}
+ isUsingDefaultAvatar={!policy?.avatar ?? null}
+ onImageSelected={(file) => Policy.updateWorkspaceAvatar(policy?.id ?? '', file as File)}
+ onImageRemoved={() => Policy.deleteWorkspaceAvatar(policy?.id ?? '')}
editorMaskImage={Expensicons.ImageCropSquareMask}
- pendingAction={lodashGet(policy, 'pendingFields.avatar', null)}
- errors={lodashGet(policy, 'errorFields.avatar', null)}
- onErrorClose={() => Policy.clearAvatarErrors(policy.id)}
- previewSource={UserUtils.getFullSizeAvatar(policy.avatar, '')}
+ pendingAction={policy?.pendingFields?.avatar}
+ errors={policy?.errorFields?.avatar}
+ onErrorClose={() => Policy.clearAvatarErrors(policy?.id ?? '')}
+ previewSource={UserUtils.getFullSizeAvatar(policy?.avatar ?? '')}
headerTitle={translate('workspace.common.workspaceAvatar')}
- originalFileName={policy.originalFileName}
+ originalFileName={policy?.originalFileName}
disabled={readOnly}
disabledStyle={styles.cursorDefault}
+ errorRowStyles={undefined}
/>
-
+
- {(!_.isEmpty(policy.description) || !readOnly) && (
-
+ {(!StringUtils.isEmptyString(policy?.description ?? '') || !readOnly) && (
+
)}
-
+
+ {!readOnly && (
+
+
+
+ )}
@@ -176,13 +178,10 @@ function WorkspaceProfilePage({policy, currencyList, route}) {
);
}
-WorkspaceProfilePage.propTypes = propTypes;
-WorkspaceProfilePage.defaultProps = defaultProps;
WorkspaceProfilePage.displayName = 'WorkspaceProfilePage';
-export default compose(
- withPolicy,
- withOnyx({
+export default withPolicy(
+ withOnyx({
currencyList: {key: ONYXKEYS.CURRENCY_LIST},
- }),
-)(WorkspaceProfilePage);
+ })(WorkspaceProfilePage),
+);
diff --git a/src/pages/workspace/WorkspaceProfileSharePage.tsx b/src/pages/workspace/WorkspaceProfileSharePage.tsx
new file mode 100644
index 000000000000..dd03436042ca
--- /dev/null
+++ b/src/pages/workspace/WorkspaceProfileSharePage.tsx
@@ -0,0 +1,88 @@
+import React, {useRef} from 'react';
+import {ScrollView, View} from 'react-native';
+import type {ImageSourcePropType} from 'react-native';
+import expensifyLogo from '@assets/images/expensify-logo-round-transparent.png';
+import ContextMenuItem from '@components/ContextMenuItem';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItem from '@components/MenuItem';
+import QRShareWithDownload from '@components/QRShare/QRShareWithDownload';
+import type QRShareWithDownloadHandle from '@components/QRShare/QRShareWithDownload/types';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useEnvironment from '@hooks/useEnvironment';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import Clipboard from '@libs/Clipboard';
+import Navigation from '@libs/Navigation/Navigation';
+import shouldAllowDownloadQRCode from '@libs/shouldAllowDownloadQRCode';
+import * as Url from '@libs/Url';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import withPolicy from './withPolicy';
+import type {WithPolicyProps} from './withPolicy';
+
+function WorkspaceProfileSharePage({policy}: WithPolicyProps) {
+ const themeStyles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {environmentURL} = useEnvironment();
+ const qrCodeRef = useRef(null);
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ const policyName = policy?.name ?? '';
+ const id = policy?.id ?? '';
+ const urlWithTrailingSlash = Url.addTrailingForwardSlash(environmentURL);
+
+ const url = `${urlWithTrailingSlash}${ROUTES.WORKSPACE_PROFILE.getRoute(id)}`;
+ return (
+
+
+
+
+
+
+
+
+
+ Clipboard.setString(url)}
+ shouldLimitWidth={false}
+ wrapperStyle={themeStyles.sectionMenuItemTopDescription}
+ />
+ {shouldAllowDownloadQRCode && (
+
+
+
+
+ );
+}
+
+WorkspaceProfileSharePage.displayName = 'WorkspaceProfileSharePage';
+
+export default withPolicy(WorkspaceProfileSharePage);
diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx
index 88ea1bf1ec54..d1edf7f2f783 100755
--- a/src/pages/workspace/WorkspacesListPage.tsx
+++ b/src/pages/workspace/WorkspacesListPage.tsx
@@ -23,6 +23,7 @@ import useNetwork from '@hooks/useNetwork';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import localeCompare from '@libs/LocaleCompare';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
@@ -308,7 +309,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r
type: policy.type,
}),
)
- .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
+ .sort((a, b) => localeCompare(a.title, b.title));
}, [reimbursementAccount?.errors, policies, isOffline, theme.textLight, allPolicyMembers, policyRooms]);
if (isEmptyObject(workspaces)) {
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
new file mode 100644
index 000000000000..b8a65c28806b
--- /dev/null
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -0,0 +1,136 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useMemo, useState} from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import * as Illustrations from '@components/Icon/Illustrations';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import TableListItem from '@components/SelectionList/TableListItem';
+import Text from '@components/Text';
+import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import type {CentralPaneNavigatorParamList} from '@navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import type * as OnyxTypes from '@src/types/onyx';
+
+type PolicyForList = {
+ value: string;
+ text: string;
+ keyForList: string;
+ isSelected: boolean;
+ rightElement: React.ReactNode;
+};
+
+type WorkspaceCategoriesOnyxProps = {
+ /** Collection of categories attached to a policy */
+ policyCategories: OnyxEntry;
+};
+
+type WorkspaceCategoriesPageProps = WorkspaceCategoriesOnyxProps & StackScreenProps;
+
+function WorkspaceCategoriesPage({policyCategories, route}: WorkspaceCategoriesPageProps) {
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const styles = useThemeStyles();
+ const theme = useTheme();
+ const {translate} = useLocalize();
+ const [selectedCategories, setSelectedCategories] = useState>({});
+
+ const categoryList = useMemo(
+ () =>
+ Object.values(policyCategories ?? {}).map((value) => ({
+ value: value.name,
+ text: value.name,
+ keyForList: value.name,
+ isSelected: !!selectedCategories[value.name],
+ rightElement: (
+
+ {value.enabled ? translate('workspace.common.enabled') : translate('workspace.common.disabled')}
+
+
+
+
+ ),
+ })),
+ [policyCategories, selectedCategories, styles.alignSelfCenter, styles.disabledText, styles.flexRow, styles.p1, styles.pl2, theme.icon, translate],
+ );
+
+ const toggleCategory = (category: PolicyForList) => {
+ setSelectedCategories((prev) => ({
+ ...prev,
+ [category.value]: !prev[category.value],
+ }));
+ };
+
+ const toggleAllCategories = () => {
+ const isAllSelected = categoryList.every((category) => !!selectedCategories[category.value]);
+ setSelectedCategories(isAllSelected ? {} : Object.fromEntries(categoryList.map((item) => [item.value, true])));
+ };
+
+ const getCustomListHeader = () => (
+
+ {translate('common.name')}
+ {translate('statusPage.status')}
+
+ );
+
+ return (
+
+
+
+
+
+ {translate('workspace.categories.subtitle')}
+
+ {categoryList.length ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+WorkspaceCategoriesPage.displayName = 'WorkspaceCategoriesPage';
+
+export default withOnyx({
+ policyCategories: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params.policyID}`,
+ },
+})(WorkspaceCategoriesPage);
diff --git a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx
new file mode 100644
index 000000000000..62f32992601a
--- /dev/null
+++ b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx
@@ -0,0 +1,85 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import type {SvgProps} from 'react-native-svg';
+import Icon from '@components/Icon';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import Switch from '@components/Switch';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
+
+type ToggleSettingOptionRowProps = {
+ /** Icon to be shown for the option */
+ icon: React.FC;
+ /** Title of the option */
+ title: string;
+ /** Subtitle of the option */
+ subtitle: string;
+ /** Whether the option is enabled or not */
+ isActive: boolean;
+ /** Callback to be called when the switch is toggled */
+ onToggle: (isEnabled: boolean) => void;
+ /** SubMenuItems will be shown when the option is enabled */
+ subMenuItems?: React.ReactNode;
+ /** If there is a pending action, we will grey out the option */
+ pendingAction?: PendingAction;
+};
+const ICON_SIZE = 48;
+
+function ToggleSettingOptionRow({icon, title, subtitle, onToggle, subMenuItems, isActive, pendingAction}: ToggleSettingOptionRowProps) {
+ const [isEnabled, setIsEnabled] = useState(isActive);
+ const styles = useThemeStyles();
+ const toggleSwitch = () => {
+ setIsEnabled(!isEnabled);
+ onToggle(!isEnabled);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+
+ {isEnabled && subMenuItems}
+
+
+ );
+}
+
+export type {ToggleSettingOptionRowProps};
+export default ToggleSettingOptionRow;
diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
new file mode 100644
index 000000000000..fc1ed1d19560
--- /dev/null
+++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
@@ -0,0 +1,162 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useMemo} from 'react';
+import {FlatList, View} from 'react-native';
+import * as Illustrations from '@components/Icon/Illustrations';
+import MenuItem from '@components/MenuItem';
+import Section from '@components/Section';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import type {CentralPaneNavigatorParamList} from '@navigation/types';
+import withPolicy from '@pages/workspace/withPolicy';
+import type {WithPolicyProps} from '@pages/workspace/withPolicy';
+import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections';
+import * as Policy from '@userActions/Policy';
+import CONST from '@src/CONST';
+import type SCREENS from '@src/SCREENS';
+import ToggleSettingOptionRow from './ToggleSettingsOptionRow';
+import type {ToggleSettingOptionRowProps} from './ToggleSettingsOptionRow';
+
+type WorkspaceWorkflowsPageProps = WithPolicyProps & StackScreenProps;
+
+function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const {isOffline} = useNetwork();
+
+ const ownerPersonalDetails = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([policy?.ownerAccountID ?? 0], CONST.EMPTY_OBJECT), false);
+ const policyOwnerDisplayName = ownerPersonalDetails[0]?.displayName;
+ const containerStyle = useMemo(() => [styles.ph8, styles.mhn8, styles.ml11, styles.pv3, styles.pr0, styles.pl4, styles.mr0, styles.widthAuto, styles.mt4], [styles]);
+
+ const items: ToggleSettingOptionRowProps[] = useMemo(
+ () => [
+ {
+ icon: Illustrations.ReceiptEnvelope,
+ title: translate('workflowsPage.delaySubmissionTitle'),
+ subtitle: translate('workflowsPage.delaySubmissionDescription'),
+ onToggle: (isEnabled: boolean) => {
+ Policy.setWorkspaceAutoReporting(route.params.policyID, isEnabled);
+ },
+ subMenuItems: (
+