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 > )}