diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index 7e1b5fbbae90..0b32d8ee6dc1 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -1,6 +1,11 @@ name: Set up Node description: Set up Node +outputs: + cache-hit: + description: Was there a cache hit on the main node_modules? + value: ${{ steps.cache-node-modules.outputs.cache-hit }} + runs: using: composite steps: diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml index d2e0ec4f38e5..9eb5bc6eb409 100644 --- a/.github/workflows/failureNotifier.yml +++ b/.github/workflows/failureNotifier.yml @@ -88,7 +88,7 @@ jobs: repo: context.repo.repo, title: issueTitle, body: issueBody, - labels: [failureLabel, 'Daily'], + labels: [failureLabel, 'Hourly'], assignees: [prMerger] }); } diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 7c7b51240fdb..04de0f5b5deb 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -184,6 +184,7 @@ jobs: run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - name: Setup Node + id: setup-node uses: ./.github/actions/composite/setupNode - name: Setup Ruby @@ -206,7 +207,7 @@ jobs: - name: Install cocoapods uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 - if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' + if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' with: timeout_minutes: 10 max_attempts: 5 diff --git a/android/app/build.gradle b/android/app/build.gradle index 157eaa16a24c..99d7a186e7ee 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001044306 - versionName "1.4.43-6" + versionCode 1001044318 + versionName "1.4.43-18" } flavorDimensions "default" diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java index 3d16e607be49..8eff32dedf76 100644 --- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java @@ -15,6 +15,8 @@ import android.graphics.PorterDuff.Mode; import android.graphics.PorterDuffXfermode; import android.graphics.Rect; +import android.media.AudioAttributes; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.service.notification.StatusBarNotification; @@ -31,6 +33,7 @@ import androidx.core.graphics.drawable.IconCompat; import androidx.versionedparcelable.ParcelUtils; +import com.expensify.chat.R; import com.urbanairship.AirshipConfigOptions; import com.urbanairship.json.JsonMap; import com.urbanairship.json.JsonValue; @@ -105,6 +108,9 @@ protected NotificationCompat.Builder onExtendBuilder(@NonNull Context context, @ builder.setChannelId(CHANNEL_MESSAGES_ID); } else { builder.setPriority(PRIORITY_MAX); + // Set sound for versions below Oreo + // for Oreo and above we set sound on the notification's channel level + builder.setSound(getSoundFile(context)); } // Attempt to parse data and apply custom notification styling @@ -130,6 +136,13 @@ private void createAndRegisterNotificationChannel(@NonNull Context context) { NotificationChannelGroup channelGroup = new NotificationChannelGroup(NOTIFICATION_GROUP_CHATS, CHANNEL_GROUP_NAME); NotificationChannel channel = new NotificationChannel(CHANNEL_MESSAGES_ID, CHANNEL_MESSAGES_NAME, NotificationManager.IMPORTANCE_HIGH); + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build(); + + channel.setSound(getSoundFile(context), audioAttributes); + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); notificationManager.createNotificationChannelGroup(channelGroup); notificationManager.createNotificationChannel(channel); @@ -333,4 +346,8 @@ private Bitmap fetchIcon(@NonNull Context context, String urlString) { return null; } + + private Uri getSoundFile(Context context) { + return Uri.parse("android.resource://" + context.getPackageName() + "/" + R.raw.receive); + } } diff --git a/assets/images/folder.svg b/assets/images/folder.svg new file mode 100644 index 000000000000..17cef959132f --- /dev/null +++ b/assets/images/folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/product-illustrations/emptystate__expenses.svg b/assets/images/product-illustrations/emptystate__expenses.svg new file mode 100644 index 000000000000..c01a89109cbf --- /dev/null +++ b/assets/images/product-illustrations/emptystate__expenses.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/product-illustrations/mushroom-top-hat.svg b/assets/images/product-illustrations/mushroom-top-hat.svg new file mode 100644 index 000000000000..cb808f7289e0 --- /dev/null +++ b/assets/images/product-illustrations/mushroom-top-hat.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__approval.svg b/assets/images/simple-illustrations/simple-illustration__approval.svg new file mode 100644 index 000000000000..bdef2436958b --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__approval.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__folder-open.svg b/assets/images/simple-illustrations/simple-illustration__folder-open.svg new file mode 100644 index 000000000000..c104313a9b6c --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__folder-open.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg b/assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg new file mode 100644 index 000000000000..fc7082e9932c --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg @@ -0,0 +1 @@ + diff --git a/assets/images/simple-illustrations/simple-illustration__wallet-alt.svg b/assets/images/simple-illustrations/simple-illustration__wallet-alt.svg new file mode 100644 index 000000000000..33d1fc0fa044 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__wallet-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__workflows.svg b/assets/images/simple-illustrations/simple-illustration__workflows.svg new file mode 100644 index 000000000000..47d30d54310f --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__workflows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/workflows.svg b/assets/images/workflows.svg new file mode 100644 index 000000000000..24156c66eb69 --- /dev/null +++ b/assets/images/workflows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md index 09dd4de2867b..3fe5ec41f5f6 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md @@ -3,7 +3,7 @@ title: Billing Overview description: An overview of how billing works in Expensify. --- # Overview -Expensify’s billing is based on monthly member activity. At the beginning of each month, you’ll be billed for the previous month’s activity. Your Expensify bill ultimately depends on your plan type, whether you're on an annual subscription or pay-per-use billing, and whether you’re using Expensify Cards. +Expensify’s billing is based on monthly member activity. At the beginning of each month, you’ll be billed for the previous month’s activity. Your Expensify bill ultimately depends on your plan type, whether you're on an annual subscription or pay-per-use billing, and whether you’re using the Expensify Visa® Commercial Card. # How billing works in Expensify Expensify bills the owners of Group Workspaces on the first of each month for the previous month's usage. You can find billing receipts in **Settings > Account > Payments > Billing History**. We recommend that businesses have one billing owner for all of their Group Workspaces. ## Active members @@ -23,7 +23,7 @@ Bundling the Expensify Card with an annual subscription ensures you pay the lowe If at least 50% of your approved USD spend in a given month is on your company’s Expensify Cards, you will receive an additional 50% discount on the price per member. This additional 50% discount, when coupled with an annual subscription, brings the price per member to $5 on a Collect plan and $9 on a Control plan. -Additionally, every month, you receive 1% cash back on all Expensify Card purchases, and 2% if the spend across your Expensify Cards is $250k or more. Any cash back from the Expensify Card is first applied to your Expensify bill, further reducing your price per member. Any leftover cash back is deposited directly into your connected bank account. +Additionally, every month, you receive 1% cash back on all Expensify Card purchases, and 2% if the spend across your Expensify Cards is $250k or more (_applies to USD purchases only_). Any cash back from the Expensify Card is first applied to your Expensify bill, further reducing your price per member. Any leftover cash back is deposited directly into your connected bank account. ## Savings calculator To see how much money you can save (and even earn!) by using the Expensify Card, check out our [savings calculator](https://use.expensify.com/price-savings-calculator). Just enter a few details and see how much you’ll save! {% include faq-begin.md %} diff --git a/docs/articles/expensify-classic/getting-started/Invite-Members.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members.md similarity index 100% rename from docs/articles/expensify-classic/getting-started/Invite-Members.md rename to docs/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members.md diff --git a/docs/redirects.csv b/docs/redirects.csv index 76b7bac3fc99..4ed309467f13 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -25,16 +25,16 @@ https://community.expensify.com/discussion/5194/how-to-assign-a-vacation-delegat https://community.expensify.com/discussion/5190/how-to-individually-assign-a-vacation-delegate-from-account-settings,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate https://community.expensify.com/discussion/5274/how-to-set-up-an-adp-indirect-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/integrations/HR-integrations/ADP https://community.expensify.com/discussion/5776/how-to-create-mileage-expenses-in-expensify,https://help.expensify.com/articles/expensify-classic/get-paid-back/Distance-Tracking -https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details -https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details -https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details -https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details -https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details +https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks.html,https://use.expensify.com/company-credit-card https://help.expensify.com/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.html,https://use.expensify.com/accountants-program https://help.expensify.com/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners, https://use.expensify.com/blog/maximizing-rewards-expensifyapproved-accounting-partners-now-earn-0-5-revenue-share https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements -https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts +https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts https://community.expensify.com/discussion/4783/how-to-add-or-remove-a-copilot,https://help.expensify.com/articles/expensify-classic/account-settings/Copilot https://community.expensify.com/discussion/4343/expensify-anz-partnership-announcement,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-ANZ https://community.expensify.com/discussion/7318/deep-dive-company-credit-card-import-options,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards @@ -54,3 +54,9 @@ https://help.expensify.com/articles/expensify-classic/getting-started/Employees, https://help.expensify.com/articles/expensify-classic/getting-started/Using-The-App,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace https://help.expensify.com/articles/expensify-classic/getting-started/support/Expensify-Support,https://use.expensify.com/support https://help.expensify.com/articles/expensify-classic/getting-started/Plan-Types,https://use.expensify.com/ +https://help.expensify.com/articles/new-expensify/payments/Referral-Program,https://help.expensify.com/articles/expensify-classic/get-paid-back/Referral-Program +https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://help.expensify.com/articles/expensify-classic/account-settings/Preferences,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts +https://help.expensify.com/articles/expensify-classic/getting-started/Individual-Users,https://help.expensify.com/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself +https://help.expensify.com/articles/expensify-classic/getting-started/Invite-Members,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 1d97482676c8..574657c8c3f4 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.43.6 + 1.4.43.18 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 28fdd7e1f174..e4962c94df8d 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.43.6 + 1.4.43.18 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 796fa42f5306..308c4314ee68 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.43 CFBundleVersion - 1.4.43.6 + 1.4.43.18 NSExtension NSExtensionPointIdentifier diff --git a/ios/NotificationServiceExtension/NotificationService.swift b/ios/NotificationServiceExtension/NotificationService.swift index c4eb01981bf2..e489cb368d17 100644 --- a/ios/NotificationServiceExtension/NotificationService.swift +++ b/ios/NotificationServiceExtension/NotificationService.swift @@ -24,6 +24,8 @@ class NotificationService: UANotificationServiceExtension { return } + bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName("receive.mp3")) + if #available(iOSApplicationExtension 15.0, *) { configureCommunicationNotification(notificationContent: bestAttemptContent, contentHandler: contentHandler) } else { diff --git a/jest.config.js b/jest.config.js index 95ecc350ed9f..441507af4228 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ module.exports = { `/?(*.)+(spec|test).${testFileExtension}`, ], transform: { - '^.+\\.jsx?$': 'babel-jest', + '^.+\\.[jt]sx?$': 'babel-jest', '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: ['/node_modules/(?!react-native)/'], diff --git a/package-lock.json b/package-lock.json index 2b8f1b57a95b..aab783e8bbb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.43-6", + "version": "1.4.43-18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.43-6", + "version": "1.4.43-18", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -38,6 +38,7 @@ "@react-native-picker/picker": "2.5.1", "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.8", + "@react-navigation/native-stack": "^6.9.17", "@react-navigation/stack": "6.3.16", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "^10.1.11", @@ -97,7 +98,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.2", + "react-native-onyx": "2.0.6", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -236,7 +237,7 @@ "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", "ts-node": "^10.9.2", - "type-fest": "^3.12.0", + "type-fest": "^4.10.2", "typescript": "^5.3.2", "wait-port": "^0.2.9", "webpack": "^5.76.0", @@ -8138,9 +8139,9 @@ } }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", - "integrity": "sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==", + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", + "integrity": "sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==", "dev": true, "dependencies": { "ansi-html-community": "^0.0.8", @@ -8160,7 +8161,7 @@ "@types/webpack": "4.x || 5.x", "react-refresh": ">=0.10.0 <1.0.0", "sockjs-client": "^1.4.0", - "type-fest": ">=0.17.0 <4.0.0", + "type-fest": ">=0.17.0 <5.0.0", "webpack": ">=4.43.0 <6.0.0", "webpack-dev-server": "3.x || 4.x", "webpack-hot-middleware": "2.x", @@ -10258,6 +10259,17 @@ "react": "*" } }, + "node_modules/@react-navigation/elements": { + "version": "1.3.21", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.21.tgz", + "integrity": "sha512-eyS2C6McNR8ihUoYfc166O1D8VYVh9KIl0UQPI8/ZJVsStlfSTgeEEh+WXge6+7SFPnZ4ewzEJdSAHH+jzcEfg==", + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-safe-area-context": ">= 3.0.0" + } + }, "node_modules/@react-navigation/material-top-tabs": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/@react-navigation/material-top-tabs/-/material-top-tabs-6.6.3.tgz", @@ -10289,6 +10301,22 @@ "react-native": "*" } }, + "node_modules/@react-navigation/native-stack": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-6.9.17.tgz", + "integrity": "sha512-X8p8aS7JptQq7uZZNFEvfEcPf6tlK4PyVwYDdryRbG98B4bh2wFQYMThxvqa+FGEN7USEuHdv2mF0GhFKfX0ew==", + "dependencies": { + "@react-navigation/elements": "^1.3.21", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-safe-area-context": ">= 3.0.0", + "react-native-screens": ">= 3.0.0" + } + }, "node_modules/@react-navigation/routers": { "version": "6.1.9", "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz", @@ -10315,17 +10343,6 @@ "react-native-screens": ">= 3.0.0" } }, - "node_modules/@react-navigation/stack/node_modules/@react-navigation/elements": { - "version": "1.3.17", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz", - "integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA==", - "peerDependencies": { - "@react-navigation/native": "^6.0.0", - "react": "*", - "react-native": "*", - "react-native-safe-area-context": ">= 3.0.0" - } - }, "node_modules/@react-ng/bounds-observer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@react-ng/bounds-observer/-/bounds-observer-0.2.1.tgz", @@ -26853,10 +26870,11 @@ } }, "node_modules/core-js-pure": { - "version": "3.24.1", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.36.0.tgz", + "integrity": "sha512-cN28qmhRNgbMZZMc/RFu5w8pK9VJzpb2rJVR/lHuZJKwmXnoWOpXmMkxqBB514igkp1Hu8WGROsiOAzUcKdHOQ==", "dev": true, "hasInstallScript": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -38605,6 +38623,17 @@ "node": ">=8" } }, + "node_modules/jest-watch-typeahead/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-watcher": { "version": "29.4.1", "license": "MIT", @@ -45141,9 +45170,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.2.tgz", - "integrity": "sha512-24kcG3ChBXp+uSSCXudFvZTdCnKLRHQRgvTcnh2eA7COtKvbL8ggEJNkglSYmcf5WoDzLgYyWiKvcjcXQnmBvw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.6.tgz", + "integrity": "sha512-qsCxvNKc+mq/Y74v6Twe7VZxqgfpjBm0997R8OEtCUJEtgAp0riCQ3GvuVVIWYALz3S+ADokEAEPzeFW2frtpw==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -50832,11 +50861,12 @@ } }, "node_modules/type-fest": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", - "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.3.tgz", + "integrity": "sha512-JLXyjizi072smKGGcZiAJDCNweT8J+AuRxmPZ1aG7TERg4ijx9REl8CNhbr36RV4qXqL1gO1FF9HL8OkVmmrsA==", + "dev": true, "engines": { - "node": ">=14.16" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index 75be3665a4d6..f5ff807cdbec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.43-6", + "version": "1.4.43-18", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -50,8 +50,8 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "test:e2e": "ts-node tests/e2e/testRunner.js --development --skipCheckout --skipInstallDeps --buildMode none", - "test:e2e:dev": "ts-node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps", + "test:e2e": "ts-node tests/e2e/testRunner.js --config ./config.local.ts", + "test:e2e:dev": "ts-node tests/e2e/testRunner.js --config ./config.dev.js", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.js", @@ -86,6 +86,7 @@ "@react-native-picker/picker": "2.5.1", "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.8", + "@react-navigation/native-stack": "^6.9.17", "@react-navigation/stack": "6.3.16", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "^10.1.11", @@ -145,7 +146,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.2", + "react-native-onyx": "2.0.6", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -284,7 +285,7 @@ "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", "ts-node": "^10.9.2", - "type-fest": "^3.12.0", + "type-fest": "^4.10.2", "typescript": "^5.3.2", "wait-port": "^0.2.9", "webpack": "^5.76.0", diff --git a/patches/@react-navigation+native-stack+6.9.17.patch b/patches/@react-navigation+native-stack+6.9.17.patch new file mode 100644 index 000000000000..933ca6ce792e --- /dev/null +++ b/patches/@react-navigation+native-stack+6.9.17.patch @@ -0,0 +1,39 @@ +diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx +index 206fb0b..7a34a8e 100644 +--- a/node_modules/@react-navigation/native-stack/src/types.tsx ++++ b/node_modules/@react-navigation/native-stack/src/types.tsx +@@ -490,6 +490,14 @@ export type NativeStackNavigationOptions = { + * Only supported on iOS and Android. + */ + freezeOnBlur?: boolean; ++ // partial changes from https://github.com/react-navigation/react-navigation/commit/90cfbf23bcc5259f3262691a9eec6c5b906e5262 ++ // patch can be removed when new version of `native-stack` will be released ++ /** ++ * Whether the keyboard should hide when swiping to the previous screen. Defaults to `false`. ++ * ++ * Only supported on iOS ++ */ ++ keyboardHandlingEnabled?: boolean; + }; + + export type NativeStackNavigatorProps = DefaultNavigatorOptions< +diff --git a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx +index a005c43..03d8b50 100644 +--- a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx ++++ b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx +@@ -161,6 +161,7 @@ const SceneView = ({ + statusBarTranslucent, + statusBarColor, + freezeOnBlur, ++ keyboardHandlingEnabled, + } = options; + + let { +@@ -289,6 +290,7 @@ const SceneView = ({ + onNativeDismissCancelled={onNativeDismissCancelled} + // this prop is available since rn-screens 3.16 + freezeOnBlur={freezeOnBlur} ++ hideKeyboardOnSwipe={keyboardHandlingEnabled} + > + + diff --git a/patches/expo-av+13.10.4.patch b/patches/expo-av+13.10.4.patch new file mode 100644 index 000000000000..c7b1626e233a --- /dev/null +++ b/patches/expo-av+13.10.4.patch @@ -0,0 +1,17 @@ +diff --git a/node_modules/expo-av/android/build.gradle b/node_modules/expo-av/android/build.gradle +index 2d68ca6..c3fa3c5 100644 +--- a/node_modules/expo-av/android/build.gradle ++++ b/node_modules/expo-av/android/build.gradle +@@ -7,10 +7,11 @@ apply plugin: 'maven-publish' + group = 'host.exp.exponent' + version = '13.10.4' + ++def REACT_NATIVE_PATH = this.hasProperty('reactNativeProject') ? this.reactNativeProject + '/node_modules/react-native/package.json' : 'react-native/package.json' + def REACT_NATIVE_BUILD_FROM_SOURCE = findProject(":ReactAndroid") != null + def REACT_NATIVE_DIR = REACT_NATIVE_BUILD_FROM_SOURCE + ? findProject(":ReactAndroid").getProjectDir().parent +- : new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).parent ++ : new File(["node", "--print", "require.resolve('${REACT_NATIVE_PATH}')"].execute(null, rootDir).text.trim()).parent + + def reactNativeArchitectures() { + def value = project.getProperties().get("reactNativeArchitectures") diff --git a/src/App.tsx b/src/App.tsx index 9562ea647e25..cbe5948f8d4e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,6 @@ import {PortalProvider} from '@gorhom/portal'; import React from 'react'; import {LogBox} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import Onyx from 'react-native-onyx'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; @@ -31,8 +30,6 @@ import {WindowDimensionsProvider} from './components/withWindowDimensions'; import Expensify from './Expensify'; import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; -import * as Session from './libs/actions/Session'; -import * as Environment from './libs/Environment/Environment'; import InitialUrlContext from './libs/InitialUrlContext'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; import type {Route} from './ROUTES'; @@ -42,12 +39,6 @@ type AppProps = { url?: Route; }; -// For easier debugging and development, when we are in web we expose Onyx to the window, so you can more easily set data into Onyx -if (window && Environment.isDevelopment()) { - window.Onyx = Onyx; - window.setSupportToken = Session.setSupportAuthToken; -} - LogBox.ignoreLogs([ // Basically it means that if the app goes in the background and back to foreground on Android, // the timer is lost. Currently Expensify is using a 30 minutes interval to refresh personal details. diff --git a/src/CONST.ts b/src/CONST.ts index 6a57738d06ec..8abd4c087b16 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -895,6 +895,7 @@ const CONST = { DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, + DEFAULT_NETWORK_DATA: {isOffline: false}, FORMS: { LOGIN_FORM: 'LoginForm', VALIDATE_CODE_FORM: 'ValidateCodeForm', @@ -951,6 +952,9 @@ const CONST = { EMOJI_DEFAULT_SKIN_TONE: -1, + // Amount of emojis to render ahead at the end of the update cycle + EMOJI_DRAW_AMOUNT: 250, + INVISIBLE_CODEPOINTS: ['fe0f', '200d', '2066'], UNICODE: { @@ -1554,7 +1558,9 @@ const CONST = { WORKSPACE_INVOICES: 'WorkspaceSendInvoices', WORKSPACE_TRAVEL: 'WorkspaceBookTravel', WORKSPACE_MEMBERS: 'WorkspaceManageMembers', + WORKSPACE_WORKFLOWS: 'WorkspaceWorkflows', WORKSPACE_BANK_ACCOUNT: 'WorkspaceBankAccount', + WORKSPACE_SETTINGS: 'WorkspaceSettings', }, get EXPENSIFY_EMAILS() { return [ @@ -3106,6 +3112,8 @@ const CONST = { */ ADDITIONAL_ALLOWED_CHARACTERS: 20, + VALIDATION_REIMBURSEMENT_INPUT_LIMIT: 20, + REFERRAL_PROGRAM: { CONTENT_TYPES: { MONEY_REQUEST: 'request', @@ -3303,6 +3311,14 @@ const CONST = { ADDRESS: 3, }, }, + + EXIT_SURVEY: { + REASONS: { + FEATURE_NOT_AVAILABLE: 'featureNotAvailable', + DONT_UNDERSTAND: 'dontUnderstand', + PREFER_CLASSIC: 'preferClassic', + }, + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 9d35994875e1..f2d606bd62a6 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -205,6 +205,9 @@ const ONYXKEYS = { /** Is report data loading? */ IS_LOADING_APP: 'isLoadingApp', + /** Is the user in the process of switching to OldDot? */ + IS_SWITCHING_TO_OLD_DOT: 'isSwitchingToOldDot', + /** Is the test tools modal open? */ IS_TEST_TOOLS_MODAL_OPEN: 'isTestToolsModalOpen', @@ -279,7 +282,6 @@ const ONYXKEYS = { POLICY_CATEGORIES: 'policyCategories_', POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', - POLICY_TAX_RATE: 'policyTaxRates_', POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', POLICY_REPORT_FIELDS: 'policyReportFields_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', @@ -388,6 +390,10 @@ const ONYXKEYS = { REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft', PERSONAL_BANK_ACCOUNT: 'personalBankAccountForm', PERSONAL_BANK_ACCOUNT_DRAFT: 'personalBankAccountFormDraft', + EXIT_SURVEY_REASON_FORM: 'exitSurveyReasonForm', + EXIT_SURVEY_REASON_FORM_DRAFT: 'exitSurveyReasonFormDraft', + EXIT_SURVEY_RESPONSE_FORM: 'exitSurveyResponseForm', + EXIT_SURVEY_RESPONSE_FORM_DRAFT: 'exitSurveyResponseFormDraft', }, } as const; @@ -396,36 +402,37 @@ type AllOnyxKeys = DeepValueOf; type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: FormTypes.AddDebitCardForm; [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm; - [ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceProfileDescriptionForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; - [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; - [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm; + [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm; + [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.WorkspaceInviteMessageForm; [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: FormTypes.DateOfBirthForm; - [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: FormTypes.HomeAddressForm; [ONYXKEYS.FORMS.NEW_ROOM_FORM]: FormTypes.NewRoomForm; - [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.RoomSettingsForm; + [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.NewTaskForm; + [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.EditTaskForm; + [ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM]: FormTypes.ExitSurveyReasonForm; + [ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM]: FormTypes.ExitSurveyResponseForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.MoneyRequestDescriptionForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.MoneyRequestMerchantForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.MoneyRequestAmountForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.MoneyRequestDateForm; [ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm; - [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.NewContactMethodForm; + [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.WaypointForm; + [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.SettingsStatusSetForm; + [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.SettingsStatusClearDateForm; + [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: FormTypes.SettingsStatusSetClearAfterForm; [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: FormTypes.PrivateNotesForm; [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: FormTypes.IKnowTeacherForm; [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: FormTypes.IntroSchoolPrincipalForm; - [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.Form; - [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.ReportVirtualCardFraudForm; + [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.ReportPhysicalCardForm; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: FormTypes.GetPhysicalCardForm; [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: FormTypes.ReportFieldEditForm; [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm; @@ -469,7 +476,6 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.SELECTED_TAB]: string; [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; [ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStep; - [ONYXKEYS.COLLECTION.POLICY_TAX_RATE]: string[]; }; type OnyxValuesMapping = { @@ -491,7 +497,7 @@ type OnyxValuesMapping = { [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails; [ONYXKEYS.TASK]: OnyxTypes.Task; [ONYXKEYS.WORKSPACE_RATE_AND_UNIT]: OnyxTypes.WorkspaceRateAndUnit; - [ONYXKEYS.CURRENCY_LIST]: Record; + [ONYXKEYS.CURRENCY_LIST]: OnyxTypes.CurrencyList; [ONYXKEYS.UPDATE_AVAILABLE]: boolean; [ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest; [ONYXKEYS.COUNTRY_CODE]: number; @@ -535,6 +541,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean; [ONYXKEYS.IS_LOADING_APP]: boolean; + [ONYXKEYS.IS_SWITCHING_TO_OLD_DOT]: boolean; [ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer; [ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string; [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c5480d363019..a8786bda3ffb 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -84,28 +84,28 @@ const ROUTES = { SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', SETTINGS_WALLET: 'settings/wallet', SETTINGS_WALLET_DOMAINCARD: { - route: '/settings/wallet/card/:domain', - getRoute: (domain: string) => `/settings/wallet/card/${domain}` as const, + route: 'settings/wallet/card/:domain', + getRoute: (domain: string) => `settings/wallet/card/${domain}` as const, }, SETTINGS_REPORT_FRAUD: { - route: '/settings/wallet/card/:domain/report-virtual-fraud', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud` as const, + route: 'settings/wallet/card/:domain/report-virtual-fraud', + getRoute: (domain: string) => `settings/wallet/card/${domain}/report-virtual-fraud` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: { - route: '/settings/wallet/card/:domain/get-physical/name', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name` as const, + route: 'settings/wallet/card/:domain/get-physical/name', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/name` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: { - route: '/settings/wallet/card/:domain/get-physical/phone', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone` as const, + route: 'settings/wallet/card/:domain/get-physical/phone', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/phone` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: { - route: '/settings/wallet/card/:domain/get-physical/address', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address` as const, + route: 'settings/wallet/card/:domain/get-physical/address', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/address` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: { - route: '/settings/wallet/card/:domain/get-physical/confirm', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm` as const, + route: 'settings/wallet/card/:domain/get-physical/confirm', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/confirm` as const, }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', @@ -117,8 +117,8 @@ const ROUTES = { SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED: { - route: '/settings/wallet/card/:domain/report-card-lost-or-damaged', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged` as const, + route: 'settings/wallet/card/:domain/report-card-lost-or-damaged', + getRoute: (domain: string) => `settings/wallet/card/${domain}/report-card-lost-or-damaged` as const, }, SETTINGS_WALLET_CARD_ACTIVATE: { route: 'settings/wallet/card/:domain/activate', @@ -159,6 +159,17 @@ const ROUTES = { getRoute: (source: string) => `settings/troubleshoot/console/share-log?source=${encodeURI(source)}` as const, }, + SETTINGS_EXIT_SURVEY_REASON: 'settings/exit-survey/reason', + SETTINGS_EXIT_SURVEY_RESPONSE: { + route: 'settings/exit-survey/response', + getRoute: (reason?: ValueOf, backTo?: string) => + getUrlWithBackToParam(`settings/exit-survey/response${reason ? `?reason=${encodeURIComponent(reason)}` : ''}`, backTo), + }, + SETTINGS_EXIT_SURVEY_CONFIRM: { + route: 'settings/exit-survey/confirm', + getRoute: (backTo?: string) => getUrlWithBackToParam('settings/exit-survey/confirm', backTo), + }, + KEYBOARD_SHORTCUTS: 'keyboard-shortcuts', NEW: 'new', @@ -219,6 +230,10 @@ const ROUTES = { route: 'r/:reportID/settings/who-can-post', getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post` as const, }, + REPORT_SETTINGS_VISIBILITY: { + route: 'r/:reportID/settings/visibility', + getRoute: (reportID: string) => `r/${reportID}/settings/visibility` as const, + }, SPLIT_BILL_DETAILS: { route: 'r/:reportID/split/:reportActionID', getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}` as const, @@ -283,10 +298,6 @@ const ROUTES = { route: ':iouType/new/currency/:reportID?', getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const, }, - MONEY_REQUEST_CATEGORY: { - route: ':iouType/new/category/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const, - }, MONEY_REQUEST_HOLD_REASON: { route: ':iouType/edit/reason/:transactionID?', getRoute: (iouType: string, transactionID: string, reportID: string, backTo: string) => `${iouType}/edit/reason/${transactionID}?backTo=${backTo}&reportID=${reportID}` as const, @@ -334,9 +345,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/taxAmount/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_CATEGORY: { - route: 'create/:iouType/category/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/category/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/category/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_CURRENCY: { route: 'create/:iouType/currency/:transactionID/:reportID/:pageIndex?', @@ -459,6 +470,10 @@ const ROUTES = { route: 'workspace/:policyID/profile/description', getRoute: (policyID: string) => `workspace/${policyID}/profile/description` as const, }, + WORKSPACE_PROFILE_SHARE: { + route: 'workspace/:policyID/profile/share', + getRoute: (policyID: string) => `workspace/${policyID}/profile/share` as const, + }, WORKSPACE_AVATAR: { route: 'workspace/:policyID/avatar', getRoute: (policyID: string) => `workspace/${policyID}/avatar` as const, @@ -467,6 +482,10 @@ const ROUTES = { route: 'workspace/:policyID/settings/currency', getRoute: (policyID: string) => `workspace/${policyID}/settings/currency` as const, }, + WORKSPACE_WORKFLOWS: { + route: 'workspace/:policyID/workflows', + getRoute: (policyID: string) => `workspace/${policyID}/workflows` as const, + }, WORKSPACE_CARD: { route: 'workspace/:policyID/card', getRoute: (policyID: string) => `workspace/${policyID}/card` as const, @@ -503,6 +522,10 @@ const ROUTES = { route: 'workspace/:policyID/members', getRoute: (policyID: string) => `workspace/${policyID}/members` as const, }, + WORKSPACE_CATEGORIES: { + route: 'workspace/:policyID/categories', + getRoute: (policyID: string) => `workspace/${policyID}/categories` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', @@ -542,4 +565,4 @@ type Route = RouteIsPlainString extends true ? never : AllRoutes; type HybridAppRoute = (typeof HYBRID_APP_ROUTES)[keyof typeof HYBRID_APP_ROUTES]; -export type {Route, HybridAppRoute}; +export type {Route, HybridAppRoute, AllRoutes}; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index ee3c64e8d804..520895c89c98 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -80,6 +80,12 @@ const SCREENS = { REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud', CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address', }, + + EXIT_SURVEY: { + REASON: 'Settings_ExitSurvey_Reason', + RESPONSE: 'Settings_ExitSurvey_Response', + CONFIRM: 'Settings_ExitSurvey_Confirm', + }, }, SAVE_THE_WORLD: { ROOT: 'SaveTheWorld_Root', @@ -149,7 +155,6 @@ const SCREENS = { PARTICIPANTS: 'Money_Request_Participants', CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', - CATEGORY: 'Money_Request_Category', WAYPOINT: 'Money_Request_Waypoint', EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', DISTANCE: 'Money_Request_Distance', @@ -167,6 +172,7 @@ const SCREENS = { ROOM_NAME: 'Report_Settings_Room_Name', NOTIFICATION_PREFERENCES: 'Report_Settings_Notification_Preferences', WRITE_CAPABILITY: 'Report_Settings_Write_Capability', + VISIBILITY: 'Report_Settings_Visibility', }, NEW_TASK: { @@ -207,8 +213,11 @@ const SCREENS = { MEMBERS: 'Workspace_Members', INVITE: 'Workspace_Invite', INVITE_MESSAGE: 'Workspace_Invite_Message', + CATEGORIES: 'Workspace_Categories', CURRENCY: 'Workspace_Profile_Currency', + WORKFLOWS: 'Workspace_Workflows', DESCRIPTION: 'Workspace_Profile_Description', + SHARE: 'Workspace_Profile_Share', NAME: 'Workspace_Profile_Name', }, diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 89e87eeebe54..8ad26e5a7c46 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -272,7 +272,7 @@ function AddressSearch( const renderHeaderComponent = () => ( <> - {predefinedPlaces.length > 0 && ( + {(predefinedPlaces?.length ?? 0) > 0 && ( <> {/* This will show current location button in list if there are some recent destinations */} {shouldShowCurrentLocationButton && ( @@ -339,7 +339,7 @@ function AddressSearch( fetchDetails suppressDefaultStyles enablePoweredByContainer={false} - predefinedPlaces={predefinedPlaces} + predefinedPlaces={predefinedPlaces ?? undefined} listEmptyComponent={listEmptyComponent} listLoaderComponent={listLoader} renderHeaderComponent={renderHeaderComponent} @@ -348,7 +348,7 @@ function AddressSearch( const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text; return ( - {title && {title}} + {!!title && {title}} {subtitle} ); @@ -398,10 +398,10 @@ function AddressSearch( if (inputID) { onInputChange?.(text); } else { - onInputChange({street: text}); + onInputChange?.({street: text}); } // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering - if (!text && !predefinedPlaces.length) { + if (!text && !predefinedPlaces?.length) { setDisplayListViewBorder(false); } }, diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index 9b4254a9bc45..e4735e9d0020 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -20,6 +20,8 @@ type RenamedInputKeysProps = { lat: string; lng: string; zipCode: string; + address?: string; + country?: string; }; type OnPressProps = { @@ -59,7 +61,7 @@ type AddressSearchProps = { defaultValue?: string; /** A callback function when the value of this field has changed */ - onInputChange: (value: string | number | RenamedInputKeysProps | StreetValue, key?: string) => void; + onInputChange?: (value: string | number | RenamedInputKeysProps | StreetValue, key?: string) => void; /** A callback function when an address has been auto-selected */ onPress?: (props: OnPressProps) => void; @@ -74,10 +76,10 @@ type AddressSearchProps = { canUseCurrentLocation?: boolean; /** A list of predefined places that can be shown when the user isn't searching for something */ - predefinedPlaces?: Place[]; + predefinedPlaces?: Place[] | null; /** A map of inputID key names */ - renamedInputKeys: RenamedInputKeysProps; + renamedInputKeys?: RenamedInputKeysProps; /** Maximum number of characters allowed in search input */ maxInputLength?: number; diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js deleted file mode 100644 index 3fc90433f13e..000000000000 --- a/src/components/Alert/index.js +++ /dev/null @@ -1,24 +0,0 @@ -import _ from 'underscore'; - -/** - * Shows an alert modal with ok and cancel options. - * - * @param {String} title The title of the alert - * @param {String} description The description of the alert - * @param {Object[]} [options] An array of objects with `style` and `onPress` properties - */ -export default (title, description, options) => { - const result = _.filter(window.confirm([title, description], Boolean)).join('\n'); - - if (result) { - const confirmOption = _.find(options, ({style}) => style !== 'cancel'); - if (confirmOption && confirmOption.onPress) { - confirmOption.onPress(); - } - } else { - const cancelOption = _.find(options, ({style}) => style === 'cancel'); - if (cancelOption && cancelOption.onPress) { - cancelOption.onPress(); - } - } -}; diff --git a/src/components/Alert/index.native.js b/src/components/Alert/index.native.js deleted file mode 100644 index 31c837a7dd6b..000000000000 --- a/src/components/Alert/index.native.js +++ /dev/null @@ -1,3 +0,0 @@ -import {Alert} from 'react-native'; - -export default Alert.alert; diff --git a/src/components/Alert/index.native.tsx b/src/components/Alert/index.native.tsx new file mode 100644 index 000000000000..b72eff5d9b58 --- /dev/null +++ b/src/components/Alert/index.native.tsx @@ -0,0 +1,6 @@ +import {Alert as AlertRN} from 'react-native'; +import type Alert from './types'; + +const alert: Alert = AlertRN.alert; + +export default alert; diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx new file mode 100644 index 000000000000..f212f06aa9d3 --- /dev/null +++ b/src/components/Alert/index.tsx @@ -0,0 +1,16 @@ +import type Alert from './types'; + +/** Shows an alert modal with ok and cancel options. */ +const alert: Alert = (title, description, options) => { + const result = window.confirm([title, description].filter(Boolean).join('\n')); + + if (result) { + const confirmOption = options?.find(({style}) => style !== 'cancel'); + confirmOption?.onPress?.(); + } else { + const cancelOption = options?.find(({style}) => style === 'cancel'); + cancelOption?.onPress?.(); + } +}; + +export default alert; diff --git a/src/components/Alert/types.ts b/src/components/Alert/types.ts new file mode 100644 index 000000000000..25454abfe8b8 --- /dev/null +++ b/src/components/Alert/types.ts @@ -0,0 +1,5 @@ +import type {AlertStatic} from 'react-native'; + +type Alert = AlertStatic['alert']; + +export default Alert; diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js index 59928b80c4b1..d4d3d0696c59 100644 --- a/src/components/AttachmentPicker/index.native.js +++ b/src/components/AttachmentPicker/index.native.js @@ -1,7 +1,7 @@ import lodashCompact from 'lodash/compact'; import PropTypes from 'prop-types'; import React, {useCallback, useMemo, useRef, useState} from 'react'; -import {Alert, View} from 'react-native'; +import {Alert, Image as RNImage, View} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; import RNDocumentPicker from 'react-native-document-picker'; import {launchImageLibrary} from 'react-native-image-picker'; @@ -57,11 +57,22 @@ const getImagePickerOptions = (type) => { }; /** - * See https://github.com/rnmods/react-native-document-picker#options for DocumentPicker configuration options + * Return documentPickerOptions based on the type + * @param {String} type + * @returns {Object} */ -const documentPickerOptions = { - type: [RNDocumentPicker.types.allFiles], - copyTo: 'cachesDirectory', + +const getDocumentPickerOptions = (type) => { + if (type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE) { + return { + type: [RNDocumentPicker.types.images], + copyTo: 'cachesDirectory', + }; + } + return { + type: [RNDocumentPicker.types.allFiles], + copyTo: 'cachesDirectory', + }; }; /** @@ -158,7 +169,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) { */ const showDocumentPicker = useCallback( () => - RNDocumentPicker.pick(documentPickerOptions).catch((error) => { + RNDocumentPicker.pick(getDocumentPickerOptions(type)).catch((error) => { if (RNDocumentPicker.isCancel(error)) { return; } @@ -166,7 +177,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) { showGeneralAlert(error.message); throw error; }), - [showGeneralAlert], + [showGeneralAlert, type], ); const menuItemData = useMemo(() => { @@ -181,7 +192,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) { textTranslationKey: 'attachmentPicker.chooseFromGallery', pickAttachment: () => showImagePicker(launchImageLibrary), }, - type !== CONST.ATTACHMENT_PICKER_TYPE.IMAGE && { + { icon: Expensicons.Paperclip, textTranslationKey: 'attachmentPicker.chooseDocument', pickAttachment: showDocumentPicker, @@ -189,7 +200,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) { ]); return data; - }, [showDocumentPicker, showImagePicker, type, shouldHideCameraOption]); + }, [showDocumentPicker, showImagePicker, shouldHideCameraOption]); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible}); @@ -232,22 +243,23 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) { onCanceled.current(); return Promise.resolve(); } - const fileData = _.first(attachments); - - if (fileData.width === -1 || fileData.height === -1) { - showImageCorruptionAlert(); - return Promise.resolve(); - } - - return getDataForUpload(fileData) - .then((result) => { - completeAttachmentSelection.current(result); - }) - .catch((error) => { - showGeneralAlert(error.message); - throw error; - }); + RNImage.getSize(fileData.uri, (width, height) => { + fileData.width = width; + fileData.height = height; + if (fileData.width === -1 || fileData.height === -1) { + showImageCorruptionAlert(); + return Promise.resolve(); + } + return getDataForUpload(fileData) + .then((result) => { + completeAttachmentSelection.current(result); + }) + .catch((error) => { + showGeneralAlert(error.message); + throw error; + }); + }); }, [showGeneralAlert, showImageCorruptionAlert], ); diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts index fd9b57511cc4..f1b9d16de654 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.ts @@ -26,6 +26,7 @@ type AttachmentCarouselPagerContextValue = { isScrollEnabled: SharedValue; onTap: () => void; onScaleChanged: (scale: number) => void; + onSwipeDown: () => void; }; const AttachmentCarouselPagerContext = createContext(null); diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index 8704584c3e18..33d9f20b5e57 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -42,9 +42,15 @@ type AttachmentCarouselPagerProps = { * @param showArrows If set, it will show/hide the arrows. If not set, it will toggle the arrows. */ onRequestToggleArrows: (showArrows?: boolean) => void; + + /** A callback that is called when swipe-down-to-close gesture happens */ + onClose: () => void; }; -function AttachmentCarouselPager({items, activeSource, initialPage, onPageSelected, onRequestToggleArrows}: AttachmentCarouselPagerProps, ref: ForwardedRef) { +function AttachmentCarouselPager( + {items, activeSource, initialPage, onPageSelected, onRequestToggleArrows, onClose}: AttachmentCarouselPagerProps, + ref: ForwardedRef, +) { const styles = useThemeStyles(); const pagerRef = useRef(null); @@ -114,9 +120,10 @@ function AttachmentCarouselPager({items, activeSource, initialPage, onPageSelect isScrollEnabled, pagerRef, onTap: handleTap, + onSwipeDown: onClose, onScaleChanged: handleScaleChange, }), - [pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, handleScaleChange], + [pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, handleTap, onClose, handleScaleChange], ); const animatedProps = useAnimatedProps(() => ({ diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index 228f0d597a32..a4d3e1392095 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -102,6 +102,10 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, [setShouldShowArrows], ); + const goBack = useCallback(() => { + Navigation.goBack(); + }, []); + return ( {page == null ? ( @@ -133,6 +137,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, activeSource={activeSource} onRequestToggleArrows={toggleArrows} onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)} + onClose={goBack} ref={pagerRef} /> diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.tsx similarity index 77% rename from src/components/AvatarWithImagePicker.js rename to src/components/AvatarWithImagePicker.tsx index 26d41ea82e00..fa8a6d71516f 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.tsx @@ -1,18 +1,20 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; -import _ from 'underscore'; +import type {StyleProp, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; +import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import getImageResolution from '@libs/fileDownload/getImageResolution'; -import stylePropTypes from '@styles/stylePropTypes'; +import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type IconAsset from '@src/types/utils/IconAsset'; import AttachmentModal from './AttachmentModal'; import AttachmentPicker from './AttachmentPicker'; import Avatar from './Avatar'; @@ -20,167 +22,140 @@ import AvatarCropModal from './AvatarCropModal/AvatarCropModal'; import DotIndicatorMessage from './DotIndicatorMessage'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; -import sourcePropTypes from './Image/sourcePropTypes'; import OfflineWithFeedback from './OfflineWithFeedback'; import PopoverMenu from './PopoverMenu'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Tooltip from './Tooltip'; import withNavigationFocus from './withNavigationFocus'; -const propTypes = { +type ErrorData = { + validationError?: TranslationPaths | null | ''; + phraseParam: Record; +}; + +type OpenPickerParams = { + onPicked: (image: File) => void; +}; +type OpenPicker = (args: OpenPickerParams) => void; + +type MenuItem = { + icon: IconAsset; + text: string; + onSelected: () => void; +}; + +type AvatarWithImagePickerProps = { /** Avatar source to display */ - source: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]), + source?: AvatarSource; /** Additional style props */ - style: stylePropTypes, + style?: StyleProp; /** Additional style props for disabled picker */ - disabledStyle: stylePropTypes, + disabledStyle?: StyleProp; /** Executed once an image has been selected */ - onImageSelected: PropTypes.func, + onImageSelected?: (file: File | CustomRNImageManipulatorResult) => void; /** Execute when the user taps "remove" */ - onImageRemoved: PropTypes.func, + onImageRemoved?: () => void; /** A default avatar component to display when there is no source */ - DefaultAvatar: PropTypes.func, + DefaultAvatar?: () => React.ReactNode; /** Whether we are using the default avatar */ - isUsingDefaultAvatar: PropTypes.bool, + isUsingDefaultAvatar?: boolean; /** Size of Indicator */ - size: PropTypes.oneOf([CONST.AVATAR_SIZE.XLARGE, CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]), + size?: typeof CONST.AVATAR_SIZE.XLARGE | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT; /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ - fallbackIcon: sourcePropTypes, + fallbackIcon?: AvatarSource; /** Denotes whether it is an avatar or a workspace avatar */ - type: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_WORKSPACE]), + type?: typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE; /** Image crop vector mask */ - editorMaskImage: sourcePropTypes, + editorMaskImage?: IconAsset; /** Additional style object for the error row */ - errorRowStyles: stylePropTypes, + errorRowStyles?: StyleProp; /** A function to run when the X button next to the error is clicked */ - onErrorClose: PropTypes.func, + onErrorClose?: () => void; /** The type of action that's pending */ - pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), + pendingAction?: OnyxCommon.PendingAction; /** The errors to display */ - // eslint-disable-next-line react/forbid-prop-types - errors: PropTypes.object, + errors?: OnyxCommon.Errors | null; /** Title for avatar preview modal */ - headerTitle: PropTypes.string, + headerTitle?: string; /** Avatar source for avatar preview modal */ - previewSource: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]), + previewSource?: AvatarSource; /** File name of the avatar */ - originalFileName: PropTypes.string, + originalFileName?: string; /** Whether navigation is focused */ - isFocused: PropTypes.bool.isRequired, + isFocused: boolean; /** Style applied to the avatar */ - avatarStyle: stylePropTypes.isRequired, + avatarStyle: StyleProp; /** Indicates if picker feature should be disabled */ - disabled: PropTypes.bool, + disabled?: boolean; /** Executed once click on view photo option */ - onViewPhotoPress: PropTypes.func, - - /** Where the popover should be positioned relative to the anchor points. */ - anchorAlignment: PropTypes.shape({ - horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), - vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), - }), + onViewPhotoPress?: () => void; /** Allows to open an image without Attachment Picker. */ - enablePreview: PropTypes.bool, -}; - -const defaultProps = { - source: '', - onImageSelected: () => {}, - onImageRemoved: () => {}, - style: [], - disabledStyle: [], - DefaultAvatar: () => {}, - isUsingDefaultAvatar: false, - size: CONST.AVATAR_SIZE.DEFAULT, - fallbackIcon: Expensicons.FallbackAvatar, - type: CONST.ICON_TYPE_AVATAR, - editorMaskImage: undefined, - errorRowStyles: [], - onErrorClose: () => {}, - pendingAction: null, - errors: null, - headerTitle: '', - previewSource: '', - originalFileName: '', - disabled: false, - onViewPhotoPress: undefined, - anchorAlignment: { - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, - }, - enablePreview: false, + enablePreview?: boolean; }; function AvatarWithImagePicker({ isFocused, - DefaultAvatar, + DefaultAvatar = () => null, style, disabledStyle, pendingAction, errors, errorRowStyles, - onErrorClose, - source, - fallbackIcon, - size, - type, - headerTitle, - previewSource, - originalFileName, - isUsingDefaultAvatar, - onImageRemoved, - onImageSelected, + onErrorClose = () => {}, + source = '', + fallbackIcon = Expensicons.FallbackAvatar, + size = CONST.AVATAR_SIZE.DEFAULT, + type = CONST.ICON_TYPE_AVATAR, + headerTitle = '', + previewSource = '', + originalFileName = '', + isUsingDefaultAvatar = false, + onImageSelected = () => {}, + onImageRemoved = () => {}, editorMaskImage, avatarStyle, - disabled, + disabled = false, onViewPhotoPress, - enablePreview, -}) { + enablePreview = false, +}: AvatarWithImagePickerProps) { const theme = useTheme(); const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0}); const [isMenuVisible, setIsMenuVisible] = useState(false); - const [errorData, setErrorData] = useState({ - validationError: null, - phraseParam: {}, - }); + const [errorData, setErrorData] = useState({validationError: null, phraseParam: {}}); const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false); const [imageData, setImageData] = useState({ uri: '', name: '', type: '', }); - const anchorRef = useRef(); + const anchorRef = useRef(null); const {translate} = useLocalize(); - /** - * @param {String} error - * @param {Object} phraseParam - */ - const setError = (error, phraseParam) => { + const setError = (error: TranslationPaths | null, phraseParam: Record) => { setErrorData({ validationError: error, phraseParam, @@ -198,40 +173,29 @@ function AvatarWithImagePicker({ /** * Check if the attachment extension is allowed. - * - * @param {Object} image - * @returns {Boolean} */ - const isValidExtension = (image) => { - const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', '')); - return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase()); + const isValidExtension = (image: File): boolean => { + const {fileExtension} = FileUtils.splitExtensionFromFileName(image?.name ?? ''); + return CONST.AVATAR_ALLOWED_EXTENSIONS.some((extension) => extension === fileExtension.toLowerCase()); }; /** * Check if the attachment size is less than allowed size. - * - * @param {Object} image - * @returns {Boolean} */ - const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE; + const isValidSize = (image: File): boolean => (image?.size ?? 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE; /** * Check if the attachment resolution matches constraints. - * - * @param {Object} image - * @returns {Promise} */ - const isValidResolution = (image) => + const isValidResolution = (image: File): Promise => getImageResolution(image).then( ({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX, ); /** * Validates if an image has a valid resolution and opens an avatar crop modal - * - * @param {Object} image */ - const showAvatarCropModal = (image) => { + const showAvatarCropModal = (image: File) => { if (!isValidExtension(image)) { setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS}); return; @@ -269,11 +233,8 @@ function AvatarWithImagePicker({ /** * Create menu items list for avatar menu - * - * @param {Function} openPicker - * @returns {Array} */ - const createMenuItems = (openPicker) => { + const createMenuItems = (openPicker: OpenPicker): MenuItem[] => { const menuItems = [ { icon: Expensicons.Upload, @@ -318,6 +279,7 @@ function AvatarWithImagePicker({ vertical: y + height + variables.spacing2, }); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMenuVisible, windowWidth]); @@ -383,7 +345,11 @@ function AvatarWithImagePicker({ maybeIcon={isUsingDefaultAvatar} > {({show}) => ( - + + {/* @ts-expect-error TODO: Remove this once AttachmentPicker (https://github.com/Expensify/App/issues/25134) is migrated to TypeScript. */} {({openPicker}) => { const menuItems = createMenuItems(openPicker); @@ -432,7 +398,8 @@ function AvatarWithImagePicker({ {errorData.validationError && ( )} @@ -449,8 +416,6 @@ function AvatarWithImagePicker({ ); } -AvatarWithImagePicker.propTypes = propTypes; -AvatarWithImagePicker.defaultProps = defaultProps; AvatarWithImagePicker.displayName = 'AvatarWithImagePicker'; export default withNavigationFocus(AvatarWithImagePicker); diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js index 0e57bcf4db03..2374fc9e5d0c 100644 --- a/src/components/CategoryPicker/index.js +++ b/src/components/CategoryPicker/index.js @@ -3,6 +3,7 @@ import React, {useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -67,6 +68,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC textInputLabel={shouldShowTextInput && translate('common.search')} onChangeText={setSearchValue} onSelectRow={onSubmit} + ListItem={RadioListItem} initiallyFocusedOptionKey={selectedOptionKey} /> ); diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 25dc99459064..5b5e99ac0621 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -1,4 +1,5 @@ -import React, {forwardRef, useEffect} from 'react'; +import {useIsFocused} from '@react-navigation/native'; +import React, {forwardRef, useEffect, useRef} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; @@ -23,15 +24,28 @@ type CountrySelectorProps = { /** inputID used by the Form component */ // eslint-disable-next-line react/no-unused-prop-types inputID: string; + + /** Callback to call when the picker modal is dismissed */ + onBlur?: () => void; }; -function CountrySelector({errorText = '', value: countryCode, onInputChange}: CountrySelectorProps, ref: ForwardedRef) { +function CountrySelector({errorText = '', value: countryCode, onInputChange, onBlur}: CountrySelectorProps, ref: ForwardedRef) { const styles = useThemeStyles(); const {translate} = useLocalize(); const title = countryCode ? translate(`allCountries.${countryCode}`) : ''; const countryTitleDescStyle = title.length === 0 ? styles.textNormal : null; + const didOpenContrySelector = useRef(false); + const isFocused = useIsFocused(); + useEffect(() => { + if (!isFocused || !didOpenContrySelector.current) { + return; + } + didOpenContrySelector.current = false; + onBlur?.(); + }, [isFocused, onBlur]); + useEffect(() => { // This will cause the form to revalidate and remove any error related to country name onInputChange(countryCode); @@ -48,6 +62,7 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange}: Co description={translate('common.country')} onPress={() => { const activeRoute = Navigation.getActiveRouteWithoutParams(); + didOpenContrySelector.current = true; Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); }} /> diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index 49915ebfbf1b..f8c4a12ec188 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -79,6 +80,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear showScrollIndicator shouldStopPropagation shouldUseDynamicMaxToRenderPerBatch + ListItem={RadioListItem} /> diff --git a/src/components/DistanceRequest/index.tsx b/src/components/DistanceRequest/index.tsx index 29987f716565..3f74c148de70 100644 --- a/src/components/DistanceRequest/index.tsx +++ b/src/components/DistanceRequest/index.tsx @@ -28,6 +28,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Report, Transaction} from '@src/types/onyx'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import DistanceRequestFooter from './DistanceRequestFooter'; import DistanceRequestRenderItem from './DistanceRequestRenderItem'; @@ -176,7 +177,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit ); }; - const getError = () => { + const getError = useCallback(() => { // Get route error if available else show the invalid number of waypoints error. if (hasRouteError) { return ErrorUtils.getLatestErrorField((transaction ?? {}) as Transaction, 'route'); @@ -186,8 +187,12 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit // eslint-disable-next-line @typescript-eslint/naming-convention return {0: 'iou.error.atLeastTwoDifferentWaypoints'}; } - return {}; - }; + + if (Object.keys(validatedWaypoints).length < Object.keys(waypoints).length) { + // eslint-disable-next-line @typescript-eslint/naming-convention + return {0: translate('iou.error.duplicateWaypointsErrorMessage')}; + } + }, [translate, transaction, hasRouteError, validatedWaypoints, waypoints]); const updateWaypoints = useCallback( ({data}: DraggableListData) => { @@ -211,7 +216,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit const submitWaypoints = useCallback(() => { // If there is any error or loading state, don't let user go to next page. - if (Object.keys(validatedWaypoints).length < 2 || hasRouteError || isLoadingRoute || (isLoading && !isOffline)) { + if (!isEmptyObject(getError()) || isLoadingRoute || (isLoading && !isOffline)) { setHasError(true); return; } @@ -221,7 +226,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit } onSubmit(waypoints); - }, [onSubmit, setHasError, hasRouteError, isLoadingRoute, isLoading, validatedWaypoints, waypoints, isEditingNewRequest, isEditingRequest, isOffline]); + }, [onSubmit, setHasError, getError, isLoadingRoute, isLoading, waypoints, isEditingNewRequest, isEditingRequest, isOffline]); const content = ( <> @@ -254,10 +259,10 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} - {((hasError && Object.keys(validatedWaypoints).length < 2) || hasRouteError) && ( + {((hasError && !isEmptyObject(getError())) || hasRouteError) && ( )} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js index b263885f0a60..1d2d15e4564c 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js @@ -131,6 +131,7 @@ function BaseEmojiPickerMenu({headerEmojis, scrollToHeader, isFiltered, listWrap ref={forwardedRef} keyboardShouldPersistTaps="handled" data={data} + drawDistance={CONST.EMOJI_DRAW_AMOUNT} renderItem={renderItem} keyExtractor={keyExtractor} numColumns={CONST.EMOJI_NUM_PER_ROW} diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index ae98978ffcad..37d0f730c9e9 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -7,6 +7,7 @@ import type AmountTextInput from '@components/AmountTextInput'; import type CheckboxWithLabel from '@components/CheckboxWithLabel'; import type CountrySelector from '@components/CountrySelector'; import type Picker from '@components/Picker'; +import type RadioButtons from '@components/RadioButtons'; import type SingleChoiceQuestion from '@components/SingleChoiceQuestion'; import type StatePicker from '@components/StatePicker'; import type TextInput from '@components/TextInput'; @@ -34,7 +35,8 @@ type ValidInputs = | typeof AmountForm | typeof BusinessTypePicker | typeof StatePicker - | typeof ValuePicker; + | typeof ValuePicker + | typeof RadioButtons; type ValueTypeKey = 'string' | 'boolean' | 'date'; type ValueTypeMap = { diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 8e0ce759d021..f2e38ccb74af 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -10,7 +10,6 @@ import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; @@ -27,7 +26,6 @@ type MentionUserRendererProps = WithCurrentUserPersonalDetailsProps & CustomRend function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersonalDetails, ...defaultRendererProps}: MentionUserRendererProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); const htmlAttribAccountID = tnode.attributes.accountid; const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; @@ -39,7 +37,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona const user = personalDetails[htmlAttribAccountID]; accountID = parseInt(htmlAttribAccountID, 10); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || user?.displayName || translate('common.hidden'); + displayNameOrLogin = PersonalDetailsUtils.getDisplayNameOrDefault(user, LocalePhoneNumber.formatPhoneNumber(user?.login ?? '')); navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID); } else if ('data' in tnode && !isEmptyObject(tnode.data)) { // We need to remove the LTR unicode and leading @ from data as it is not part of the login diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index 2e47b97ec7af..43d1be85d21a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -1,8 +1,8 @@ import React from 'react'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; +import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import VideoPlayerPreview from '@components/VideoPlayerPreview'; import * as FileUtils from '@libs/fileDownload/FileUtils'; -import {parseReportRouteParams} from '@libs/ReportUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; @@ -22,22 +22,24 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { const width = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE]); const height = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE]); const duration = Number(htmlAttribs[CONST.ATTACHMENT_DURATION_ATTRIBUTE]); - const activeRoute = Navigation.getActiveRoute(); - const {reportID} = parseReportRouteParams(activeRoute); return ( - { - const route = ROUTES.REPORT_ATTACHMENTS.getRoute(reportID, sourceURL); - Navigation.navigate(route); - }} - /> + + {({report}) => ( + { + const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report?.reportID ?? '', sourceURL); + Navigation.navigate(route); + }} + /> + )} + ); } diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 553a60e568ec..2a7ed30abf1a 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -68,6 +68,7 @@ import Flag from '@assets/images/flag.svg'; import FlagLevelOne from '@assets/images/flag_level_01.svg'; import FlagLevelTwo from '@assets/images/flag_level_02.svg'; import FlagLevelThree from '@assets/images/flag_level_03.svg'; +import Folder from '@assets/images/folder.svg'; import Fullscreen from '@assets/images/fullscreen.svg'; import Gallery from '@assets/images/gallery.svg'; import Gear from '@assets/images/gear.svg'; @@ -144,6 +145,7 @@ import Users from '@assets/images/users.svg'; import VolumeHigh from '@assets/images/volume-high.svg'; import VolumeLow from '@assets/images/volume-low.svg'; import Wallet from '@assets/images/wallet.svg'; +import Workflows from '@assets/images/workflows.svg'; import Workspace from '@assets/images/workspace-default-avatar.svg'; import Wrench from '@assets/images/wrench.svg'; import Zoom from '@assets/images/zoom.svg'; @@ -216,6 +218,7 @@ export { FlagLevelTwo, FlagLevelThree, Fullscreen, + Folder, Gallery, Gear, Globe, @@ -287,6 +290,7 @@ export { VolumeHigh, VolumeLow, Wallet, + Workflows, Workspace, Zoom, Twitter, diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 3f6b6ca20540..e03b393dc81f 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -5,6 +5,7 @@ import BankUserGreen from '@assets/images/product-illustrations/bank-user--green import ConciergeBlue from '@assets/images/product-illustrations/concierge--blue.svg'; import ConciergeExclamation from '@assets/images/product-illustrations/concierge--exclamation.svg'; import CreditCardsBlue from '@assets/images/product-illustrations/credit-cards--blue.svg'; +import EmptyStateExpenses from '@assets/images/product-illustrations/emptystate__expenses.svg'; import GpsTrackOrange from '@assets/images/product-illustrations/gps-track--orange.svg'; import Hands from '@assets/images/product-illustrations/home-illustration-hands.svg'; import InvoiceOrange from '@assets/images/product-illustrations/invoice--orange.svg'; @@ -15,6 +16,7 @@ import JewelBoxYellow from '@assets/images/product-illustrations/jewel-box--yell import MagicCode from '@assets/images/product-illustrations/magic-code.svg'; import MoneyEnvelopeBlue from '@assets/images/product-illustrations/money-envelope--blue.svg'; import MoneyMousePink from '@assets/images/product-illustrations/money-mouse--pink.svg'; +import MushroomTopHat from '@assets/images/product-illustrations/mushroom-top-hat.svg'; import PaymentHands from '@assets/images/product-illustrations/payment-hands.svg'; import ReceiptYellow from '@assets/images/product-illustrations/receipt--yellow.svg'; import ReceiptsSearchYellow from '@assets/images/product-illustrations/receipts-search--yellow.svg'; @@ -26,6 +28,7 @@ import TadaBlue from '@assets/images/product-illustrations/tada--blue.svg'; import TadaYellow from '@assets/images/product-illustrations/tada--yellow.svg'; import TeleScope from '@assets/images/product-illustrations/telescope.svg'; import ToddBehindCloud from '@assets/images/product-illustrations/todd-behind-cloud.svg'; +import Approval from '@assets/images/simple-illustrations/simple-illustration__approval.svg'; import BankArrow from '@assets/images/simple-illustrations/simple-illustration__bank-arrow.svg'; import BigRocket from '@assets/images/simple-illustrations/simple-illustration__bigrocket.svg'; import PinkBill from '@assets/images/simple-illustrations/simple-illustration__bill.svg'; @@ -36,6 +39,7 @@ import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustra import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg'; import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; +import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg'; import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg'; import HandEarth from '@assets/images/simple-illustrations/simple-illustration__handearth.svg'; @@ -55,6 +59,7 @@ import OpenSafe from '@assets/images/simple-illustrations/simple-illustration__o import PalmTree from '@assets/images/simple-illustrations/simple-illustration__palmtree.svg'; import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg'; import QRCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg'; +import ReceiptEnvelope from '@assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg'; import ReceiptWrangler from '@assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg'; import SanFrancisco from '@assets/images/simple-illustrations/simple-illustration__sanfrancisco.svg'; import ShieldYellow from '@assets/images/simple-illustrations/simple-illustration__shield.svg'; @@ -63,6 +68,8 @@ import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustrati import TrackShoe from '@assets/images/simple-illustrations/simple-illustration__track-shoe.svg'; import TrashCan from '@assets/images/simple-illustrations/simple-illustration__trashcan.svg'; import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg'; +import WalletAlt from '@assets/images/simple-illustrations/simple-illustration__wallet-alt.svg'; +import Workflows from '@assets/images/simple-illustrations/simple-illustration__workflows.svg'; export { Abracadabra, @@ -76,6 +83,8 @@ export { ConciergeExclamation, CreditCardsBlue, EmailAddress, + EmptyStateExpenses, + FolderOpen, HandCard, HotDogStand, InvoiceOrange, @@ -88,6 +97,7 @@ export { Mailbox, MoneyEnvelopeBlue, MoneyMousePink, + MushroomTopHat, ReceiptsSearchYellow, ReceiptYellow, ReceiptWrangler, @@ -129,5 +139,9 @@ export { LockClosed, Gears, QRCode, + ReceiptEnvelope, + Approval, + WalletAlt, + Workflows, House, }; diff --git a/src/components/Image/index.js b/src/components/Image/index.js index ef1a69e19c12..59fcde8273fd 100644 --- a/src/components/Image/index.js +++ b/src/components/Image/index.js @@ -3,12 +3,15 @@ import React, {useEffect, useMemo} from 'react'; import {Image as RNImage} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import useNetwork from '@hooks/useNetwork'; import ONYXKEYS from '@src/ONYXKEYS'; import {defaultProps, imagePropTypes} from './imagePropTypes'; import RESIZE_MODES from './resizeModes'; function Image(props) { const {source: propsSource, isAuthTokenRequired, onLoad, session} = props; + const {isOffline} = useNetwork(); + /** * Check if the image source is a URL - if so the `encryptedAuthToken` is appended * to the source. @@ -39,7 +42,7 @@ function Image(props) { RNImage.getSize(source.uri, (width, height) => { onLoad({nativeEvent: {width, height}}); }); - }, [onLoad, source]); + }, [onLoad, source, isOffline]); // Omit the props which the underlying RNImage won't use const forwardedProps = _.omit(props, ['source', 'onLoad', 'session', 'isAuthTokenRequired']); diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index b3fc1dc91c16..0ca4a0456e33 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -2,6 +2,7 @@ import delay from 'lodash/delay'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import type {ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import FullscreenLoadingIndicator from './FullscreenLoadingIndicator'; @@ -44,16 +45,27 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); + const {isOffline} = useNetwork(); const source = useMemo(() => ({uri: url}), [url]); const onError = () => { Log.hmmm('Unable to fetch image to calculate size', {url}); onLoadFailure?.(); + if (isLoadedRef.current) { + isLoadedRef.current = false; + setIsImageCached(false); + } + if (isOffline) { + return; + } + setIsLoading(false); }; const imageLoadedSuccessfully = (event: OnLoadNativeEvent) => { isLoadedRef.current = true; + setIsLoading(false); + setIsImageCached(true); onMeasure({ width: event.nativeEvent.width, height: event.nativeEvent.height, @@ -87,10 +99,6 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT } setIsLoading(true); }} - onLoadEnd={() => { - setIsLoading(false); - setIsImageCached(true); - }} onError={onError} onLoad={imageLoadedSuccessfully} /> diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index ae225b3db9e9..923337ba9ada 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -52,8 +52,13 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti return null; } - const isHidden = optionItem?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; - if (isHidden && !isFocused && !optionItem?.isPinned) { + const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + const shouldShowGreenDotIndicator = !hasBrickError && ReportUtils.requiresAttentionFromCurrentUser(optionItem, optionItem.parentReportAction); + + const isHidden = optionItem.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + + const shouldOverrideHidden = hasBrickError || isFocused || optionItem.isPinned; + if (isHidden && !shouldOverrideHidden) { return null; } @@ -74,8 +79,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const hoveredBackgroundColor = !!styles.sidebarLinkHover && 'backgroundColor' in styles.sidebarLinkHover ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; - const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - const shouldShowGreenDotIndicator = !hasBrickError && ReportUtils.requiresAttentionFromCurrentUser(optionItem, optionItem.parentReportAction); /** * Show the ReportActionContextMenu modal popover. * diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index 36cb175e3c45..69fa0d5e6e41 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -59,6 +59,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan activePage, onTap, onScaleChanged: onScaleChangedContext, + onSwipeDown, pagerRef, } = useMemo(() => { if (attachmentCarouselPagerContext === null) { @@ -70,6 +71,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan activePage: 0, onTap: () => {}, onScaleChanged: () => {}, + onSwipeDown: () => {}, pagerRef: undefined, }; } @@ -212,6 +214,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan shouldDisableTransformationGestures={isPagerScrolling} onTap={onTap} onScaleChanged={scaleChange} + onSwipeDown={onSwipeDown} > string; + formatPhoneNumber: (phoneNumber: string) => string; /** Gets the locale digit corresponding to a standard digit */ toLocaleDigit: (digit: string) => string; diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 46c96fd706a9..93febc4fd3c0 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -430,3 +430,4 @@ function MagicCodeInput( MagicCodeInput.displayName = 'MagicCodeInput'; export default forwardRef(MagicCodeInput); +export type {AutoCompleteVariant, MagicCodeInputHandle}; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 8fc9c62bfb38..1c2a8a3197fe 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -573,10 +573,12 @@ function MenuItem( {badgeText && ( diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 0de601bc9f61..df2781d3ea89 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -24,6 +24,7 @@ import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes'; +import {policyPropTypes} from '@pages/workspace/withPolicy'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -41,7 +42,6 @@ import SettlementButton from './SettlementButton'; import ShowMoreButton from './ShowMoreButton'; import Switch from './Switch'; import tagPropTypes from './tagPropTypes'; -import taxPropTypes from './taxPropTypes'; import Text from './Text'; import transactionPropTypes from './transactionPropTypes'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails'; @@ -167,8 +167,8 @@ const propTypes = { policyTags: tagPropTypes, /* Onyx Props */ - /** Collection of tax rates attached to a policy */ - policyTaxRates: taxPropTypes, + /** The policy of the report */ + policy: policyPropTypes.policy, /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ iou: iouPropTypes, @@ -196,6 +196,7 @@ const defaultProps = { receiptPath: '', receiptFilename: '', listStyles: [], + policy: {}, policyCategories: {}, policyTags: {}, transactionID: '', @@ -206,7 +207,6 @@ const defaultProps = { shouldShowSmartScanFields: true, isPolicyExpenseChat: false, iou: iouDefaultProps, - policyTaxRates: {}, }; function MoneyRequestConfirmationList(props) { @@ -228,6 +228,7 @@ function MoneyRequestConfirmationList(props) { const {unit, rate, currency} = props.mileageRate; const distance = lodashGet(transaction, 'routes.route0.distance', 0); const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0; + const taxRates = lodashGet(props.policy, 'taxRates', {}); // A flag for showing the categories field const shouldShowCategories = props.isPolicyExpenseChat && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories))); @@ -262,8 +263,8 @@ function MoneyRequestConfirmationList(props) { ); const formattedTaxAmount = CurrencyUtils.convertToDisplayString(props.transaction.taxAmount, props.iouCurrencyCode); - const defaultTaxKey = props.policyTaxRates.defaultExternalID; - const defaultTaxName = (defaultTaxKey && `${props.policyTaxRates.taxes[defaultTaxKey].name} (${props.policyTaxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || ''; + const defaultTaxKey = taxRates.defaultExternalID; + const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || ''; const taxRateTitle = (props.transaction.taxRate && props.transaction.taxRate.text) || defaultTaxName; const isFocused = useIsFocused(); @@ -766,11 +767,15 @@ function MoneyRequestConfirmationList(props) { description={translate('common.category')} numberOfLinesTitle={2} onPress={() => { - if (props.isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.CATEGORY)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_CATEGORY.getRoute(props.iouType, props.reportID)); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute( + CONST.IOU.ACTION.EDIT, + props.iouType, + props.transaction.transactionID, + props.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ); }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} @@ -814,7 +819,7 @@ function MoneyRequestConfirmationList(props) { @@ -831,7 +836,7 @@ function MoneyRequestConfirmationList(props) { @@ -886,9 +891,6 @@ export default compose( policy: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, }, - policyTaxRates: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${policyID}`, - }, iou: { key: ONYXKEYS.IOU, }, diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 3939e847707d..8eeaeaf87eff 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -23,6 +23,7 @@ import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; +import {policyPropTypes} from '@pages/workspace/withPolicy'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -41,7 +42,6 @@ import ReceiptEmptyState from './ReceiptEmptyState'; import SettlementButton from './SettlementButton'; import Switch from './Switch'; import tagPropTypes from './tagPropTypes'; -import taxPropTypes from './taxPropTypes'; import Text from './Text'; import transactionPropTypes from './transactionPropTypes'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails'; @@ -161,8 +161,8 @@ const propTypes = { policyTags: tagPropTypes, /* Onyx Props */ - /** Collection of tax rates attached to a policy */ - policyTaxRates: taxPropTypes, + /** The policy of the report */ + policy: policyPropTypes.policy, /** Transaction that represents the money request */ transaction: transactionPropTypes, @@ -189,6 +189,7 @@ const defaultProps = { receiptPath: '', receiptFilename: '', listStyles: [], + policy: {}, policyCategories: {}, policyTags: {}, transactionID: '', @@ -197,7 +198,6 @@ const defaultProps = { isDistanceRequest: false, shouldShowSmartScanFields: true, isPolicyExpenseChat: false, - policyTaxRates: {}, }; function MoneyTemporaryForRefactorRequestConfirmationList({ @@ -238,7 +238,6 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ session: {accountID}, shouldShowSmartScanFields, transaction, - policyTaxRates, }) { const theme = useTheme(); const styles = useThemeStyles(); @@ -252,6 +251,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const {unit, rate, currency} = mileageRate; const distance = lodashGet(transaction, 'routes.route0.distance', 0); const shouldCalculateDistanceAmount = isDistanceRequest && iouAmount === 0; + const taxRates = lodashGet(policy, 'taxRates', {}); // A flag for showing the categories field const shouldShowCategories = isPolicyExpenseChat && (iouCategory || OptionsListUtils.hasEnabledOptions(_.values(policyCategories))); @@ -286,8 +286,8 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ ); const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction.taxAmount, iouCurrencyCode); - const defaultTaxKey = policyTaxRates.defaultExternalID; - const defaultTaxName = (defaultTaxKey && `${policyTaxRates.taxes[defaultTaxKey].name} (${policyTaxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || ''; + const defaultTaxKey = taxRates.defaultExternalID; + const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || ''; const taxRateTitle = (transaction.taxRate && transaction.taxRate.text) || defaultTaxName; const isFocused = useIsFocused(); @@ -747,7 +747,11 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ title={iouCategory} description={translate('common.category')} numberOfLinesTitle={2} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + onPress={() => + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ) + } style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} disabled={didConfirm} @@ -763,7 +767,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ @@ -783,10 +787,10 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ { item: ( Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} @@ -800,10 +804,10 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ { item: ( Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} @@ -932,8 +936,5 @@ export default compose( policy: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, }, - policyTaxRates: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${policyID}`, - }, }), )(MoneyTemporaryForRefactorRequestConfirmationList); diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index 1efbe1827b85..0bdd53719173 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -10,7 +10,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import {DEFAULT_ZOOM_RANGE, SPRING_CONFIG, ZOOM_RANGE_BOUNCE_FACTORS} from './constants'; -import type {CanvasSize, ContentSize, OnScaleChangedCallback, OnTapCallback, ZoomRange} from './types'; +import type {CanvasSize, ContentSize, OnScaleChangedCallback, OnSwipeDownCallback, OnTapCallback, ZoomRange} from './types'; import usePanGesture from './usePanGesture'; import usePinchGesture from './usePinchGesture'; import useTapGestures from './useTapGestures'; @@ -47,6 +47,8 @@ type MultiGestureCanvasProps = ChildrenProps & { /** Handles scale changed event */ onTap?: OnTapCallback; + + onSwipeDown?: OnSwipeDownCallback; }; function MultiGestureCanvas({ @@ -59,6 +61,7 @@ function MultiGestureCanvas({ shouldDisableTransformationGestures: shouldDisableTransformationGesturesProp, onTap, onScaleChanged, + onSwipeDown, }: MultiGestureCanvasProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -88,6 +91,7 @@ function MultiGestureCanvas({ const panTranslateX = useSharedValue(0); const panTranslateY = useSharedValue(0); + const isSwipingDownToClose = useSharedValue(false); const panGestureRef = useRef(Gesture.Pan()); const pinchScale = useSharedValue(1); @@ -113,8 +117,8 @@ function MultiGestureCanvas({ stopAnimation(); if (animated) { - offsetX.value = withSpring(0, SPRING_CONFIG); - offsetY.value = withSpring(0, SPRING_CONFIG); + offsetX.value = 0; + offsetY.value = 0; panTranslateX.value = withSpring(0, SPRING_CONFIG); panTranslateY.value = withSpring(0, SPRING_CONFIG); pinchTranslateX.value = withSpring(0, SPRING_CONFIG); @@ -172,6 +176,8 @@ function MultiGestureCanvas({ panTranslateY, stopAnimation, shouldDisableTransformationGestures, + isSwipingDownToClose, + onSwipeDown, }) .simultaneousWithExternalGesture(...panGestureSimultaneousList) .withRef(panGestureRef); diff --git a/src/components/MultiGestureCanvas/types.ts b/src/components/MultiGestureCanvas/types.ts index 40fcc1462a09..fbb2f3deb88c 100644 --- a/src/components/MultiGestureCanvas/types.ts +++ b/src/components/MultiGestureCanvas/types.ts @@ -24,6 +24,9 @@ type OnScaleChangedCallback = (zoomScale: number) => void; /** Triggered when the canvas is tapped (single tap) */ type OnTapCallback = () => void; +/** Triggered when the swipe down gesture on canvas occurs */ +type OnSwipeDownCallback = () => void; + /** Types used of variables used within the MultiGestureCanvas component and it's hooks */ type MultiGestureCanvasVariables = { canvasSize: CanvasSize; @@ -32,6 +35,7 @@ type MultiGestureCanvasVariables = { minContentScale: number; maxContentScale: number; shouldDisableTransformationGestures: SharedValue; + isSwipingDownToClose: SharedValue; zoomScale: SharedValue; totalScale: SharedValue; pinchScale: SharedValue; @@ -45,6 +49,7 @@ type MultiGestureCanvasVariables = { reset: (animated: boolean, callback: () => void) => void; onTap: OnTapCallback | undefined; onScaleChanged: OnScaleChangedCallback | undefined; + onSwipeDown: OnSwipeDownCallback | undefined; }; -export type {CanvasSize, ContentSize, ZoomRange, OnScaleChangedCallback, OnTapCallback, MultiGestureCanvasVariables}; +export type {CanvasSize, ContentSize, ZoomRange, OnScaleChangedCallback, OnTapCallback, MultiGestureCanvasVariables, OnSwipeDownCallback}; diff --git a/src/components/MultiGestureCanvas/usePanGesture.ts b/src/components/MultiGestureCanvas/usePanGesture.ts index a3f9c7d62df0..97843e118871 100644 --- a/src/components/MultiGestureCanvas/usePanGesture.ts +++ b/src/components/MultiGestureCanvas/usePanGesture.ts @@ -1,7 +1,8 @@ /* eslint-disable no-param-reassign */ +import {Dimensions} from 'react-native'; import type {PanGesture} from 'react-native-gesture-handler'; import {Gesture} from 'react-native-gesture-handler'; -import {useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated'; +import {runOnJS, useDerivedValue, useSharedValue, useWorkletCallback, withDecay, withSpring} from 'react-native-reanimated'; import {SPRING_CONFIG} from './constants'; import type {MultiGestureCanvasVariables} from './types'; import * as MultiGestureCanvasUtils from './utils'; @@ -10,10 +11,24 @@ import * as MultiGestureCanvasUtils from './utils'; // We're using a "withDecay" animation to smoothly phase out the pan animation // https://docs.swmansion.com/react-native-reanimated/docs/animations/withDecay/ const PAN_DECAY_DECELARATION = 0.9915; +const SCREEN_HEIGHT = Dimensions.get('screen').height; +const SNAP_POINT = SCREEN_HEIGHT / 4; +const SNAP_POINT_HIDDEN = SCREEN_HEIGHT / 1.2; type UsePanGestureProps = Pick< MultiGestureCanvasVariables, - 'canvasSize' | 'contentSize' | 'zoomScale' | 'totalScale' | 'offsetX' | 'offsetY' | 'panTranslateX' | 'panTranslateY' | 'shouldDisableTransformationGestures' | 'stopAnimation' + | 'canvasSize' + | 'contentSize' + | 'zoomScale' + | 'totalScale' + | 'offsetX' + | 'offsetY' + | 'panTranslateX' + | 'panTranslateY' + | 'shouldDisableTransformationGestures' + | 'stopAnimation' + | 'onSwipeDown' + | 'isSwipingDownToClose' >; const usePanGesture = ({ @@ -27,16 +42,24 @@ const usePanGesture = ({ panTranslateY, shouldDisableTransformationGestures, stopAnimation, + isSwipingDownToClose, + onSwipeDown, }: UsePanGestureProps): PanGesture => { // The content size after fitting it to the canvas and zooming const zoomedContentWidth = useDerivedValue(() => contentSize.width * totalScale.value, [contentSize.width]); const zoomedContentHeight = useDerivedValue(() => contentSize.height * totalScale.value, [contentSize.height]); + // Used to track previous touch position for the "swipe down to close" gesture + const previousTouch = useSharedValue<{x: number; y: number} | null>(null); + // Velocity of the pan gesture // We need to keep track of the velocity to properly phase out/decay the pan animation const panVelocityX = useSharedValue(0); const panVelocityY = useSharedValue(0); + // Disable "swipe down to close" gesture when content is bigger than the canvas + const enableSwipeDownToClose = useDerivedValue(() => canvasSize.height < zoomedContentHeight.value, [canvasSize.height]); + // Calculates bounds of the scaled content // Can we pan left/right/up/down // Can be used to limit gesture or implementing tension effect @@ -113,8 +136,22 @@ const usePanGesture = ({ }); } } else { - // Animated back to the boundary - offsetY.value = withSpring(clampedOffset.y, SPRING_CONFIG); + const finalTranslateY = offsetY.value + panVelocityY.value * 0.2; + + if (finalTranslateY > SNAP_POINT && zoomScale.value <= 1) { + offsetY.value = withSpring(SNAP_POINT_HIDDEN, SPRING_CONFIG, () => { + isSwipingDownToClose.value = false; + }); + + if (onSwipeDown) { + runOnJS(onSwipeDown)(); + } + } else { + // Animated back to the boundary + offsetY.value = withSpring(clampedOffset.y, SPRING_CONFIG, () => { + isSwipingDownToClose.value = false; + }); + } } // Reset velocity variables after we finished the pan gesture @@ -125,14 +162,36 @@ const usePanGesture = ({ const panGesture = Gesture.Pan() .manualActivation(true) .averageTouches(true) - // eslint-disable-next-line @typescript-eslint/naming-convention - .onTouchesMove((_evt, state) => { + .onTouchesUp(() => { + previousTouch.value = null; + }) + .onTouchesMove((evt, state) => { // We only allow panning when the content is zoomed in - if (zoomScale.value <= 1 || shouldDisableTransformationGestures.value) { - return; + if (zoomScale.value > 1 && !shouldDisableTransformationGestures.value) { + state.activate(); } - state.activate(); + // TODO: this needs tuning to work properly + if (!shouldDisableTransformationGestures.value && zoomScale.value === 1 && previousTouch.value !== null) { + const velocityX = Math.abs(evt.allTouches[0].x - previousTouch.value.x); + const velocityY = evt.allTouches[0].y - previousTouch.value.y; + + if (Math.abs(velocityY) > velocityX && velocityY > 20) { + state.activate(); + + isSwipingDownToClose.value = true; + previousTouch.value = null; + + return; + } + } + + if (previousTouch.value === null) { + previousTouch.value = { + x: evt.allTouches[0].x, + y: evt.allTouches[0].y, + }; + } }) .onStart(() => { stopAnimation(); @@ -147,15 +206,23 @@ const usePanGesture = ({ panVelocityX.value = evt.velocityX; panVelocityY.value = evt.velocityY; - panTranslateX.value += evt.changeX; - panTranslateY.value += evt.changeY; + if (!isSwipingDownToClose.value) { + panTranslateX.value += evt.changeX; + } + + if (enableSwipeDownToClose.value || isSwipingDownToClose.value) { + panTranslateY.value += evt.changeY; + } }) .onEnd(() => { // Add pan translation to total offset and reset gesture variables offsetX.value += panTranslateX.value; offsetY.value += panTranslateY.value; + + // Reset pan gesture variables panTranslateX.value = 0; panTranslateY.value = 0; + previousTouch.value = null; // If we are swiping (in the pager), we don't want to return to boundaries if (shouldDisableTransformationGestures.value) { diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 5310163a7433..a391ff061baa 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -79,7 +79,7 @@ type PopoverMenuProps = Partial & { anchorPosition: AnchorPosition; /** Ref of the anchor */ - anchorRef: RefObject; + anchorRef: RefObject; /** Where the popover should be positioned relative to the anchor points. */ anchorAlignment?: AnchorAlignment; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index 69728d7be126..b8d4efbd916d 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -21,14 +21,15 @@ function PopoverContextProvider(props: PopoverContextProps) { const [isOpen, setIsOpen] = useState(false); const activePopoverRef = useRef(null); - const closePopover = useCallback((anchorRef?: RefObject) => { + const closePopover = useCallback((anchorRef?: RefObject): boolean => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { - return; + return false; } activePopoverRef.current.close(); activePopoverRef.current = null; setIsOpen(false); + return true; }, []); useEffect(() => { @@ -63,11 +64,13 @@ function PopoverContextProvider(props: PopoverContextProps) { if (e.key !== 'Escape') { return; } - closePopover(); + if (closePopover()) { + e.stopImmediatePropagation(); + } }; - document.addEventListener('keydown', listener, true); + document.addEventListener('keyup', listener, true); return () => { - document.removeEventListener('keydown', listener, true); + document.removeEventListener('keyup', listener, true); }; }, [closePopover]); diff --git a/src/components/Pressable/PressableWithFeedback.tsx b/src/components/Pressable/PressableWithFeedback.tsx index b717c4890a2d..74ea4596046e 100644 --- a/src/components/Pressable/PressableWithFeedback.tsx +++ b/src/components/Pressable/PressableWithFeedback.tsx @@ -2,6 +2,7 @@ import React, {forwardRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import OpacityView from '@components/OpacityView'; +import type {Color} from '@styles/theme/types'; import variables from '@styles/variables'; import GenericPressable from './GenericPressable'; import type {PressableRef} from './GenericPressable/types'; @@ -27,6 +28,9 @@ type PressableWithFeedbackProps = PressableProps & { /** Whether the view needs to be rendered offscreen (for Android only) */ needsOffscreenAlphaCompositing?: boolean; + + /** The color of the underlay that will show through when the Pressable is active. */ + underlayColor?: Color; }; function PressableWithFeedback( diff --git a/src/components/QRShare/index.tsx b/src/components/QRShare/index.tsx index 45a4a4fd4964..c7e9e7637a6c 100644 --- a/src/components/QRShare/index.tsx +++ b/src/components/QRShare/index.tsx @@ -9,15 +9,12 @@ import QRCode from '@components/QRCode'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import variables from '@styles/variables'; -import CONST from '@src/CONST'; import type {QRShareHandle, QRShareProps} from './types'; function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRShareProps, ref: ForwardedRef) { const styles = useThemeStyles(); const theme = useTheme(); - const {isSmallScreenWidth} = useWindowDimensions(); const [qrCodeSize, setQrCodeSize] = useState(1); const svgRef = useRef(); @@ -32,11 +29,7 @@ function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRSha const onLayout = (event: LayoutChangeEvent) => { const containerWidth = event.nativeEvent.layout.width - variables.qrShareHorizontalPadding * 2 || 0; - if (isSmallScreenWidth) { - setQrCodeSize(Math.max(1, containerWidth)); - return; - } - setQrCodeSize(Math.max(1, Math.min(containerWidth, CONST.CENTRAL_PANE_ANIMATION_HEIGHT))); + setQrCodeSize(Math.max(1, containerWidth)); }; return ( 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 e2f7314afd73..d768fe8e5d90 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -4,6 +4,7 @@ import lodashSortBy from 'lodash/sortBy'; import React from 'react'; import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; +import ConfirmedRoute from '@components/ConfirmedRoute'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestSkeletonView from '@components/MoneyRequestSkeletonView'; @@ -85,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); @@ -114,6 +115,9 @@ function MoneyRequestPreviewContent({ const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(transaction)] : []; + const hasPendingWaypoints = transaction?.pendingFields?.waypoints; + const showMapAsImage = isDistanceRequest && hasPendingWaypoints; + const getSettledMessage = (): string => { if (isCardTransaction) { return translate('common.done'); @@ -148,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')}`; @@ -206,7 +210,12 @@ function MoneyRequestPreviewContent({ !onPreviewPressed ? [styles.moneyRequestPreviewBox, containerStyles] : {}, ]} > - {hasReceipt && ( + {showMapAsImage && ( + + + + )} + {!showMapAsImage && hasReceipt && ( {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 d3c86698f910..5e869ac15e1e 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import ConfirmedRoute from '@components/ConfirmedRoute'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -109,6 +110,8 @@ function MoneyRequestView({ const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); const formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : ''; + const hasPendingWaypoints = transaction?.pendingFields?.waypoints; + const showMapAsImage = isDistanceRequest && hasPendingWaypoints; const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); const isCardTransaction = TransactionUtils.isCardTransaction(transaction); const cardProgramName = isCardTransaction && transactionCardID !== undefined ? CardUtils.getCardDescription(transactionCardID) : ''; @@ -142,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')}`; @@ -199,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> = { @@ -225,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); } @@ -239,7 +240,8 @@ function MoneyRequestView({ - {hasReceipt && ( + {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} + {(showMapAsImage || hasReceipt) && ( - + {showMapAsImage ? ( + + ) : ( + + )} )} @@ -365,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')} /> @@ -379,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 9bfeacbc0ac2..2f853dc55839 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -4,34 +4,32 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import RadioListItem from './RadioListItem'; import type {BaseListItemProps, ListItem} from './types'; -import UserListItem from './UserListItem'; function BaseListItem({ item, - isFocused = false, + pressableStyle, + wrapperStyle, + selectMultipleStyle, isDisabled = false, - showTooltip, shouldPreventDefaultFocusOnSelectRow = false, canSelectMultiple = false, onSelectRow, onDismissError = () => {}, rightHandSideComponent, keyForList, + errors, + pendingAction, + FooterComponent, + children, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - const isUserItem = 'icons' in item && item?.icons?.length && item.icons.length > 0; - const ListItem = isUserItem ? UserListItem : RadioListItem; const rightHandSideComponentRender = () => { if (canSelectMultiple || !rightHandSideComponent) { @@ -48,8 +46,8 @@ function BaseListItem({ return ( onDismissError(item)} - pendingAction={isUserItem ? item.pendingAction : undefined} - errors={isUserItem ? item.errors : undefined} + pendingAction={pendingAction} + errors={errors} errorRowStyles={styles.ph5} > ({ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} nativeID={keyForList} + style={pressableStyle} > {({hovered}) => ( <> - + {canSelectMultiple && ( - + {item.isSelected && ( ({ )} - onSelectRow(item)} - showTooltip={showTooltip} - isFocused={isFocused} - isHovered={hovered} - /> + {typeof children === 'function' ? children(hovered) : children} {!canSelectMultiple && item.isSelected && !rightHandSideComponent && ( ({ )} {rightHandSideComponentRender()} - {isUserItem && item.invitedSecondaryLogin && ( - - {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} - - )} + {FooterComponent} )} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 850874b7abc0..1c69d00b3910 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -22,12 +22,12 @@ import Log from '@libs/Log'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import BaseListItem from './BaseListItem'; import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, Section, SectionListDataType} from './types'; function BaseSelectionList( { sections, + ListItem, canSelectMultiple = false, onSelectRow, onSelectAll, @@ -61,6 +61,8 @@ function BaseSelectionList( rightHandSideComponent, isLoadingNewOptions = false, onLayout, + customListHeader, + listHeaderWrapperStyle, }: BaseSelectionListProps, inputRef: ForwardedRef, ) { @@ -280,14 +282,14 @@ function BaseSelectionList( const showTooltip = shouldShowTooltips && normalizedIndex < 10; return ( - 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/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index 60e97d887b4d..759c29013b5d 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -2,61 +2,107 @@ import React from 'react'; import {View} from 'react-native'; import MultipleAvatars from '@components/MultipleAvatars'; import SubscriptAvatar from '@components/SubscriptAvatar'; +import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; +import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ListItemProps} from './types'; +import BaseListItem from './BaseListItem'; +import type {UserListItemProps} from './types'; -function UserListItem({item, textStyles, alternateTextStyles, showTooltip, style, isFocused, isHovered}: ListItemProps) { +function UserListItem({ + item, + isFocused, + showTooltip, + isDisabled, + canSelectMultiple, + onSelectRow, + onDismissError, + shouldPreventDefaultFocusOnSelectRow, + rightHandSideComponent, +}: UserListItemProps) { const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; const subscriptAvatarBorderColor = isFocused ? focusedBackgroundColor : theme.sidebar; const hoveredBackgroundColor = !!styles.sidebarLinkHover && 'backgroundColor' in styles.sidebarLinkHover ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; return ( - <> - {!!item.icons && ( + + {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} + + ) : undefined + } + keyForList={item.keyForList} + > + {(hovered) => ( <> - {item.shouldShowSubscript ? ( - - ) : ( - + {item.shouldShowSubscript ? ( + + ) : ( + + )} + + )} + + - )} + {!!item.alternateText && ( + + )} + + {!!item.rightElement && item.rightElement} )} - - - {!!item.alternateText && ( - - )} - - {!!item.rightElement && item.rightElement} - + ); } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 6ef0fb742dc3..59f6b14cfb1f 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -1,18 +1,16 @@ import type {ReactElement, ReactNode} from 'react'; import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextStyle, ViewStyle} from 'react-native'; 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 = { /** Whether this item is focused (for arrow key controls) */ isFocused?: boolean; - /** Style to be applied to Text */ - textStyles?: StyleProp; - - /** Style to be applied on the alternate text */ - alternateTextStyles?: StyleProp; - /** Whether this item is disabled */ isDisabled?: boolean; @@ -30,6 +28,15 @@ 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; + + /** Styles for the checkbox wrapper view if select multiple option is on */ + selectMultipleStyle?: StyleProp; }; type ListItem = { @@ -87,14 +94,39 @@ type ListItemProps = CommonListItemProps & { /** Is item hovered */ isHovered?: boolean; + + /** Whether the default focus should be prevented on row selection */ + shouldPreventDefaultFocusOnSelectRow?: boolean; + + /** Key used internally by React */ + keyForList?: string; }; type BaseListItemProps = CommonListItemProps & { item: TItem; shouldPreventDefaultFocusOnSelectRow?: boolean; keyForList?: string; + errors?: Errors | ReceiptErrors | null; + pendingAction?: PendingAction | null; + FooterComponent?: ReactElement; + children?: ReactElement | ((hovered: boolean) => ReactElement); }; +type UserListItemProps = ListItemProps & { + /** Errors that this user may contain */ + errors?: Errors | ReceiptErrors | null; + + /** The type of action that's pending */ + pendingAction?: PendingAction | null; + + /** The React element that will be shown as a footer */ + FooterComponent?: ReactElement; +}; + +type RadioListItemProps = ListItemProps; + +type TableListItemProps = ListItemProps; + type Section = { /** Title of the section */ title?: string; @@ -116,6 +148,9 @@ type BaseSelectionListProps = Partial & { /** Sections for the section list */ sections: Array>>; + /** Default renderer for every item in the list */ + ListItem: typeof RadioListItem | typeof UserListItem | typeof TableListItem; + /** Whether this is a multi-select list */ canSelectMultiple?: boolean; @@ -126,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; @@ -210,13 +245,19 @@ type BaseSelectionListProps = Partial & { shouldDelayFocus?: boolean; /** Component to display on the right side of each child */ - rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null; + rightHandSideComponent?: ((item: ListItem) => ReactElement) | ReactElement | null; /** Whether to show the loading indicator for new options */ isLoadingNewOptions?: boolean; /** 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 = { @@ -241,6 +282,9 @@ export type { CommonListItemProps, Section, 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/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 798d3be7a698..c09c7a25e375 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -4,6 +4,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchCountryOptions from '@libs/searchCountryOptions'; @@ -100,6 +101,7 @@ function StateSelectorModal({currentState, isVisible, onClose = () => {}, onStat initiallyFocusedOptionKey={currentState} shouldStopPropagation shouldUseDynamicMaxToRenderPerBatch + ListItem={RadioListItem} /> diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js index 94b91c66f154..341ea9cddae9 100644 --- a/src/components/TagPicker/index.js +++ b/src/components/TagPicker/index.js @@ -20,7 +20,7 @@ function TagPicker({selectedTag, tag, tagIndex, policyTags, policyRecentlyUsedTa const policyRecentlyUsedTagsList = lodashGet(policyRecentlyUsedTags, tag, []); const policyTagList = PolicyUtils.getTagList(policyTags, tagIndex); - const policyTagsCount = PolicyUtils.getCountOfEnabledTagsOfList(policyTagList); + const policyTagsCount = PolicyUtils.getCountOfEnabledTagsOfList(policyTagList.tags); const isTagsCountBelowThreshold = policyTagsCount < CONST.TAG_LIST_THRESHOLD; const shouldShowTextInput = !isTagsCountBelowThreshold; diff --git a/src/components/TaxPicker/index.js b/src/components/TaxPicker/index.js index f25a1b84bf64..be15cd546b36 100644 --- a/src/components/TaxPicker/index.js +++ b/src/components/TaxPicker/index.js @@ -10,14 +10,14 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import {defaultProps, propTypes} from './taxPickerPropTypes'; -function TaxPicker({selectedTaxRate, policyTaxRates, insets, onSubmit}) { +function TaxPicker({selectedTaxRate, taxRates, insets, onSubmit}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const [searchValue, setSearchValue] = useState(''); - const policyTaxRatesCount = TransactionUtils.getEnabledTaxRateCount(policyTaxRates.taxes); - const isTaxRatesCountBelowThreshold = policyTaxRatesCount < CONST.TAX_RATES_LIST_THRESHOLD; + const taxRatesCount = TransactionUtils.getEnabledTaxRateCount(taxRates.taxes); + const isTaxRatesCountBelowThreshold = taxRatesCount < CONST.TAX_RATES_LIST_THRESHOLD; const shouldShowTextInput = !isTaxRatesCountBelowThreshold; @@ -36,28 +36,9 @@ function TaxPicker({selectedTaxRate, policyTaxRates, insets, onSubmit}) { }, [selectedTaxRate]); const sections = useMemo(() => { - const {policyTaxRatesOptions} = OptionsListUtils.getFilteredOptions( - {}, - {}, - [], - searchValue, - selectedOptions, - [], - false, - false, - false, - {}, - [], - false, - {}, - [], - false, - false, - true, - policyTaxRates, - ); - return policyTaxRatesOptions; - }, [policyTaxRates, searchValue, selectedOptions]); + const {taxRatesOptions} = OptionsListUtils.getFilteredOptions({}, {}, [], searchValue, selectedOptions, [], false, false, false, {}, [], false, {}, [], false, false, true, taxRates); + return taxRatesOptions; + }, [taxRates, searchValue, selectedOptions]); const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (taxRate) => taxRate.searchText === selectedTaxRate)[0], 'keyForList'); diff --git a/src/components/TaxPicker/taxPickerPropTypes.js b/src/components/TaxPicker/taxPickerPropTypes.js index 289b4e19aaa4..06b7c00b7826 100644 --- a/src/components/TaxPicker/taxPickerPropTypes.js +++ b/src/components/TaxPicker/taxPickerPropTypes.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import taxPropTypes from '@components/taxPropTypes'; +import safeAreaInsetPropTypes from '@pages/safeAreaInsetPropTypes'; const propTypes = { /** The selected tax rate of an expense */ @@ -8,14 +9,19 @@ const propTypes = { /** Callback to fire when a tax is pressed */ onSubmit: PropTypes.func.isRequired, - /* Onyx Props */ /** Collection of tax rates attached to a policy */ - policyTaxRates: taxPropTypes, + taxRates: taxPropTypes, + + /** + * Safe area insets required for reflecting the portion of the view, + * that is not covered by navigation bars, tab bars, toolbars, and other ancestor views. + */ + insets: safeAreaInsetPropTypes.isRequired, }; const defaultProps = { selectedTaxRate: '', - policyTaxRates: {}, + taxRates: {}, }; export {propTypes, defaultProps}; 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/ValuePicker/ValueSelectorModal.tsx b/src/components/ValuePicker/ValueSelectorModal.tsx index 1e7c6088241d..fad59d4e48e4 100644 --- a/src/components/ValuePicker/ValueSelectorModal.tsx +++ b/src/components/ValuePicker/ValueSelectorModal.tsx @@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {ValueSelectorModalProps} from './types'; @@ -40,6 +41,7 @@ function ValueSelectorModal({items = [], selectedItem, label = '', isVisible, on initiallyFocusedOptionKey={selectedItem?.value} shouldStopPropagation shouldShowTooltips={shouldShowTooltips} + ListItem={RadioListItem} /> 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/VideoPlayerPreview/VideoPlayerThumbnail.js b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js index 9e6069e4d979..595442c317d5 100644 --- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js +++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js @@ -5,7 +5,11 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import Image from '@components/Image'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; import useThemeStyles from '@hooks/useThemeStyles'; +import ControlSelection from '@libs/ControlSelection'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as ReportUtils from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -35,22 +39,31 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}) { /> )} - - - - - + + {({anchor, report, action, checkIfContextMenuActive}) => ( + DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onLongPress={(event) => + showContextMenuForReport(event, anchor, (report && report.reportID) || '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report)) + } + > + + + + + )} + ); } diff --git a/src/components/WorkspaceEmptyStateSection.tsx b/src/components/WorkspaceEmptyStateSection.tsx new file mode 100644 index 000000000000..330f8e1ebbf5 --- /dev/null +++ b/src/components/WorkspaceEmptyStateSection.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import type IconAsset from '@src/types/utils/IconAsset'; +import Icon from './Icon'; +import Text from './Text'; + +type WorkspaceEmptyStateSectionProps = { + /** The text to display in the title of the section */ + title: string; + + /** The text to display in the subtitle of the section */ + subtitle?: string; + + /** The icon to display along with the title */ + icon: IconAsset; +}; + +function WorkspaceEmptyStateSection({icon, subtitle, title}: WorkspaceEmptyStateSectionProps) { + const styles = useThemeStyles(); + const {isSmallScreenWidth} = useWindowDimensions(); + + return ( + <> + + + + + + {title} + + + {!!subtitle && ( + + {subtitle} + + )} + + + + ); +} +WorkspaceEmptyStateSection.displayName = 'WorkspaceEmptyStateSection'; + +export default WorkspaceEmptyStateSection; diff --git a/src/components/__mocks__/ConfirmedRoute.tsx b/src/components/__mocks__/ConfirmedRoute.tsx new file mode 100644 index 000000000000..3c78e764ebea --- /dev/null +++ b/src/components/__mocks__/ConfirmedRoute.tsx @@ -0,0 +1,8 @@ +import {View} from 'react-native'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any +function ConfirmedRoute(props: any) { + return ; +} + +export default ConfirmedRoute; diff --git a/src/components/withKeyboardState.tsx b/src/components/withKeyboardState.tsx index 74d10945fbcb..560576fdbf5c 100755 --- a/src/components/withKeyboardState.tsx +++ b/src/components/withKeyboardState.tsx @@ -8,27 +8,34 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; type KeyboardStateContextValue = { /** Whether the keyboard is open */ isKeyboardShown: boolean; + + /** Height of the keyboard in pixels */ + keyboardHeight: number; }; // TODO: Remove - left for backwards compatibility with existing components (https://github.com/Expensify/App/issues/25151) const keyboardStatePropTypes = { /** Whether the keyboard is open */ isKeyboardShown: PropTypes.bool.isRequired, + + /** Height of the keyboard in pixels */ + keyboardHeight: PropTypes.number.isRequired, }; const KeyboardStateContext = createContext({ isKeyboardShown: false, + keyboardHeight: 0, }); function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { - const [isKeyboardShown, setIsKeyboardShown] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); useEffect(() => { - const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { - setIsKeyboardShown(true); + const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', (e) => { + setKeyboardHeight(e.endCoordinates.height); }); const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { - setIsKeyboardShown(false); + setKeyboardHeight(0); }); return () => { @@ -39,9 +46,10 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { const contextValue = useMemo( () => ({ - isKeyboardShown, + keyboardHeight, + isKeyboardShown: keyboardHeight !== 0, }), - [isKeyboardShown], + [keyboardHeight], ); return {children}; } diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx index 17fda7fd5e30..9da862ecdebe 100644 --- a/src/components/withToggleVisibilityView.tsx +++ b/src/components/withToggleVisibilityView.tsx @@ -7,7 +7,7 @@ import getComponentDisplayName from '@libs/getComponentDisplayName'; type WithToggleVisibilityViewProps = { /** Whether the content is visible. */ - isVisible?: boolean; + isVisible: boolean; }; export default function withToggleVisibilityView( diff --git a/src/hooks/useLocationBias.ts b/src/hooks/useLocationBias.ts index b95ffbb57e9d..e18aba4a907c 100644 --- a/src/hooks/useLocationBias.ts +++ b/src/hooks/useLocationBias.ts @@ -1,15 +1,18 @@ import {useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {UserLocation} from '@src/types/onyx'; +import type {WaypointCollection} from '@src/types/onyx/Transaction'; /** * Construct the rectangular boundary based on user location and waypoints */ -export default function useLocationBias(allWaypoints: Record, userLocation?: {latitude: number; longitude: number}) { +export default function useLocationBias(allWaypoints: WaypointCollection, userLocation?: OnyxEntry) { return useMemo(() => { const hasFilledWaypointCount = Object.values(allWaypoints).some((waypoint) => Object.keys(waypoint).length > 0); // If there are no filled wayPoints and if user's current location cannot be retrieved, // it is futile to arrive at a biased location. Let's return if (!hasFilledWaypointCount && userLocation === undefined) { - return null; + return undefined; } // Gather the longitudes and latitudes from filled waypoints. @@ -29,8 +32,8 @@ export default function useLocationBias(allWaypoints: Record void; }; -type UseNetwork = {isOffline?: boolean}; +type UseNetwork = {isOffline: boolean}; export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork { const callback = useRef(onReconnect); callback.current = onReconnect; - const {isOffline} = useContext(NetworkContext) ?? {}; + const {isOffline} = useContext(NetworkContext) ?? CONST.DEFAULT_NETWORK_DATA; const prevOfflineStatusRef = useRef(isOffline); useEffect(() => { // If we were offline before and now we are not offline then we just reconnected @@ -28,5 +29,5 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { prevOfflineStatusRef.current = isOffline; }, [isOffline]); - return {isOffline}; + return {isOffline: isOffline ?? false}; } diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index ea825b45bc0b..29b2dcb86718 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -58,19 +58,7 @@ function useViolations(violations: TransactionViolation[]) { } return violationGroups ?? new Map(); }, [violations]); - - const getViolationsForField = useCallback( - (field: ViolationField, data?: TransactionViolation['data']) => { - const currentViolations = violationsByField.get(field) ?? []; - - if (data?.tagName) { - return currentViolations.filter((violation) => violation.data?.tagName === data.tagName); - } - - return currentViolations; - }, - [violationsByField], - ); + const getViolationsForField = useCallback((field: ViolationField) => violationsByField.get(field) ?? [], [violationsByField]); return { getViolationsForField, diff --git a/src/languages/en.ts b/src/languages/en.ts index 0553d6470ddc..4d7041d4a791 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -121,6 +121,7 @@ export default { no: 'No', ok: 'OK', buttonConfirm: 'Got it', + name: 'Name', attachment: 'Attachment', to: 'To', optional: 'Optional', @@ -205,6 +206,7 @@ export default { iAcceptThe: 'I accept the ', remove: 'Remove', admin: 'Admin', + owner: 'Owner', dateFormat: 'YYYY-MM-DD', send: 'Send', notifications: 'Notifications', @@ -308,6 +310,8 @@ export default { of: 'of', default: 'Default', update: 'Update', + member: 'Member', + role: 'Role', }, location: { useCurrent: 'Use current location', @@ -856,7 +860,6 @@ export default { noLogsAvailable: 'No logs available', logSizeTooLarge: ({size}: LogSizeParams) => `Log size exceeds the limit of ${size} MB. Please use "Save log" to download the log file instead.`, }, - goToExpensifyClassic: 'Go to Expensify Classic', security: 'Security', signOut: 'Sign out', signOutConfirmationText: "You'll lose any offline changes if you sign-out.", @@ -1026,6 +1029,25 @@ export default { }, cardDetailsLoadingFailure: 'An error occurred while loading the card details. Please check your internet connection and try again.', }, + workflowsPage: { + workflowTitle: 'Spend', + workflowDescription: 'Configure a workflow from the moment spend occurs, including approval and payment.', + delaySubmissionTitle: 'Delay submissions', + delaySubmissionDescription: 'Expenses are shared right away for better spend visibility. Set a slower cadence if needed.', + submissionFrequency: 'Submission frequency', + weeklyFrequency: 'Weekly', + monthlyFrequency: 'Monthly', + twiceAMonthFrequency: 'Twice a month', + byTripFrequency: 'By trip', + manuallyFrequency: 'Manually', + dailyFrequency: 'Daily', + addApprovalsTitle: 'Add approvals', + approver: 'Approver', + connectBankAccount: 'Connect bank account', + addApprovalsDescription: 'Require additional approval before authorizing a payment.', + makeOrTrackPaymentsTitle: 'Make or track payments', + makeOrTrackPaymentsDescription: 'Add an authorized payer for payments made in Expensify, or simply track payments made elsewhere.', + }, reportFraudPage: { title: 'Report virtual card fraud', description: 'If your virtual card details have been stolen or compromised, we’ll permanently deactivate your existing card and provide you with a new virtual card and number.', @@ -1679,11 +1701,15 @@ export default { workspace: { common: { card: 'Cards', + workflows: 'Workflows', workspace: 'Workspace', edit: 'Edit workspace', + enabled: 'Enabled', + disabled: 'Disabled', delete: 'Delete workspace', settings: 'Settings', reimburse: 'Reimbursements', + categories: 'Categories', bills: 'Bills', invoices: 'Invoices', travel: 'Travel', @@ -1712,6 +1738,13 @@ export default { control: 'Control', collect: 'Collect', }, + categories: { + subtitle: 'Get a better overview of where money is being spent. Use our default categories or add your own.', + emptyCategories: { + title: "You haven't created any categories", + subtitle: 'Add a category to organize your spend.', + }, + }, emptyWorkspace: { title: 'Create a workspace', subtitle: 'Workspaces are where you’ll chat with your team, reimburse expenses, issue cards, send invoices, pay bills, and more - all in one place.', @@ -1745,6 +1778,7 @@ export default { }, addedWithPrimary: 'Some users were added with their primary logins.', invitedBySecondaryLogin: ({secondaryLogin}) => `Added by secondary login ${secondaryLogin}.`, + membersListTitle: 'Directory of all workspace members.', }, card: { header: 'Unlock free Expensify Cards', @@ -2344,4 +2378,28 @@ export default { mute: 'Mute', unmute: 'Unmute', }, + exitSurvey: { + header: 'Before you go', + reasonPage: { + title: "Please tell us why you're leaving", + subtitle: 'Before you go, please tell us why you’d like to switch to Expensify Classic.', + }, + reasons: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "I need a feature that's only available in Expensify Classic.", + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: "I don't understand how to use New Expensify.", + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'I understand how to use New Expensify, but I prefer Expensify Classic.', + }, + prompts: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: "What feature do you need that isn't available in New Expensify?", + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'What are you trying to do?', + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Why do you prefer Expensify Classic?', + }, + responsePlaceholder: 'Your response', + thankYou: 'Thanks for the feedback!', + thankYouSubtitle: 'Your responses will help us build a better product to get stuff done. Thank you so much!', + goToExpensifyClassic: 'Switch to Expensify Classic', + offlineTitle: "Looks like you're stuck here...", + offline: + "You appear to be offline. Unfortunately, Expensify Classic doesn't work offline, but New Expensify does. If you prefer to use Expensify Classic, try again when you have an internet connection.", + }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index 2a2eb96bd488..c9ff087d0de7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -111,6 +111,7 @@ export default { no: 'No', ok: 'OK', buttonConfirm: 'Ok, entendido', + name: 'Nombre', attachment: 'Archivo adjunto', to: 'A', optional: 'Opcional', @@ -195,6 +196,7 @@ export default { iAcceptThe: 'Acepto los ', remove: 'Eliminar', admin: 'Administrador', + owner: 'Dueño', dateFormat: 'AAAA-MM-DD', send: 'Enviar', notifications: 'Notificaciones', @@ -298,6 +300,8 @@ export default { of: 'de', default: 'Predeterminado', update: 'Actualizar', + member: 'Miembro', + role: 'Role', }, location: { useCurrent: 'Usar ubicación actual', @@ -855,7 +859,6 @@ export default { signOut: 'Desconectar', signOutConfirmationText: 'Si cierras sesión perderás los cambios hechos mientras estabas desconectado', versionLetter: 'v', - goToExpensifyClassic: 'Ir a Expensify Classic', readTheTermsAndPrivacy: { phrase1: 'Leer los', phrase2: 'Términos de Servicio', @@ -1022,6 +1025,25 @@ export default { }, cardDetailsLoadingFailure: 'Se ha producido un error al cargar los datos de la tarjeta. Comprueba tu conexión a Internet e inténtalo de nuevo.', }, + workflowsPage: { + workflowTitle: 'Gasto', + workflowDescription: 'Configure un flujo de trabajo desde el momento en que se produce el gasto, incluida la aprobación y el pago', + delaySubmissionTitle: 'Retrasar envíos', + delaySubmissionDescription: 'Los gastos se comparten de inmediato para una mejor visibilidad del gasto. Establece una cadencia más lenta si es necesario.', + submissionFrequency: 'Frecuencia de envíos', + weeklyFrequency: 'Semanal', + monthlyFrequency: 'Mensual', + twiceAMonthFrequency: 'Dos veces al mes', + byTripFrequency: 'Por viaje', + manuallyFrequency: 'Manual', + dailyFrequency: 'Diaria', + addApprovalsTitle: 'Requerir aprobaciones', + approver: 'Aprobador', + connectBankAccount: 'Conectar cuenta bancaria', + addApprovalsDescription: 'Requiere una aprobación adicional antes de autorizar un pago.', + makeOrTrackPaymentsTitle: 'Realizar o seguir pagos', + makeOrTrackPaymentsDescription: 'Añade un pagador autorizado para los pagos realizados en Expensify, o simplemente realiza un seguimiento de los pagos realizados en otro lugar.', + }, reportFraudPage: { title: 'Reportar fraude con la tarjeta virtual', description: @@ -1703,11 +1725,15 @@ export default { workspace: { common: { card: 'Tarjetas', + workflows: 'Flujos de trabajo', workspace: 'Espacio de trabajo', edit: 'Editar espacio de trabajo', + enabled: 'Activada', + disabled: 'Desactivada', delete: 'Eliminar espacio de trabajo', settings: 'Configuración', reimburse: 'Reembolsos', + categories: 'Categorías', bills: 'Pagar facturas', invoices: 'Enviar facturas', travel: 'Viajes', @@ -1736,6 +1762,13 @@ export default { control: 'Control', collect: 'Recolectar', }, + categories: { + subtitle: 'Obtén una visión general de dónde te gastas el dinero. Utiliza las categorías predeterminadas o añade las tuyas propias.', + emptyCategories: { + title: 'No has creado ninguna categoría', + subtitle: 'Añade una categoría para organizar tu gasto.', + }, + }, emptyWorkspace: { title: 'Crea un espacio de trabajo', subtitle: 'En los espacios de trabajo podrás chatear con tu equipo, reembolsar gastos, emitir tarjetas, enviar y pagar facturas, y mucho más - todo en un mismo lugar.', @@ -1769,6 +1802,7 @@ export default { }, addedWithPrimary: 'Se agregaron algunos usuarios con sus nombres de usuario principales.', invitedBySecondaryLogin: ({secondaryLogin}) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, + membersListTitle: 'Directorio de todos los miembros del espacio de trabajo.', }, card: { header: 'Desbloquea Tarjetas Expensify gratis', @@ -2836,4 +2870,28 @@ export default { mute: 'Silenciar', unmute: 'Activar sonido', }, + exitSurvey: { + header: 'Antes de irte', + reasonPage: { + title: 'Dinos por qué te vas', + subtitle: 'Antes de irte, por favor dinos por qué te gustaría cambiarte a Expensify Classic.', + }, + reasons: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: 'Necesito una función que sólo está disponible en Expensify Classic.', + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: 'No entiendo cómo usar New Expensify.', + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: 'Entiendo cómo usar New Expensify, pero prefiero Expensify Classic.', + }, + prompts: { + [CONST.EXIT_SURVEY.REASONS.FEATURE_NOT_AVAILABLE]: '¿Qué función necesitas que no esté disponible en New Expensify?', + [CONST.EXIT_SURVEY.REASONS.DONT_UNDERSTAND]: '¿Qué estás tratando de hacer?', + [CONST.EXIT_SURVEY.REASONS.PREFER_CLASSIC]: '¿Por qué prefieres Expensify Classic?', + }, + responsePlaceholder: 'Su respuesta', + thankYou: '¡Gracias por tus comentarios!', + thankYouSubtitle: 'Sus respuestas nos ayudarán a crear un mejor producto para hacer las cosas bien. ¡Muchas gracias!', + goToExpensifyClassic: 'Cambiar a Expensify Classic', + offlineTitle: 'Parece que estás atrapado aquí...', + offline: + 'Parece que estás desconectado. Desafortunadamente, Expensify Classic no funciona sin conexión, pero New Expensify sí. Si prefieres utilizar Expensify Classic, inténtalo de nuevo cuando tengas conexión a internet.', + }, } satisfies EnglishTranslation; diff --git a/src/languages/types.ts b/src/languages/types.ts index 6a67217a6129..bb56d5c38cd3 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -105,7 +105,7 @@ type SettleExpensifyCardParams = { formattedAmount: string; }; -type RequestAmountParams = {amount: number}; +type RequestAmountParams = {amount: string}; type RequestedAmountMessageParams = {formattedAmount: string; comment?: string}; @@ -157,7 +157,7 @@ type EnterMagicCodeParams = {contactMethod: string}; type TransferParams = {amount: string}; -type InstantSummaryParams = {rate: number; minAmount: number}; +type InstantSummaryParams = {rate: string; minAmount: string}; type NotYouParams = {user: string}; diff --git a/src/libs/API/parameters/CreateDistanceRequestParams.ts b/src/libs/API/parameters/CreateDistanceRequestParams.ts index c1eb1003a698..62f90a64cf05 100644 --- a/src/libs/API/parameters/CreateDistanceRequestParams.ts +++ b/src/libs/API/parameters/CreateDistanceRequestParams.ts @@ -12,6 +12,8 @@ type CreateDistanceRequestParams = { category?: string; tag?: string; billable?: boolean; + transactionThreadReportID: string; + createdReportActionIDForThread: string; }; export default CreateDistanceRequestParams; diff --git a/src/libs/API/parameters/RequestMoneyParams.ts b/src/libs/API/parameters/RequestMoneyParams.ts index 983394008ba7..b55f9fd7a2a9 100644 --- a/src/libs/API/parameters/RequestMoneyParams.ts +++ b/src/libs/API/parameters/RequestMoneyParams.ts @@ -25,6 +25,8 @@ type RequestMoneyParams = { taxAmount: number; billable?: boolean; gpsPoints?: string; + transactionThreadReportID: string; + createdReportActionIDForThread: string; }; export default RequestMoneyParams; diff --git a/src/libs/API/parameters/SendMoneyParams.ts b/src/libs/API/parameters/SendMoneyParams.ts index b737ba2ea48b..ac6f42de5aa5 100644 --- a/src/libs/API/parameters/SendMoneyParams.ts +++ b/src/libs/API/parameters/SendMoneyParams.ts @@ -9,6 +9,8 @@ type SendMoneyParams = { newIOUReportDetails: string; createdReportActionID: string; reportPreviewReportActionID: string; + transactionThreadReportID: string; + createdReportActionIDForThread: string; }; export default SendMoneyParams; diff --git a/src/libs/API/parameters/SetWorkspaceApprovalModeParams.ts b/src/libs/API/parameters/SetWorkspaceApprovalModeParams.ts new file mode 100644 index 000000000000..df84fbabbf95 --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceApprovalModeParams.ts @@ -0,0 +1,6 @@ +type SetWorkspaceApprovalModeParams = { + policyID: string; + value: string; +}; + +export default SetWorkspaceApprovalModeParams; diff --git a/src/libs/API/parameters/SetWorkspaceAutoReportingParams.ts b/src/libs/API/parameters/SetWorkspaceAutoReportingParams.ts new file mode 100644 index 000000000000..a87817986ffa --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceAutoReportingParams.ts @@ -0,0 +1,6 @@ +type SetWorkspaceAutoReportingParams = { + policyID: string; + enabled: boolean; +}; + +export default SetWorkspaceAutoReportingParams; diff --git a/src/libs/API/parameters/SwitchToOldDotParams.ts b/src/libs/API/parameters/SwitchToOldDotParams.ts new file mode 100644 index 000000000000..95449a123dc9 --- /dev/null +++ b/src/libs/API/parameters/SwitchToOldDotParams.ts @@ -0,0 +1,9 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type SwitchToOldDotParams = { + reason?: ValueOf; + surveyResponse?: string; +}; + +export default SwitchToOldDotParams; diff --git a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts index f5cc3f664d12..dedc45d0365f 100644 --- a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts @@ -1,5 +1,5 @@ -import type {BeneficialOwnersStepProps} from '@src/types/form/ReimbursementAccountForm'; +import type {ACHContractStepProps} from '@src/types/form/ReimbursementAccountForm'; -type UpdateBeneficialOwnersForBankAccountParams = BeneficialOwnersStepProps & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean}; +type UpdateBeneficialOwnersForBankAccountParams = Partial & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean}; export default UpdateBeneficialOwnersForBankAccountParams; diff --git a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts index 21ca49839aec..6421fe02f571 100644 --- a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts @@ -1,5 +1,7 @@ -import type {CompanyStepProps} from '@src/types/form/ReimbursementAccountForm'; +import type {BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps} from '@src/types/form/ReimbursementAccountForm'; -type UpdateCompanyInformationForBankAccountParams = CompanyStepProps & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean}; +type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps; + +type UpdateCompanyInformationForBankAccountParams = Partial & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean}; export default UpdateCompanyInformationForBankAccountParams; diff --git a/src/libs/API/parameters/UpdateRoomVisibilityParams.ts b/src/libs/API/parameters/UpdateRoomVisibilityParams.ts new file mode 100644 index 000000000000..a69559f0ce47 --- /dev/null +++ b/src/libs/API/parameters/UpdateRoomVisibilityParams.ts @@ -0,0 +1,8 @@ +import type {RoomVisibility} from '@src/types/onyx/Report'; + +type UpdateRoomVisibilityParams = { + reportID: string; + visibility: RoomVisibility; +}; + +export default UpdateRoomVisibilityParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 371fb8ddb404..0b0a81eb21f8 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -84,6 +84,7 @@ export type {default as DeleteCommentParams} from './DeleteCommentParams'; export type {default as UpdateCommentParams} from './UpdateCommentParams'; export type {default as UpdateReportNotificationPreferenceParams} from './UpdateReportNotificationPreferenceParams'; export type {default as UpdateRoomDescriptionParams} from './UpdateRoomDescriptionParams'; +export type {default as UpdateRoomVisibilityParams} from './UpdateRoomVisibilityParams'; export type {default as UpdateReportWriteCapabilityParams} from './UpdateReportWriteCapabilityParams'; export type {default as AddWorkspaceRoomParams} from './AddWorkspaceRoomParams'; export type {default as UpdatePolicyRoomNameParams} from './UpdatePolicyRoomNameParams'; @@ -144,3 +145,6 @@ export type {default as UnHoldMoneyRequestParams} from './UnHoldMoneyRequestPara export type {default as CancelPaymentParams} from './CancelPaymentParams'; export type {default as AcceptACHContractForBankAccount} from './AcceptACHContractForBankAccount'; export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams'; +export type {default as SetWorkspaceAutoReportingParams} from './SetWorkspaceAutoReportingParams'; +export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams'; +export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 9c0d57b1cf14..17cc366ba3b7 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -8,6 +8,8 @@ import type UpdateBeneficialOwnersForBankAccountParams from './parameters/Update type ApiRequest = ValueOf; const WRITE_COMMANDS = { + SET_WORKSPACE_AUTO_REPORTING: 'SetWorkspaceAutoReporting', + SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode', DISMISS_REFERRAL_BANNER: 'DismissReferralBanner', UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale', RECONNECT_APP: 'ReconnectApp', @@ -85,6 +87,7 @@ const WRITE_COMMANDS = { DELETE_COMMENT: 'DeleteComment', UPDATE_COMMENT: 'UpdateComment', UPDATE_REPORT_NOTIFICATION_PREFERENCE: 'UpdateReportNotificationPreference', + UPDATE_ROOM_VISIBILITY: 'UpdateRoomVisibility', UPDATE_ROOM_DESCRIPTION: 'UpdateRoomDescription', UPDATE_REPORT_WRITE_CAPABILITY: 'UpdateReportWriteCapability', ADD_WORKSPACE_ROOM: 'AddWorkspaceRoom', @@ -146,6 +149,7 @@ const WRITE_COMMANDS = { PAY_MONEY_REQUEST: 'PayMoneyRequest', CANCEL_PAYMENT: 'CancelPayment', ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT: 'AcceptACHContractForBankAccount', + SWITCH_TO_OLD_DOT: 'SwitchToOldDot', } as const; type WriteCommand = ValueOf; @@ -226,6 +230,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DELETE_COMMENT]: Parameters.DeleteCommentParams; [WRITE_COMMANDS.UPDATE_COMMENT]: Parameters.UpdateCommentParams; [WRITE_COMMANDS.UPDATE_REPORT_NOTIFICATION_PREFERENCE]: Parameters.UpdateReportNotificationPreferenceParams; + [WRITE_COMMANDS.UPDATE_ROOM_VISIBILITY]: Parameters.UpdateRoomVisibilityParams; [WRITE_COMMANDS.UPDATE_ROOM_DESCRIPTION]: Parameters.UpdateRoomDescriptionParams; [WRITE_COMMANDS.UPDATE_REPORT_WRITE_CAPABILITY]: Parameters.UpdateReportWriteCapabilityParams; [WRITE_COMMANDS.ADD_WORKSPACE_ROOM]: Parameters.AddWorkspaceRoomParams; @@ -290,6 +295,9 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CANCEL_PAYMENT]: Parameters.CancelPaymentParams; [WRITE_COMMANDS.ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT]: Parameters.AcceptACHContractForBankAccount; [WRITE_COMMANDS.UPDATE_WORKSPACE_DESCRIPTION]: Parameters.UpdateWorkspaceDescriptionParams; + [WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING]: Parameters.SetWorkspaceAutoReportingParams; + [WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams; + [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams; }; const READ_COMMANDS = { diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts index 14c880edc593..204e78aa5458 100644 --- a/src/libs/Environment/Environment.ts +++ b/src/libs/Environment/Environment.ts @@ -24,6 +24,13 @@ function isDevelopment(): boolean { return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV; } +/** + * Are we running the app in production? + */ +function isProduction(): Promise { + return getEnvironment().then((environment) => environment === CONST.ENVIRONMENT.PRODUCTION); +} + /** * Are we running an internal test build? */ @@ -47,4 +54,4 @@ function getOldDotEnvironmentURL(): Promise { return getEnvironment().then((environment) => OLDDOT_ENVIRONMENT_URLS[environment]); } -export {getEnvironment, isInternalTestBuild, isDevelopment, getEnvironmentURL, getOldDotEnvironmentURL}; +export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL}; diff --git a/src/libs/FormUtils.ts b/src/libs/FormUtils.ts deleted file mode 100644 index 4d0571ada6f2..000000000000 --- a/src/libs/FormUtils.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {OnyxFormDraftKey, OnyxFormKey} from '@src/ONYXKEYS'; - -function getDraftKey(formID: OnyxFormKey): OnyxFormDraftKey { - return `${formID}Draft`; -} - -export default {getDraftKey}; diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts index cf49ba03f287..48c537f0f14c 100644 --- a/src/libs/GetPhysicalCardUtils.ts +++ b/src/libs/GetPhysicalCardUtils.ts @@ -3,17 +3,18 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; import type {LoginList, PrivatePersonalDetails} from '@src/types/onyx'; +import * as LoginUtils from './LoginUtils'; import Navigation from './Navigation/Navigation'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import * as UserUtils from './UserUtils'; -function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry): Route { +function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route { const {address, legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {}; if (!legalFirstName && !legalLastName) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain); } - if (!phoneNumber && !UserUtils.getSecondaryPhoneLogin(loginList)) { + if (!phoneNumber || !LoginUtils.validateNumber(phoneNumber)) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain); } if (!(address?.street && address?.city && address?.state && address?.country && address?.zip)) { @@ -23,8 +24,8 @@ function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry) { - Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails, loginList)); +function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: OnyxEntry) { + Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails)); } /** @@ -35,8 +36,8 @@ function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: OnyxE * @param loginList * @returns */ -function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry) { - const expectedRoute = getCurrentRoute(domain, privatePersonalDetails, loginList); +function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: OnyxEntry) { + const expectedRoute = getCurrentRoute(domain, privatePersonalDetails); // If the user is on the current route or the current route is confirmation, then he's allowed to stay on the current step if ([currentRoute, ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain)].includes(expectedRoute)) { @@ -59,14 +60,14 @@ function getUpdatedDraftValues(draftValues: OnyxEntry, priv return { /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ // we do not need to use nullish coalescing here because we want to allow empty strings - legalFirstName: draftValues?.legalFirstName || legalFirstName, - legalLastName: draftValues?.legalLastName || legalLastName, - addressLine1: draftValues?.addressLine1 || address?.street.split('\n')[0], + legalFirstName: draftValues?.legalFirstName || legalFirstName || '', + legalLastName: draftValues?.legalLastName || legalLastName || '', + addressLine1: draftValues?.addressLine1 || address?.street.split('\n')[0] || '', addressLine2: draftValues?.addressLine2 || address?.street.split('\n')[1] || '', - city: draftValues?.city || address?.city, - country: draftValues?.country || address?.country, + city: draftValues?.city || address?.city || '', + country: draftValues?.country || address?.country || '', phoneNumber: draftValues?.phoneNumber || phoneNumber || UserUtils.getSecondaryPhoneLogin(loginList) || '', - state: draftValues?.state || address?.state, + state: draftValues?.state || address?.state || '', zipPostCode: draftValues?.zipPostCode || address?.zip || '', /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ }; diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts index 5d925ae1c684..58a82de3df53 100644 --- a/src/libs/GroupChatUtils.ts +++ b/src/libs/GroupChatUtils.ts @@ -1,5 +1,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {Report} from '@src/types/onyx'; +import localeCompare from './LocaleCompare'; import * as ReportUtils from './ReportUtils'; /** @@ -11,7 +12,7 @@ function getGroupChatName(report: OnyxEntry): string | undefined { return participants .map((participant) => ReportUtils.getDisplayNameForParticipant(participant, isMultipleParticipantReport)) - .sort((first, second) => first?.localeCompare(second ?? '') ?? 0) + .sort((first, second) => localeCompare(first ?? '', second ?? '')) .filter(Boolean) .join(', '); } diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 0bd5d3162236..56ac47676a37 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -109,18 +109,18 @@ function isValidMoneyRequestType(iouType: string): boolean { } /** - * Inserts a newly selected tag into the already existed report tags like a string + * Inserts a newly selected tag into the already existing tags like a string * - * @param reportTags - currently selected tags for a report - * @param tag - a newly selected tag, that should be added to the reportTags + * @param transactionTags - currently selected tags for a report + * @param tag - a newly selected tag, that should be added to the transactionTags * @param tagIndex - the index of a tag list * @returns */ -function insertTagIntoReportTagsString(reportTags: string, tag: string, tagIndex: number): string { - const splittedReportTags = reportTags.split(CONST.COLON); - splittedReportTags[tagIndex] = tag; +function insertTagIntoTransactionTagsString(transactionTags: string, tag: string, tagIndex: number): string { + const tagArray = TransactionUtils.getTagArrayFromName(transactionTags); + tagArray[tagIndex] = tag; - return splittedReportTags.join(CONST.COLON).replace(/:*$/, ''); + return tagArray.join(CONST.COLON).replace(/:*$/, ''); } -export {calculateAmount, updateIOUOwnerAndTotal, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType, navigateToStartMoneyRequestStep, insertTagIntoReportTagsString}; +export {calculateAmount, updateIOUOwnerAndTotal, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType, navigateToStartMoneyRequestStep, insertTagIntoTransactionTagsString}; diff --git a/src/libs/KeyboardShortcut/index.ts b/src/libs/KeyboardShortcut/index.ts index 44ba54953c40..0571f5e271ab 100644 --- a/src/libs/KeyboardShortcut/index.ts +++ b/src/libs/KeyboardShortcut/index.ts @@ -1,6 +1,7 @@ import Str from 'expensify-common/lib/str'; import * as KeyCommand from 'react-native-key-command'; import getOperatingSystem from '@libs/getOperatingSystem'; +import localeCompare from '@libs/LocaleCompare'; import CONST from '@src/CONST'; import bindHandlerToKeydownEvent from './bindHandlerToKeydownEvent'; @@ -32,7 +33,7 @@ type Shortcut = { const documentedShortcuts: Record = {}; function getDocumentedShortcuts(): Shortcut[] { - return Object.values(documentedShortcuts).sort((a, b) => a.displayName.localeCompare(b.displayName)); + return Object.values(documentedShortcuts).sort((a, b) => localeCompare(a.displayName, b.displayName)); } const keyInputEnter = KeyCommand?.constants?.keyInputEnter?.toString() ?? 'keyInputEnter'; diff --git a/src/libs/LocaleCompare.ts b/src/libs/LocaleCompare.ts index 5142c5b43d9a..b2c48b410d32 100644 --- a/src/libs/LocaleCompare.ts +++ b/src/libs/LocaleCompare.ts @@ -1,19 +1,26 @@ import Onyx from 'react-native-onyx'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -const DEFAULT_LOCALE = 'en'; - const COLLATOR_OPTIONS: Intl.CollatorOptions = {usage: 'sort', sensitivity: 'base'}; -let collator = new Intl.Collator(DEFAULT_LOCALE, COLLATOR_OPTIONS); +let collator = new Intl.Collator(CONST.LOCALES.DEFAULT, COLLATOR_OPTIONS); Onyx.connect({ key: ONYXKEYS.NVP_PREFERRED_LOCALE, callback: (locale) => { - collator = new Intl.Collator(locale ?? DEFAULT_LOCALE, COLLATOR_OPTIONS); + collator = new Intl.Collator(locale ?? CONST.LOCALES.DEFAULT, COLLATOR_OPTIONS); }, }); +/** + * This is a wrapper around the localeCompare function that uses the preferred locale from the user's settings. + * + * It re-uses Intl.Collator with static options for performance reasons. See https://github.com/facebook/hermes/issues/867 for more details. + * @param a + * @param b + * @returns -1 if a < b, 1 if a > b, 0 if a === b + */ function localeCompare(a: string, b: string) { return collator.compare(a, b); } diff --git a/src/libs/LocalePhoneNumber.ts b/src/libs/LocalePhoneNumber.ts index 9aacc6968e1e..933aa7937560 100644 --- a/src/libs/LocalePhoneNumber.ts +++ b/src/libs/LocalePhoneNumber.ts @@ -13,7 +13,7 @@ Onyx.connect({ * Returns a locally converted phone number for numbers from the same region * and an internationally converted phone number with the country code for numbers from other regions */ -function formatPhoneNumber(number: string | undefined): string { +function formatPhoneNumber(number: string): string { if (!number) { return ''; } diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 13a58834860b..f501244a725d 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -9,6 +9,7 @@ import * as Localize from './Localize'; import * as PolicyUtils from './PolicyUtils'; import * as ReportUtils from './ReportUtils'; import type {ExpenseOriginalMessage} from './ReportUtils'; +import * as TransactionUtils from './TransactionUtils'; let allPolicyTags: OnyxCollection = {}; Onyx.connect({ @@ -189,8 +190,8 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; const transactionTag = reportActionOriginalMessage?.tag ?? ''; const oldTransactionTag = reportActionOriginalMessage?.oldTag ?? ''; - const splittedTag = transactionTag.split(CONST.COLON); - const splittedOldTag = oldTransactionTag.split(CONST.COLON); + const splittedTag = TransactionUtils.getTagArrayFromName(transactionTag); + const splittedOldTag = TransactionUtils.getTagArrayFromName(oldTransactionTag); const localizedTagListName = Localize.translateLocal('common.tag'); Object.keys(policyTags).forEach((policyTagKey, index) => { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index d9835b01ceff..2be262aa5f0f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -1,8 +1,8 @@ import type {ParamListBase} from '@react-navigation/routers'; import type {StackNavigationOptions} from '@react-navigation/stack'; -import {CardStyleInterpolators, createStackNavigator} from '@react-navigation/stack'; import React, {useMemo} from 'react'; import useThemeStyles from '@hooks/useThemeStyles'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type { AddPersonalBankAccountNavigatorParamList, DetailsNavigatorParamList, @@ -35,6 +35,7 @@ import type { import type {ThemeStyles} from '@styles/index'; import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; +import subRouteOptions from './modalStackNavigatorOptions'; type Screens = Partial React.ComponentType>>; @@ -45,16 +46,15 @@ type Screens = Partial React.ComponentType>>; * @param getScreenOptions optional function that returns the screen options, override the default options */ function createModalStackNavigator(screens: Screens, getScreenOptions?: (styles: ThemeStyles) => StackNavigationOptions): React.ComponentType { - const ModalStackNavigator = createStackNavigator(); + const ModalStackNavigator = createPlatformStackNavigator(); function ModalStack() { const styles = useThemeStyles(); const defaultSubRouteOptions = useMemo( (): StackNavigationOptions => ({ + ...subRouteOptions, cardStyle: styles.navigationScreenCardStyle, - headerShown: false, - cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, }), [styles], ); @@ -100,7 +100,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, [SCREENS.MONEY_REQUEST.HOLD]: () => require('../../../pages/iou/HoldReasonPage').default as React.ComponentType, - [SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').default as React.ComponentType, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType, [SCREENS.IOU_SEND.ADD_DEBIT_CARD]: () => require('../../../pages/settings/Wallet/AddDebitCardPage').default as React.ComponentType, [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default as React.ComponentType, @@ -133,6 +132,7 @@ const ReportSettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Report/RoomNamePage').default as React.ComponentType, [SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: () => require('../../../pages/settings/Report/NotificationPreferencePage').default as React.ComponentType, [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: () => require('../../../pages/settings/Report/WriteCapabilityPage').default as React.ComponentType, + [SCREENS.REPORT_SETTINGS.VISIBILITY]: () => require('../../../pages/settings/Report/VisibilityPage').default as React.ComponentType, }); const TaskModalStackNavigator = createModalStackNavigator({ @@ -191,7 +191,6 @@ const AccountSettingsModalStackNavigator = createModalStackNavigator( [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../pages/settings/Preferences/PreferencesPage').default as React.ComponentType, [SCREENS.SETTINGS.SECURITY]: () => require('../../../pages/settings/Security/SecuritySettingsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../pages/settings/Profile/ProfilePage').default as React.ComponentType, - [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../pages/settings/Wallet/WalletPage').default as React.ComponentType, [SCREENS.SETTINGS.ABOUT]: () => require('../../../pages/settings/AboutPage/AboutPage').default as React.ComponentType, }, @@ -203,6 +202,7 @@ const WorkspaceSwitcherModalStackNavigator = createModalStackNavigator({ + [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require('../../../pages/settings/Profile/PronounsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../pages/settings/Profile/DisplayNamePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.TIMEZONE]: () => require('../../../pages/settings/Profile/TimezoneInitialPage').default as React.ComponentType, @@ -246,12 +246,16 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType, [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType, [SCREENS.WORKSPACE.DESCRIPTION]: () => require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType, + [SCREENS.WORKSPACE.SHARE]: () => require('../../../pages/workspace/WorkspaceProfileSharePage').default as React.ComponentType, [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceProfileCurrencyPage').default as React.ComponentType, [SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType, [SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType, [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType, [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: () => require('../../../pages/settings/Wallet/ReportCardLostPage').default as React.ComponentType, [SCREENS.KEYBOARD_SHORTCUTS]: () => require('../../../pages/KeyboardShortcutsPage').default as React.ComponentType, + [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyReasonPage').default as React.ComponentType, + [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyResponsePage').default as React.ComponentType, + [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyConfirmPage').default as React.ComponentType, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx index 087e963b3892..262a93da9e33 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx @@ -1,12 +1,12 @@ -import {createStackNavigator} from '@react-navigation/stack'; import React from 'react'; import useThemeStyles from '@hooks/useThemeStyles'; import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper'; import getCurrentUrl from '@libs/Navigation/currentUrl'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {CentralPaneNavigatorParamList} from '@navigation/types'; import SCREENS from '@src/SCREENS'; -const Stack = createStackNavigator(); +const Stack = createPlatformStackNavigator(); const url = getCurrentUrl(); const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined; @@ -17,11 +17,13 @@ const workspaceSettingsScreens = { [SCREENS.SETTINGS.WORKSPACES]: () => require('../../../../../pages/workspace/WorkspacesListPage').default as React.ComponentType, [SCREENS.WORKSPACE.PROFILE]: () => require('../../../../../pages/workspace/WorkspaceProfilePage').default as React.ComponentType, [SCREENS.WORKSPACE.CARD]: () => require('../../../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType, + [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default as React.ComponentType, [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType, [SCREENS.WORKSPACE.BILLS]: () => require('../../../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType, [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default as React.ComponentType, [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../../pages/workspace/travel/WorkspaceTravelPage').default as React.ComponentType, [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType, + [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../../pages/workspace/categories/WorkspaceCategoriesPage').default as React.ComponentType, } satisfies Screens; function BaseCentralPaneNavigator() { diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx new file mode 100644 index 000000000000..30651e32cbd6 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx @@ -0,0 +1,7 @@ +function Overlay() { + return null; +} + +Overlay.displayName = 'Overlay'; + +export default Overlay; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx similarity index 100% rename from src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx rename to src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index c421bdc82028..550fb947a4e6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,5 +1,4 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import {createStackNavigator} from '@react-navigation/stack'; import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; @@ -7,6 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions'; import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {AuthScreensParamList, RightModalNavigatorParamList} from '@navigation/types'; import type NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -14,7 +14,7 @@ import Overlay from './Overlay'; type RightModalNavigatorProps = StackScreenProps; -const Stack = createStackNavigator(); +const Stack = createPlatformStackNavigator(); function RightModalNavigator({navigation}: RightModalNavigatorProps) { const styles = useThemeStyles(); diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.tsx b/src/libs/Navigation/AppNavigator/PublicScreens.tsx index 6b1557994627..792a538cfd39 100644 --- a/src/libs/Navigation/AppNavigator/PublicScreens.tsx +++ b/src/libs/Navigation/AppNavigator/PublicScreens.tsx @@ -1,5 +1,5 @@ -import {createStackNavigator} from '@react-navigation/stack'; import React from 'react'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {PublicScreensParamList} from '@navigation/types'; import LogInWithShortLivedAuthTokenPage from '@pages/LogInWithShortLivedAuthTokenPage'; import AppleSignInDesktopPage from '@pages/signin/AppleSignInDesktopPage'; @@ -12,7 +12,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import defaultScreenOptions from './defaultScreenOptions'; -const RootStack = createStackNavigator(); +const RootStack = createPlatformStackNavigator(); function PublicScreens() { return ( diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts new file mode 100644 index 000000000000..17100bc71bda --- /dev/null +++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts @@ -0,0 +1,11 @@ +const defaultScreenOptions = { + contentStyle: { + overflow: 'visible', + flex: 1, + }, + headerShown: false, + animationTypeForReplace: 'push', + animation: 'slide_from_right', +}; + +export default defaultScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts new file mode 100644 index 000000000000..4015c43c679e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts @@ -0,0 +1,12 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; + +const defaultScreenOptions: StackNavigationOptions = { + cardStyle: { + overflow: 'visible', + flex: 1, + }, + headerShown: false, + animationTypeForReplace: 'push', +}; + +export default defaultScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts new file mode 100644 index 000000000000..2b062fd2f2be --- /dev/null +++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts @@ -0,0 +1,8 @@ +import type {NativeStackNavigationOptions} from '@react-navigation/native-stack'; + +const rightModalNavigatorOptions = (): NativeStackNavigationOptions => ({ + presentation: 'card', + animation: 'slide_from_right', +}); + +export default rightModalNavigatorOptions; diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts new file mode 100644 index 000000000000..935c0041b794 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts @@ -0,0 +1,20 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; +// eslint-disable-next-line no-restricted-imports +import getNavigationModalCardStyle from '@styles/utils/getNavigationModalCardStyles'; + +const rightModalNavigatorOptions = (isSmallScreenWidth: boolean): StackNavigationOptions => ({ + presentation: 'transparentModal', + + // We want pop in RHP since there are some flows that would work weird otherwise + animationTypeForReplace: 'pop', + cardStyle: { + ...getNavigationModalCardStyle(), + + // This is necessary to cover translated sidebar with overlay. + width: isSmallScreenWidth ? '100%' : '200%', + // Excess space should be on the left so we need to position from right. + right: 0, + }, +}); + +export default rightModalNavigatorOptions; diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts index c3a69bbd7ccf..5685afec5459 100644 --- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts @@ -4,6 +4,7 @@ import type {StyleUtilsType} from '@styles/utils'; import variables from '@styles/variables'; import CONFIG from '@src/CONFIG'; import createModalCardStyleInterpolator from './createModalCardStyleInterpolator'; +import getRightModalNavigatorOptions from './getRightModalNavigatorOptions'; type ScreenOptions = Record; @@ -25,23 +26,12 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr return { rightModalNavigator: { ...commonScreenOptions, + ...getRightModalNavigatorOptions(isSmallScreenWidth), cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), - presentation: 'transparentModal', - - // We want pop in RHP since there are some flows that would work weird otherwise - animationTypeForReplace: 'pop', - cardStyle: { - ...StyleUtils.getNavigationModalCardStyle(), - - // This is necessary to cover translated sidebar with overlay. - width: isSmallScreenWidth ? '100%' : '200%', - // Excess space should be on the left so we need to position from right. - right: 0, - }, }, leftModalNavigator: { ...commonScreenOptions, - cardStyleInterpolator: (props) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER), + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER), presentation: 'transparentModal', // We want pop in LHP since there are some flows that would work weird otherwise @@ -59,8 +49,8 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr homeScreen: { title: CONFIG.SITE_TITLE, ...commonScreenOptions, + // Note: The card* properties won't be applied on mobile platforms, as they use the native defaults. cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), - cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), width: isSmallScreenWidth ? '100%' : variables.sideBarWidth, @@ -73,6 +63,7 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr fullScreen: { ...commonScreenOptions, + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), @@ -87,7 +78,9 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr ...commonScreenOptions, animationEnabled: isSmallScreenWidth, cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), - + // temporary solution - better to hide a keyboard than see keyboard flickering + // see https://github.com/software-mansion/react-native-screens/issues/2021 for more details + keyboardHandlingEnabled: true, cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), paddingRight: isSmallScreenWidth ? 0 : variables.sideBarWidth, diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts new file mode 100644 index 000000000000..ca9769fa9972 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts @@ -0,0 +1,8 @@ +import type {NativeStackNavigationOptions} from '@react-navigation/native-stack'; + +const defaultSubRouteOptions: NativeStackNavigationOptions = { + headerShown: false, + animation: 'slide_from_right', +}; + +export default defaultSubRouteOptions; diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts new file mode 100644 index 000000000000..280a38b263b7 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts @@ -0,0 +1,9 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; +import {CardStyleInterpolators} from '@react-navigation/stack'; + +const defaultSubRouteOptions: StackNavigationOptions = { + headerShown: false, + cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, +}; + +export default defaultSubRouteOptions; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts new file mode 100644 index 000000000000..ef44cefc13c9 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts @@ -0,0 +1,7 @@ +import {createNativeStackNavigator} from '@react-navigation/native-stack'; + +function createPlatformStackNavigator() { + return createNativeStackNavigator(); +} + +export default createPlatformStackNavigator; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts new file mode 100644 index 000000000000..51228295572f --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts @@ -0,0 +1,5 @@ +import {createStackNavigator} from '@react-navigation/stack'; + +const createPlatformStackNavigator: typeof createStackNavigator = () => createStackNavigator(); + +export default createPlatformStackNavigator; diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts index 3a4abe225120..371ea89df2e2 100644 --- a/src/libs/Navigation/linkTo.ts +++ b/src/libs/Navigation/linkTo.ts @@ -215,7 +215,7 @@ export default function linkTo(navigation: NavigationContainerRef> = { - [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION], + [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE], [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], }; diff --git a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts index 446fb479ea09..f4316009b70b 100755 --- a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts @@ -7,11 +7,13 @@ const TAB_TO_CENTRAL_PANE_MAPPING: Record = { [SCREENS.WORKSPACE.INITIAL]: [ SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE.CARD, + SCREENS.WORKSPACE.WORKFLOWS, SCREENS.WORKSPACE.REIMBURSE, SCREENS.WORKSPACE.BILLS, SCREENS.WORKSPACE.INVOICES, SCREENS.WORKSPACE.TRAVEL, SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.CATEGORIES, ], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 74a00dec0a1f..48d649cc4dd9 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -46,6 +46,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.CARD]: { path: ROUTES.WORKSPACE_CARD.route, }, + [SCREENS.WORKSPACE.WORKFLOWS]: { + path: ROUTES.WORKSPACE_WORKFLOWS.route, + }, [SCREENS.WORKSPACE.REIMBURSE]: { path: ROUTES.WORKSPACE_REIMBURSE.route, }, @@ -61,6 +64,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.MEMBERS]: { path: ROUTES.WORKSPACE_MEMBERS.route, }, + [SCREENS.WORKSPACE.CATEGORIES]: { + path: ROUTES.WORKSPACE_CATEGORIES.route, + }, }, }, [SCREENS.NOT_FOUND]: '*', @@ -235,6 +241,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DESCRIPTION]: { path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route, }, + [SCREENS.WORKSPACE.SHARE]: { + path: ROUTES.WORKSPACE_PROFILE_SHARE.route, + }, [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, }, @@ -261,6 +270,18 @@ const config: LinkingOptions['config'] = { path: ROUTES.KEYBOARD_SHORTCUTS, }, [SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_PROFILE_NAME.route, + [SCREENS.SETTINGS.SHARE_CODE]: { + path: ROUTES.SETTINGS_SHARE_CODE, + }, + [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: { + path: ROUTES.SETTINGS_EXIT_SURVEY_REASON, + }, + [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: { + path: ROUTES.SETTINGS_EXIT_SURVEY_RESPONSE.route, + }, + [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: { + path: ROUTES.SETTINGS_EXIT_SURVEY_CONFIRM.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { @@ -289,6 +310,9 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: { path: ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.route, }, + [SCREENS.REPORT_SETTINGS.VISIBILITY]: { + path: ROUTES.REPORT_SETTINGS_VISIBILITY.route, + }, }, }, [SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: { @@ -422,7 +446,6 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route, [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route, - [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route, [SCREENS.MONEY_REQUEST.RECEIPT]: ROUTES.MONEY_REQUEST_RECEIPT.route, [SCREENS.MONEY_REQUEST.DISTANCE]: ROUTES.MONEY_REQUEST_DISTANCE.route, [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS, @@ -495,10 +518,6 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS_CENTRAL_PANE]: { screens: { - [SCREENS.SETTINGS.SHARE_CODE]: { - path: ROUTES.SETTINGS_SHARE_CODE, - exact: true, - }, [SCREENS.SETTINGS.PROFILE.ROOT]: { path: ROUTES.SETTINGS_PROFILE, exact: true, diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 8e246d82ff72..e7c5466852cf 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -70,14 +70,16 @@ function createCentralPaneNavigator(route: NavigationPartialRoute): NavigationPartialRoute { +function createFullScreenNavigator(route?: NavigationPartialRoute): NavigationPartialRoute { const routes = []; routes.push({name: SCREENS.SETTINGS.ROOT}); - routes.push({ - name: SCREENS.SETTINGS_CENTRAL_PANE, - state: getRoutesWithIndex([route]), - }); + if (route) { + routes.push({ + name: SCREENS.SETTINGS_CENTRAL_PANE, + state: getRoutesWithIndex([route]), + }); + } return { name: NAVIGATORS.FULL_SCREEN_NAVIGATOR, @@ -129,6 +131,11 @@ function getMatchingRootRouteForRHPRoute( return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: route.params}); } } + + // This screen is opened from the LHN of the FullStackNavigator, so in this case we shouldn't push any CentralPane screen + if (route.name === SCREENS.SETTINGS.SHARE_CODE) { + return createFullScreenNavigator(); + } } function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType { diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts index 55ccca73a389..02ad78a4c044 100644 --- a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts +++ b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts @@ -1,9 +1,11 @@ import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; +import type {CentralPaneName, CentralPaneNavigatorParamList, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING'; +const WORKSPACES_SCREENS = TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.WORKSPACE.INITIAL].concat(TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.ALL_SETTINGS]); + /** * @param state - react-navigation state */ @@ -31,8 +33,47 @@ const getTopMostReportIDFromRHP = (state: State): string => { return ''; }; +// Check if the given route has a policyID equal to the id provided in the function params +function hasRouteMatchingPolicyID(route: NavigationPartialRoute, policyID?: string) { + if (!route.params) { + return false; + } + + const params = `params` in route?.params ? (route.params.params as Record) : undefined; + + // If params are not defined, then we need to check if the policyID exists + if (!params) { + return !policyID; + } + + return 'policyID' in params && params.policyID === policyID; +} + +// Get already opened settings screen within the policy +function getAlreadyOpenedSettingsScreen(rootState?: State, policyID?: string): keyof CentralPaneNavigatorParamList | undefined { + if (!rootState) { + return undefined; + } + + // If one of the screen from WORKSPACES_SCREENS is now in the navigation state, we can decide which screen we should display. + // A screen from the navigation state can be pushed to the navigation state again only if it has a matching policyID with the currently selected workspace. + // Otherwise, when we switch the workspace, we want to display the initial screen in the settings tab. + const alreadyOpenedSettingsTab = rootState.routes + .filter((item) => item.params && 'screen' in item.params && WORKSPACES_SCREENS.includes(item.params.screen as keyof CentralPaneNavigatorParamList)) + .at(-1); + + if (!hasRouteMatchingPolicyID(alreadyOpenedSettingsTab as NavigationPartialRoute, policyID)) { + return undefined; + } + + const settingsScreen = + alreadyOpenedSettingsTab?.params && 'screen' in alreadyOpenedSettingsTab?.params ? (alreadyOpenedSettingsTab?.params?.screen as keyof CentralPaneNavigatorParamList) : undefined; + + return settingsScreen; +} + // Get matching central pane route for bottom tab navigator. e.g HOME -> REPORT -function getMatchingCentralPaneRouteForState(state: State): NavigationPartialRoute | undefined { +function getMatchingCentralPaneRouteForState(state: State, rootState?: State): NavigationPartialRoute | undefined { const topmostBottomTabRoute = getTopmostBottomTabRoute(state); if (!topmostBottomTabRoute) { @@ -42,7 +83,10 @@ function getMatchingCentralPaneRouteForState(state: State): const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0]; if (topmostBottomTabRoute.name === SCREENS.WORKSPACE.INITIAL) { - return {name: centralPaneName, params: topmostBottomTabRoute.params}; + // When we go back to the settings tab without switching the workspace id, we want to return to the previously opened screen + const policyID = topmostBottomTabRoute?.params && 'policyID' in topmostBottomTabRoute?.params ? (topmostBottomTabRoute.params.policyID as string) : undefined; + const screen = getAlreadyOpenedSettingsScreen(rootState, policyID) ?? centralPaneName; + return {name: screen, params: topmostBottomTabRoute.params}; } if (topmostBottomTabRoute.name === SCREENS.HOME) { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 04bc25736887..765ab76fd638 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -15,6 +15,7 @@ import type CONST from '@src/CONST'; import type NAVIGATORS from '@src/NAVIGATORS'; import type {HybridAppRoute, Route as Routes} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type EXIT_SURVEY_REASON_FORM_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm'; type NavigationRef = NavigationContainerRefWithCurrent; @@ -59,6 +60,9 @@ type CentralPaneNavigatorParamList = { [SCREENS.WORKSPACE.CARD]: { policyID: string; }; + [SCREENS.WORKSPACE.WORKFLOWS]: { + policyID: string; + }; [SCREENS.WORKSPACE.REIMBURSE]: { policyID: string; }; @@ -74,6 +78,9 @@ type CentralPaneNavigatorParamList = { [SCREENS.WORKSPACE.MEMBERS]: { policyID: string; }; + [SCREENS.WORKSPACE.CATEGORIES]: { + policyID: string; + }; }; type WorkspaceSwitcherNavigatorParamList = { @@ -92,9 +99,15 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: undefined; [SCREENS.SETTINGS.PROFILE.ADDRESS]: undefined; [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: undefined; - [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: undefined; - [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: undefined; - [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: undefined; + [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: { + backTo: Routes; + }; + [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: { + contactMethod: string; + }; + [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: { + backTo: Routes; + }; [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined; [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined; @@ -146,6 +159,7 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.CURRENCY]: undefined; [SCREENS.WORKSPACE.NAME]: undefined; [SCREENS.WORKSPACE.DESCRIPTION]: undefined; + [SCREENS.WORKSPACE.SHARE]: undefined; [SCREENS.WORKSPACE.RATE_AND_UNIT]: { policyID: string; }; @@ -167,6 +181,14 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: undefined; [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: undefined; [SCREENS.KEYBOARD_SHORTCUTS]: undefined; + [SCREENS.SETTINGS.EXIT_SURVEY.REASON]: undefined; + [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: { + [EXIT_SURVEY_REASON_FORM_INPUT_IDS.REASON]: ValueOf; + backTo: Routes; + }; + [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: { + backTo: Routes; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { @@ -203,6 +225,9 @@ type ReportSettingsNavigatorParamList = { [SCREENS.REPORT_SETTINGS.ROOM_NAME]: undefined; [SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: undefined; [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: undefined; + [SCREENS.REPORT_SETTINGS.VISIBILITY]: { + reportID: string; + }; }; type ReportDescriptionNavigatorParamList = { @@ -254,9 +279,12 @@ type MoneyRequestNavigatorParamList = { reportID: string; backTo: string; }; - [SCREENS.MONEY_REQUEST.CATEGORY]: { - iouType: string; + [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: { + action: ValueOf; + iouType: ValueOf; + transactionID: string; reportID: string; + backTo: string; }; [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: { iouType: string; @@ -277,6 +305,13 @@ type MoneyRequestNavigatorParamList = { reportID: string; backTo: string; }; + [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: { + iouType: ValueOf; + reportID: string; + backTo: Routes | undefined; + action: ValueOf; + pageIndex: string; + }; [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: { action: ValueOf; iouType: ValueOf; @@ -416,6 +451,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.NEW_CHAT]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.PROFILE]: NavigatorScreenParams; + [SCREENS.SETTINGS.SHARE_CODE]: undefined; [SCREENS.RIGHT_MODAL.REPORT_DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: NavigatorScreenParams; @@ -440,7 +476,6 @@ type RightModalNavigatorParamList = { }; type SettingsCentralPaneNavigatorParamList = { - [SCREENS.SETTINGS.SHARE_CODE]: undefined; [SCREENS.SETTINGS.PROFILE.ROOT]: undefined; [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; [SCREENS.SETTINGS.SECURITY]: undefined; @@ -467,6 +502,7 @@ type PublicScreensParamList = { shortLivedAuthToken?: string; shortLivedToken?: string; exitTo?: Routes | HybridAppRoute; + domain?: Routes; }; [SCREENS.VALIDATE_LOGIN]: { accountID: string; diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 8da35232112d..f03c34b1696e 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -73,7 +73,7 @@ function buildNextStep( const {policyID = '', ownerAccountID = -1, managerID = -1} = report; const policy = ReportUtils.getPolicy(policyID); - const {submitsTo, harvesting, isPreventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy; + const {submitsTo, harvesting, isPreventSelfApprovalEnabled, preventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy; const isOwner = currentUserAccountID === ownerAccountID; const isManager = currentUserAccountID === managerID; const isSelfApproval = currentUserAccountID === submitsTo; @@ -164,7 +164,7 @@ function buildNextStep( } // Prevented self submitting - if (isPreventSelfApprovalEnabled && isSelfApproval) { + if ((isPreventSelfApprovalEnabled ?? preventSelfApprovalEnabled) && isSelfApproval) { optimisticNextStep.message = [ { text: "Oops! Looks like you're submitting to ", diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts index b9814487bf3f..62d6fa00906a 100644 --- a/src/libs/NumberUtils.ts +++ b/src/libs/NumberUtils.ts @@ -69,9 +69,15 @@ function parseFloatAnyLocale(value: string): number { return parseFloat(value ? value.replace(',', '.') : value); } +/** + * Given an input number p and another number q, returns the largest number that's less than p and divisible by q. + */ +function roundDownToLargestMultiple(p: number, q: number) { + return Math.floor(p / q) * q; +} + /** * Rounds a number to two decimal places. - * @param value the value to round * @returns the rounded value */ function roundToTwoDecimalPlaces(value: number): number { @@ -80,14 +86,10 @@ function roundToTwoDecimalPlaces(value: number): number { /** * Clamps a value between a minimum and maximum value. - * - * @param value the value to clamp - * @param min the minimum value - * @param max the maximum value * @returns the clamped value */ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundToTwoDecimalPlaces, clamp}; +export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple, roundToTwoDecimalPlaces, clamp}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 80081061f340..3d11795f5452 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -22,18 +22,21 @@ import type { Report, ReportAction, ReportActions, + TaxRate, + TaxRates, + TaxRatesWithDefault, Transaction, TransactionViolation, } from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; -import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import times from '@src/utils/times'; import Timing from './actions/Timing'; import * as CollectionUtils from './CollectionUtils'; import * as ErrorUtils from './ErrorUtils'; +import localeCompare from './LocaleCompare'; import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; import * as LoginUtils from './LoginUtils'; @@ -111,8 +114,8 @@ type GetOptionsConfig = { recentlyUsedTags?: string[]; canInviteUser?: boolean; includeSelectedOptions?: boolean; - includePolicyTaxRates?: boolean; - policyTaxRates?: PolicyTaxRateWithDefault; + includeTaxRates?: boolean; + taxRates?: TaxRatesWithDefault; transactionViolations?: OnyxCollection; }; @@ -141,7 +144,7 @@ type GetOptions = { currentUserOption: ReportUtils.OptionData | null | undefined; categoryOptions: CategorySection[]; tagOptions: CategorySection[]; - policyTaxRatesOptions: CategorySection[]; + taxRatesOptions: CategorySection[]; }; type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean}; @@ -871,9 +874,9 @@ function sortTags(tags: Record | Tag[]) { let sortedTags; if (Array.isArray(tags)) { - sortedTags = tags.sort((a, b) => a.name.localeCompare(b.name)); + sortedTags = tags.sort((a, b) => localeCompare(a.name, b.name)); } else { - sortedTags = Object.values(tags).sort((a, b) => a.name.localeCompare(b.name)); + sortedTags = Object.values(tags).sort((a, b) => localeCompare(a.name, b.name)); } return sortedTags; @@ -1061,7 +1064,8 @@ function getTagsOptions(tags: Category[]): Option[] { function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOptions: Category[], searchInputValue: string, maxRecentReportsToShow: number) { const tagSections = []; const sortedTags = sortTags(tags); - const enabledTags = sortedTags.filter((tag) => tag.enabled); + const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); + const enabledTags = [...selectedOptions, ...sortedTags.filter((tag) => tag.enabled && !selectedOptionNames.includes(tag.name))]; const numberOfTags = enabledTags.length; let indexOffset = 0; @@ -1109,7 +1113,6 @@ function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOpt return tagSections; } - const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); const filteredRecentlyUsedTags = recentlyUsedTags .filter((recentlyUsedTag) => { const tagObject = tags.find((tag) => tag.name === recentlyUsedTag); @@ -1119,13 +1122,11 @@ function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOpt const filteredTags = enabledTags.filter((tag) => !selectedOptionNames.includes(tag.name)); if (selectedOptions.length) { - const selectedTagOptions = selectedOptions.map((option) => { - const tagObject = tags.find((tag) => tag.name === option.name); - return { - name: option.name, - enabled: !!tagObject?.enabled, - }; - }); + const selectedTagOptions = selectedOptions.map((option) => ({ + name: option.name, + // Should be marked as enabled to be able to unselect even though the selected category is disabled + enabled: true, + })); tagSections.push({ // "Selected" section @@ -1172,31 +1173,23 @@ function hasEnabledTags(policyTagList: Array return hasEnabledOptions(policyTagValueList); } -type PolicyTaxRateWithDefault = { - name: string; - defaultExternalID: string; - defaultValue: string; - foreignTaxDefault: string; - taxes: PolicyTaxRates; -}; - /** * Transforms tax rates to a new object format - to add codes and new name with concatenated name and value. * - * @param policyTaxRates - The original tax rates object. + * @param taxRates - The original tax rates object. * @returns The transformed tax rates object.g */ -function transformedTaxRates(policyTaxRates: PolicyTaxRateWithDefault | undefined): Record { - const defaultTaxKey = policyTaxRates?.defaultExternalID; - const getModifiedName = (data: PolicyTaxRate, code: string) => `${data.name} (${data.value})${defaultTaxKey === code ? ` • ${Localize.translateLocal('common.default')}` : ''}`; - const taxes = Object.fromEntries(Object.entries(policyTaxRates?.taxes ?? {}).map(([code, data]) => [code, {...data, code, modifiedName: getModifiedName(data, code), name: data.name}])); +function transformedTaxRates(taxRates: TaxRatesWithDefault | undefined): Record { + const defaultTaxKey = taxRates?.defaultExternalID; + const getModifiedName = (data: TaxRate, code: string) => `${data.name} (${data.value})${defaultTaxKey === code ? ` • ${Localize.translateLocal('common.default')}` : ''}`; + const taxes = Object.fromEntries(Object.entries(taxRates?.taxes ?? {}).map(([code, data]) => [code, {...data, code, modifiedName: getModifiedName(data, code), name: data.name}])); return taxes; } /** * Sorts tax rates alphabetically by name. */ -function sortTaxRates(taxRates: PolicyTaxRates): PolicyTaxRate[] { +function sortTaxRates(taxRates: TaxRates): TaxRate[] { const sortedtaxRates = lodashSortBy(taxRates, (taxRate) => taxRate.name); return sortedtaxRates; } @@ -1204,7 +1197,7 @@ function sortTaxRates(taxRates: PolicyTaxRates): PolicyTaxRate[] { /** * Builds the options for taxRates */ -function getTaxRatesOptions(taxRates: Array>): Option[] { +function getTaxRatesOptions(taxRates: Array>): Option[] { return taxRates.map((taxRate) => ({ text: taxRate.modifiedName, keyForList: taxRate.code, @@ -1218,10 +1211,10 @@ function getTaxRatesOptions(taxRates: Array>): Option[] { /** * Builds the section list for tax rates */ -function getTaxRatesSection(policyTaxRates: PolicyTaxRateWithDefault | undefined, selectedOptions: Category[], searchInputValue: string): CategorySection[] { +function getTaxRatesSection(taxRates: TaxRatesWithDefault | undefined, selectedOptions: Category[], searchInputValue: string): CategorySection[] { const policyRatesSections = []; - const taxes = transformedTaxRates(policyTaxRates); + const taxes = transformedTaxRates(taxRates); const sortedTaxRates = sortTaxRates(taxes); const enabledTaxRates = sortedTaxRates.filter((taxRate) => !taxRate.isDisabled); @@ -1360,8 +1353,8 @@ function getOptions( canInviteUser = true, includeSelectedOptions = false, transactionViolations = {}, - includePolicyTaxRates, - policyTaxRates, + includeTaxRates, + taxRates, }: GetOptionsConfig, ): GetOptions { if (includeCategories) { @@ -1374,7 +1367,7 @@ function getOptions( currentUserOption: null, categoryOptions, tagOptions: [], - policyTaxRatesOptions: [], + taxRatesOptions: [], }; } @@ -1388,12 +1381,12 @@ function getOptions( currentUserOption: null, categoryOptions: [], tagOptions, - policyTaxRatesOptions: [], + taxRatesOptions: [], }; } - if (includePolicyTaxRates) { - const policyTaxRatesOptions = getTaxRatesSection(policyTaxRates, selectedOptions as Category[], searchInputValue); + if (includeTaxRates) { + const taxRatesOptions = getTaxRatesSection(taxRates, selectedOptions as Category[], searchInputValue); return { recentReports: [], @@ -1402,7 +1395,7 @@ function getOptions( currentUserOption: null, categoryOptions: [], tagOptions: [], - policyTaxRatesOptions, + taxRatesOptions, }; } @@ -1414,7 +1407,7 @@ function getOptions( currentUserOption: null, categoryOptions: [], tagOptions: [], - policyTaxRatesOptions: [], + taxRatesOptions: [], }; } @@ -1494,6 +1487,10 @@ function getOptions( return; } + if (!accountIDs || accountIDs.length === 0) { + return; + } + // Save the report in the map if this is a single participant so we can associate the reportID with the // personal detail option later. Individuals should not be associated with single participant // policyExpenseChats or chatRooms since those are not people. @@ -1699,7 +1696,7 @@ function getOptions( currentUserOption, categoryOptions: [], tagOptions: [], - policyTaxRatesOptions: [], + taxRatesOptions: [], }; } @@ -1796,8 +1793,8 @@ function getFilteredOptions( recentlyUsedTags: string[] = [], canInviteUser = true, includeSelectedOptions = false, - includePolicyTaxRates = false, - policyTaxRates: PolicyTaxRateWithDefault = {} as PolicyTaxRateWithDefault, + includeTaxRates = false, + taxRates: TaxRatesWithDefault = {} as TaxRatesWithDefault, ) { return getOptions(reports, personalDetails, { betas, @@ -1817,8 +1814,8 @@ function getFilteredOptions( recentlyUsedTags, canInviteUser, includeSelectedOptions, - includePolicyTaxRates, - policyTaxRates, + includeTaxRates, + taxRates, }); } diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index a85e97a4cf05..70f87a8c7373 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -3,9 +3,11 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {PersonalDetailsList, Policy, PolicyMembers, PolicyTagList, PolicyTags} from '@src/types/onyx'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import Navigation from './Navigation/Navigation'; type MemberEmailsToAccountIDs = Record; type UnitRate = {rate: number}; @@ -93,7 +95,7 @@ function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean): boolea ); } -function isExpensifyTeam(email: string): boolean { +function isExpensifyTeam(email: string | undefined): boolean { const emailDomain = Str.extractEmailDomain(email ?? ''); return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN; } @@ -250,6 +252,13 @@ function getPolicyMembersByIdWithoutCurrentUser(policyMembers: OnyxCollection): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; } -function isDeletedAction(reportAction: OnyxEntry): boolean { +function isDeletedAction(reportAction: OnyxEntry): boolean { // A deleted comment has either an empty array or an object with html field with empty string as value const message = reportAction?.message ?? []; return message.length === 0 || message[0]?.html === ''; @@ -103,8 +104,8 @@ function isDeletedParentAction(reportAction: OnyxEntry): boolean { return (reportAction?.message?.[0]?.isDeletedParentAction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0; } -function isReversedTransaction(reportAction: OnyxEntry) { - return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0; +function isReversedTransaction(reportAction: OnyxEntry) { + return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && ((reportAction as ReportAction)?.childVisibleActionCount ?? 0) > 0; } function isPendingRemove(reportAction: OnyxEntry | EmptyObject): boolean { @@ -184,9 +185,11 @@ function getParentReportAction(report: OnyxEntry | EmptyObject): ReportA /** * Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object. */ -function isSentMoneyReportAction(reportAction: OnyxEntry): boolean { +function isSentMoneyReportAction(reportAction: OnyxEntry): boolean { return ( - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction?.originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!reportAction?.originalMessage?.IOUDetails + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && + (reportAction?.originalMessage as IOUMessage)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && + !!(reportAction?.originalMessage as IOUMessage)?.IOUDetails ); } @@ -517,7 +520,7 @@ function filterOutDeprecatedReportActions(reportActions: ReportActions | null): * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp). * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY. */ -function getSortedReportActionsForDisplay(reportActions: ReportActions | null, shouldMarkTheFirstItemAsNewest = false): ReportAction[] { +function getSortedReportActionsForDisplay(reportActions: ReportActions | ReportAction[] | null, shouldMarkTheFirstItemAsNewest = false): ReportAction[] { const filteredReportActions = Object.entries(reportActions ?? {}) .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key)) .map((entry) => entry[1]); @@ -700,8 +703,8 @@ function getAllReportActions(reportID: string): ReportActions { function isReportActionAttachment(reportAction: OnyxEntry): boolean { const message = reportAction?.message?.[0]; - if (reportAction && 'isAttachment' in reportAction) { - return reportAction.isAttachment ?? false; + if (reportAction && ('isAttachment' in reportAction || 'attachmentInfo' in reportAction)) { + return reportAction?.isAttachment ?? !!reportAction?.attachmentInfo ?? false; } if (message) { @@ -800,14 +803,6 @@ function getMemberChangeMessageFragment(reportAction: OnyxEntry): }; } -/** - * MARKEDREIMBURSED reportActions come from marking a report as reimbursed in OldDot. For now, we just - * concat all of the text elements of the message to create the full message. - */ -function getMarkedReimbursedMessage(reportAction: OnyxEntry): string { - return reportAction?.message?.map((element) => element.text).join('') ?? ''; -} - function getMemberChangeMessagePlainText(reportAction: OnyxEntry): string { const messageElements = getMemberChangeMessageElements(reportAction); return messageElements.map((element) => element.content).join(''); @@ -935,7 +930,6 @@ export { hasRequestFromCurrentAccount, getFirstVisibleReportActionID, isMemberChangeAction, - getMarkedReimbursedMessage, getMemberChangeMessageFragment, getMemberChangeMessagePlainText, isReimbursementDeQueuedAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 05e2db66d629..8813501e2b3f 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -52,6 +52,7 @@ import * as CollectionUtils from './CollectionUtils'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import isReportMessageAttachment from './isReportMessageAttachment'; +import localeCompare from './LocaleCompare'; import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; import linkingConfig from './Navigation/linkingConfig'; @@ -722,12 +723,10 @@ function hasParticipantInArray(report: Report, policyMemberAccountIDs: number[]) /** * Whether the Money Request report is settled */ -function isSettled(reportOrID: Report | OnyxEntry | string | undefined): boolean { - if (!allReports || !reportOrID) { +function isSettled(reportID: string | undefined): boolean { + if (!allReports || !reportID) { return false; } - const reportID = typeof reportOrID === 'string' ? reportOrID : reportOrID?.reportID; - const report: Report | EmptyObject = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? {}; if (isEmptyObject(report) || report.isWaitingOnBankAccount) { return false; @@ -1443,7 +1442,7 @@ function getIconsForParticipants(participants: number[], personalDetails: OnyxCo const sortedParticipantDetails = participantDetails.sort((first, second) => { // First sort by displayName/login - const displayNameLoginOrder = first[1].localeCompare(second[1]); + const displayNameLoginOrder = localeCompare(first[1], second[1]); if (displayNameLoginOrder !== 0) { return displayNameLoginOrder; } @@ -1618,22 +1617,6 @@ function getPersonalDetailsForAccountID(accountID: number): Partial { // First sort by displayName/login - const displayNameLoginOrder = first.displayName.localeCompare(second.displayName); + const displayNameLoginOrder = localeCompare(first.displayName, second.displayName); if (displayNameLoginOrder !== 0) { return displayNameLoginOrder; } @@ -2216,7 +2199,7 @@ function canEditReportAction(reportAction: OnyxEntry): boolean { reportAction?.actorAccountID === currentUserAccountID && isCommentOrIOU && canEditMoneyRequest(reportAction) && // Returns true for non-IOU actions - !isReportMessageAttachment(reportAction?.message?.[0] ?? {type: '', text: ''}) && + !ReportActionsUtils.isReportActionAttachment(reportAction) && !ReportActionsUtils.isDeletedAction(reportAction) && !ReportActionsUtils.isCreatedTaskReportAction(reportAction) && reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, @@ -2259,7 +2242,7 @@ function hasMissingSmartscanFields(iouReportID: string): boolean { /** * Given a parent IOU report action get report name for the LHN. */ -function getTransactionReportName(reportAction: OnyxEntry): string { +function getTransactionReportName(reportAction: OnyxEntry): string { if (ReportActionsUtils.isReversedTransaction(reportAction)) { return Localize.translateLocal('parentReportAction.reversedTransaction'); } @@ -2370,7 +2353,9 @@ function getReportPreviewMessage( if (isSettled(report.reportID) || (report.isWaitingOnBankAccount && isPreviewMessageForParentChatReport)) { // A settled report preview message can come in three formats "paid ... elsewhere" or "paid ... with Expensify" let translatePhraseKey: TranslationPaths = 'iou.paidElsewhereWithAmount'; - if ( + if (isPreviewMessageForParentChatReport) { + translatePhraseKey = 'iou.payerPaidAmount'; + } else if ( [CONST.IOU.PAYMENT_TYPE.VBBA, CONST.IOU.PAYMENT_TYPE.EXPENSIFY].some((paymentType) => paymentType === originalMessage?.paymentType) || !!reportActionMessage.match(/ (with Expensify|using Expensify)$/) || report.isWaitingOnBankAccount @@ -3761,7 +3746,7 @@ function buildOptimisticTaskReport( * * @param moneyRequestReportID - the reportID which the report action belong to */ -function buildTransactionThread(reportAction: OnyxEntry, moneyRequestReportID: string): OptimisticChatReport { +function buildTransactionThread(reportAction: OnyxEntry, moneyRequestReportID: string): OptimisticChatReport { const participantAccountIDs = [...new Set([currentUserAccountID, Number(reportAction?.actorAccountID)])].filter(Boolean) as number[]; return buildOptimisticChatReport( participantAccountIDs, @@ -3965,6 +3950,13 @@ function shouldReportBeInOptionList({ return true; } + const reportIsSettled = report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; + + // Always show IOU reports with violations unless they are reimbursed + if (isExpenseRequest(report) && doesReportHaveViolations && !reportIsSettled) { + return true; + } + // Hide only chat threads that haven't been commented on (other threads are actionable) if (isChatThread(report) && canHideReport && isEmptyChat) { return false; @@ -3976,11 +3968,6 @@ function shouldReportBeInOptionList({ return true; } - // Always show IOU reports with violations - if (isExpenseRequest(report) && doesReportHaveViolations) { - return true; - } - // All unread chats (even archived ones) in GSD mode will be shown. This is because GSD mode is specifically for focusing the user on the most relevant chats, primarily, the unread ones if (isInGSDMode) { return isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; @@ -4525,6 +4512,13 @@ function canEditWriteCapability(report: OnyxEntry, policy: OnyxEntry, policy: OnyxEntry): boolean { + return PolicyUtils.isPolicyAdmin(policy) && !isArchivedRoom(report); +} + /** * Returns the onyx data needed for the task assignee chat */ @@ -4704,7 +4698,6 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry) // property. If it does, it indicates that this is a 'Send money' action. const {amount, currency} = originalMessage.IOUDetails ?? originalMessage; const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(amount), currency) ?? ''; - const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport?.managerID, true); switch (originalMessage.paymentType) { case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: @@ -4718,7 +4711,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry) translationKey = 'iou.payerPaidAmount'; break; } - return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: payerName ?? ''}); + return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: ''}); } const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID ?? ''); @@ -5215,6 +5208,7 @@ export { getAvailableReportFields, reportFieldsEnabled, getAllAncestorReportActionIDs, + canEditRoomVisibility, canEditPolicyDescription, getPolicyDescriptionText, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 49436576295c..d9298817f6b7 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -242,7 +242,9 @@ function getOptionData({ result.policyID = report.policyID; result.stateNum = report.stateNum; result.statusNum = report.statusNum; - result.isUnread = ReportUtils.isUnread(report); + // When the only message of a report is deleted lastVisibileActionCreated is not reset leading to wrongly + // setting it Unread so we add additional condition here to avoid empty chat LHN from being bold. + result.isUnread = ReportUtils.isUnread(report) && !!report.lastActorAccountID; result.isUnreadWithMention = ReportUtils.isUnreadWithMention(report); result.hasDraftComment = report.hasDraft; result.isPinned = report.isPinned; diff --git a/src/libs/Sound/playSoundExcludingMobile/index.native.ts b/src/libs/Sound/playSoundExcludingMobile/index.native.ts new file mode 100644 index 000000000000..c41ad6998483 --- /dev/null +++ b/src/libs/Sound/playSoundExcludingMobile/index.native.ts @@ -0,0 +1,2 @@ +// mobile platform plays a sound when notification is delivered (in native code) +export default function playSoundExcludingMobile() {} diff --git a/src/libs/Sound/playSoundExcludingMobile/index.ts b/src/libs/Sound/playSoundExcludingMobile/index.ts new file mode 100644 index 000000000000..03c5cd57a635 --- /dev/null +++ b/src/libs/Sound/playSoundExcludingMobile/index.ts @@ -0,0 +1,5 @@ +import playSound from '..'; + +const playSoundExcludingMobile: typeof playSound = (sound) => playSound(sound); + +export default playSoundExcludingMobile; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index d3eafc6554db..3489053951b6 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -4,13 +4,15 @@ import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {RecentWaypoint, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; -import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates'; -import type {Comment, Receipt, TransactionChanges, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; +import type {RecentWaypoint, Report, ReportAction, TaxRate, TaxRates, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {IOUMessage} from '@src/types/onyx/OriginalMessage'; +import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {isCorporateCard, isExpensifyCard} from './CardUtils'; import DateUtils from './DateUtils'; import * as NumberUtils from './NumberUtils'; +import type {OptimisticIOUReportAction} from './ReportUtils'; let allTransactions: OnyxCollection = {}; @@ -94,6 +96,7 @@ function buildOptimisticTransaction( category = '', tag = '', billable = false, + pendingFields: Partial<{[K in TransactionPendingFieldsKey]: ValueOf}> | undefined = undefined, ): Transaction { // transactionIDs are random, positive, 64-bit numeric strings. // Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID) @@ -108,6 +111,7 @@ function buildOptimisticTransaction( } return { + ...(!isEmptyObject(pendingFields) ? {pendingFields} : {}), transactionID, amount, currency, @@ -364,18 +368,50 @@ function getBillable(transaction: OnyxEntry): boolean { return transaction?.billable ?? false; } +/** + * Return a colon-delimited tag string as an array, considering escaped colons and double backslashes. + */ +function getTagArrayFromName(tagName: string): string[] { + // WAIT!!!!!!!!!!!!!!!!!! + // You need to keep this in sync with TransactionUtils.php + + // We need to be able to preserve double backslashes in the original string + // and not have it interfere with splitting on a colon (:). + // So, let's replace it with something absurd to begin with, do our split, and + // then replace the double backslashes in the end. + const tagWithoutDoubleSlashes = tagName.replace(/\\\\/g, '☠'); + const tagWithoutEscapedColons = tagWithoutDoubleSlashes.replace(/\\:/g, '☢'); + + // Do our split + const matches = tagWithoutEscapedColons.split(':'); + const newMatches: string[] = []; + + for (const item of matches) { + const tagWithEscapedColons = item.replace(/☢/g, '\\:'); + const tagWithDoubleSlashes = tagWithEscapedColons.replace(/☠/g, '\\\\'); + newMatches.push(tagWithDoubleSlashes); + } + + return newMatches; +} + /** * Return the tag from the transaction. When the tagIndex is passed, return the tag based on the index. * This "tag" field has no "modified" complement. */ function getTag(transaction: OnyxEntry, tagIndex?: number): string { if (tagIndex !== undefined) { - return transaction?.tag?.split(CONST.COLON)[tagIndex] ?? ''; + const tagsArray = getTagArrayFromName(transaction?.tag ?? ''); + return tagsArray[tagIndex] ?? ''; } return transaction?.tag ?? ''; } +function getTagForDisplay(transaction: OnyxEntry, tagIndex?: number): string { + return getTag(transaction, tagIndex).replace(/[\\\\]:/g, ':'); +} + /** * Return the created field from the transaction, return the modifiedCreated if present. */ @@ -460,11 +496,11 @@ function hasRoute(transaction: Transaction): boolean { * * @deprecated Use Onyx.connect() or withOnyx() instead */ -function getLinkedTransaction(reportAction: OnyxEntry): Transaction | EmptyObject { +function getLinkedTransaction(reportAction: OnyxEntry): Transaction | EmptyObject { let transactionID = ''; if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) { - transactionID = reportAction.originalMessage?.IOUTransactionID ?? ''; + transactionID = (reportAction?.originalMessage as IOUMessage)?.IOUTransactionID ?? ''; } return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {}; @@ -550,20 +586,11 @@ function isOnHold(transaction: OnyxEntry): boolean { /** * Checks if any violations for the provided transaction are of type 'violation' */ -function hasViolation(transactionOrID: Transaction | OnyxEntry | string, transactionViolations: OnyxCollection): boolean { - if (!transactionOrID) { - return false; - } - const transactionID = typeof transactionOrID === 'string' ? transactionOrID : transactionOrID.transactionID; +function hasViolation(transactionID: string, transactionViolations: OnyxCollection): boolean { return Boolean(transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === 'violation')); } -function getTransactionViolations(transactionOrID: OnyxEntry | string, transactionViolations: OnyxCollection): TransactionViolation[] | null { - if (!transactionOrID) { - return null; - } - const transactionID = typeof transactionOrID === 'string' ? transactionOrID : transactionOrID.transactionID; - +function getTransactionViolations(transactionID: string, transactionViolations: OnyxCollection): TransactionViolation[] | null { return transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID] ?? null; } @@ -578,8 +605,8 @@ function calculateTaxAmount(percentage: string, amount: number) { /** * Calculates count of all tax enabled options */ -function getEnabledTaxRateCount(options: PolicyTaxRates) { - return Object.values(options).filter((option: PolicyTaxRate) => !option.isDisabled).length; +function getEnabledTaxRateCount(options: TaxRates) { + return Object.values(options).filter((option: TaxRate) => !option.isDisabled).length; } export { @@ -605,6 +632,8 @@ export { getCategory, getBillable, getTag, + getTagArrayFromName, + getTagForDisplay, getTransactionViolations, getLinkedTransaction, getAllReportTransactions, diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index e3a667fd5a44..12b52524f113 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -173,7 +173,7 @@ function getAvatarUrl(avatarSource: AvatarSource | undefined, accountID: number) * Avatars uploaded by users will have a _128 appended so that the asset server returns a small version. * This removes that part of the URL so the full version of the image can load. */ -function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID: number): AvatarSource { +function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID?: number): AvatarSource { const source = getAvatar(avatarSource, accountID); if (typeof source !== 'string') { return source; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 02ae638a41d3..0a46acbea102 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -5,7 +5,7 @@ import isDate from 'lodash/isDate'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; import type {OnyxCollection} from 'react-native-onyx'; -import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormValue} from '@components/Form/types'; import CONST from '@src/CONST'; import type {OnyxFormKey} from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; @@ -37,7 +37,11 @@ function validateCardNumber(value: string): boolean { /** * Validating that this is a valid address (PO boxes are not allowed) */ -function isValidAddress(value: string): boolean { +function isValidAddress(value: FormValue): boolean { + if (typeof value !== 'string') { + return false; + } + if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.EMOJIS)) { return false; } @@ -77,7 +81,7 @@ function isValidPastDate(date: string | Date): boolean { * Used to validate a value that is "required". * @param value - field value */ -function isRequiredFulfilled(value?: string | boolean | Date): boolean { +function isRequiredFulfilled(value?: FormValue): boolean { if (!value) { return false; } @@ -103,7 +107,7 @@ function getFieldRequiredErrors(values: FormOnyxVal const errors: FormInputErrors = {}; requiredFields.forEach((fieldKey) => { - if (isRequiredFulfilled(values[fieldKey] as keyof FormOnyxValues)) { + if (isRequiredFulfilled(values[fieldKey] as FormValue)) { return; } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index a1cd001badee..6153ea62cd0d 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -30,7 +30,7 @@ const ViolationsUtils = { // Add 'categoryOutOfPolicy' violation if category is not in policy if (!hasCategoryOutOfPolicyViolation && categoryKey && !isCategoryInPolicy) { - newTransactionViolations.push({name: 'categoryOutOfPolicy', type: 'violation', userMessage: ''}); + newTransactionViolations.push({name: 'categoryOutOfPolicy', type: 'violation'}); } // Remove 'categoryOutOfPolicy' violation if category is in policy @@ -45,72 +45,40 @@ const ViolationsUtils = { // Add 'missingCategory' violation if category is required and not set if (!hasMissingCategoryViolation && policyRequiresCategories && !categoryKey) { - newTransactionViolations.push({name: 'missingCategory', type: 'violation', userMessage: ''}); + newTransactionViolations.push({name: 'missingCategory', type: 'violation'}); } } if (policyRequiresTags) { - const selectedTags = updatedTransaction.tag?.split(CONST.COLON) ?? []; const policyTagKeys = Object.keys(policyTagList); - if (policyTagKeys.length === 0) { - newTransactionViolations.push({ - name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, - type: 'violation', - userMessage: '', - }); - } - - policyTagKeys.forEach((key, index) => { - const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && violation.data?.tagName === key); - const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG && violation.data?.tagName === key); - const selectedTag = selectedTags[index]; - const isTagInPolicy = Boolean(policyTagList[key]?.tags[selectedTag]?.enabled); + // At the moment, we only return violations for tags for workspaces with single-level tags + if (policyTagKeys.length === 1) { + const policyTagListName = policyTagKeys[0]; + const policyTags = policyTagList[policyTagListName]?.tags; + const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY); + const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG); + const isTagInPolicy = policyTags ? !!policyTags[updatedTransaction.tag ?? '']?.enabled : false; // Add 'tagOutOfPolicy' violation if tag is not in policy - if (!hasTagOutOfPolicyViolation && selectedTag && !isTagInPolicy) { - newTransactionViolations.push({ - name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, - type: 'violation', - userMessage: '', - data: { - tagName: key, - }, - }); + if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy) { + newTransactionViolations.push({name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, type: 'violation'}); } // Remove 'tagOutOfPolicy' violation if tag is in policy - if (hasTagOutOfPolicyViolation && selectedTag && isTagInPolicy) { - newTransactionViolations = reject(newTransactionViolations, { - name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, - data: { - tagName: key, - }, - }); + if (hasTagOutOfPolicyViolation && updatedTransaction.tag && isTagInPolicy) { + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY}); } // Remove 'missingTag' violation if tag is valid according to policy if (hasMissingTagViolation && isTagInPolicy) { - newTransactionViolations = reject(newTransactionViolations, { - name: CONST.VIOLATIONS.MISSING_TAG, - data: { - tagName: key, - }, - }); + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_TAG}); } - // Add 'missingTag violation' if tag is required and not set - if (!hasMissingTagViolation && !selectedTag && policyRequiresTags) { - newTransactionViolations.push({ - name: CONST.VIOLATIONS.MISSING_TAG, - type: 'violation', - userMessage: '', - data: { - tagName: key, - }, - }); + if (!hasMissingTagViolation && !updatedTransaction.tag && policyRequiresTags) { + newTransactionViolations.push({name: CONST.VIOLATIONS.MISSING_TAG, type: 'violation'}); } - }); + } } return { diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 30dd03b6e780..0f4e1aed36a7 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -366,7 +366,7 @@ function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subS * Updates the bank account in the database with the company step data * @param params - Business step form data */ -function updateCompanyInformationForBankAccount(bankAccountID: number, params: CompanyStepProps, policyID: string) { +function updateCompanyInformationForBankAccount(bankAccountID: number, params: Partial, policyID: string) { API.write( WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT, { @@ -383,7 +383,7 @@ function updateCompanyInformationForBankAccount(bankAccountID: number, params: C * Add beneficial owners for the bank account and verify the accuracy of the information provided * @param params - Beneficial Owners step form params */ -function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: BeneficialOwnersStepProps, policyID: string) { +function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: Partial, policyID: string) { API.write( WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT, { diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 2cc32616562d..756ef902d913 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -178,3 +178,4 @@ function revealVirtualCardDetails(cardID: number): Promise { } export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud, revealVirtualCardDetails}; +export type {ReplacementReason}; diff --git a/src/libs/actions/ExitSurvey.ts b/src/libs/actions/ExitSurvey.ts new file mode 100644 index 000000000000..ef3ecd6d3e31 --- /dev/null +++ b/src/libs/actions/ExitSurvey.ts @@ -0,0 +1,78 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import ONYXKEYS from '@src/ONYXKEYS'; +import REASON_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm'; +import type {ExitReason} from '@src/types/form/ExitSurveyReasonForm'; +import RESPONSE_INPUT_IDS from '@src/types/form/ExitSurveyResponseForm'; + +let exitReason: ExitReason | undefined; +let exitSurveyResponse: string | undefined; +Onyx.connect({ + key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM, + callback: (value) => (exitReason = value?.[REASON_INPUT_IDS.REASON]), +}); +Onyx.connect({ + key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM, + callback: (value) => (exitSurveyResponse = value?.[RESPONSE_INPUT_IDS.RESPONSE]), +}); + +function saveExitReason(reason: ExitReason) { + Onyx.set(ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM, {[REASON_INPUT_IDS.REASON]: reason}); +} + +function saveResponse(response: string) { + Onyx.set(ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM, {[RESPONSE_INPUT_IDS.RESPONSE]: response}); +} + +/** + * Save the user's response to the mandatory exit survey in the back-end. + */ +function switchToOldDot() { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.IS_SWITCHING_TO_OLD_DOT, + value: true, + }, + ]; + + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.IS_SWITCHING_TO_OLD_DOT, + value: false, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.EXIT_SURVEY_REASON_FORM_DRAFT, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM_DRAFT, + value: null, + }, + ]; + + API.write( + 'SwitchToOldDot', + { + reason: exitReason, + surveyResponse: exitSurveyResponse, + }, + {optimisticData, finallyData}, + ); +} + +export {saveExitReason, saveResponse, switchToOldDot}; diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts index 3a0bdb94d5f5..8207b78e8759 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -1,6 +1,5 @@ import Onyx from 'react-native-onyx'; import type {NullishDeep} from 'react-native-onyx'; -import FormUtils from '@libs/FormUtils'; import type {OnyxFormDraftKey, OnyxFormKey, OnyxValue} from '@src/ONYXKEYS'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; @@ -25,11 +24,11 @@ function clearErrorFields(formID: OnyxFormKey) { } function setDraftValues(formID: OnyxFormKey, draftValues: NullishDeep>) { - Onyx.merge(FormUtils.getDraftKey(formID), draftValues); + Onyx.merge(`${formID}Draft`, draftValues); } function clearDraftValues(formID: OnyxFormKey) { - Onyx.set(FormUtils.getDraftKey(formID), null); + Onyx.set(`${formID}Draft`, null); } export {setDraftValues, setErrorFields, setErrors, clearErrors, clearErrorFields, setIsLoading, clearDraftValues}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 47d10ddcef4b..37308c73e724 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -60,7 +60,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as Policy from './Policy'; import * as Report from './Report'; -type MoneyRequestRoute = StackScreenProps['route']; +type MoneyRequestRoute = StackScreenProps['route']; type IOURequestType = ValueOf; @@ -76,6 +76,8 @@ type MoneyRequestInformation = { createdChatReportActionID: string; createdIOUReportActionID: string; reportPreviewAction: OnyxTypes.ReportAction; + transactionThreadReportID: string; + createdReportActionIDForThread: string; onyxData: OnyxData; }; @@ -223,8 +225,7 @@ Onyx.connect({ * @param reportID to attach the transaction to * @param iouRequestType one of manual/scan/distance */ -// eslint-disable-next-line @typescript-eslint/naming-convention -function startMoneyRequest_temporaryForRefactor(reportID: string, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) { +function initMoneyRequest(reportID: string, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) { // Generate a brand new transactionID const newTransactionID = CONST.IOU.OPTIMISTIC_TRANSACTION_ID; // Disabling this line since currentDate can be an empty string @@ -259,6 +260,12 @@ function clearMoneyRequest(transactionID: string) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); } +// eslint-disable-next-line @typescript-eslint/naming-convention +function startMoneyRequest_temporaryForRefactor(iouType: ValueOf, reportID: string) { + clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID)); +} + // eslint-disable-next-line @typescript-eslint/naming-convention function setMoneyRequestAmount_temporaryForRefactor(transactionID: string, amount: number, currency: string, removeOriginalCurrency = false) { if (removeOriginalCurrency) { @@ -299,16 +306,10 @@ function setMoneyRequestPendingFields(transactionID: string, pendingFields: Pend Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields}); } -// eslint-disable-next-line @typescript-eslint/naming-convention -function setMoneyRequestCategory_temporaryForRefactor(transactionID: string, category: string) { +function setMoneyRequestCategory(transactionID: string, category: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category}); } -// eslint-disable-next-line @typescript-eslint/naming-convention -function resetMoneyRequestCategory_temporaryForRefactor(transactionID: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category: null}); -} - function setMoneyRequestTag(transactionID: string, tag: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {tag}); } @@ -391,6 +392,8 @@ function buildOnyxDataForMoneyRequest( optimisticPolicyRecentlyUsedCategories: string[], optimisticPolicyRecentlyUsedTags: OnyxTypes.RecentlyUsedTags, isNewChatReport: boolean, + transactionThreadReport: OptimisticChatReport, + transactionThreadCreatedReportAction: OptimisticCreatedReportAction, shouldCreateNewMoneyRequestReport: boolean, policy?: OnyxEntry, policyTagList?: OnyxEntry, @@ -400,6 +403,7 @@ function buildOnyxDataForMoneyRequest( ): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { const isScanRequest = TransactionUtils.isScanRequest(transaction); const outstandingChildRequest = getOutstandingChildRequest(needsToBeManuallySubmitted, policy); + const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); const optimisticData: OnyxUpdate[] = []; if (chatReport) { @@ -469,6 +473,19 @@ function buildOnyxDataForMoneyRequest( }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: transactionThreadReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: transactionThreadCreatedReportAction, + }, + }, + // Remove the temporary transaction used during the creation flow { onyxMethod: Onyx.METHOD.SET, @@ -531,12 +548,20 @@ function buildOnyxDataForMoneyRequest( errorFields: null, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { pendingAction: null, - pendingFields: null, + pendingFields: clearedPendingFields, }, }, @@ -575,6 +600,16 @@ function buildOnyxDataForMoneyRequest( }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, ); const failureData: OnyxUpdate[] = [ @@ -605,23 +640,24 @@ function buildOnyxDataForMoneyRequest( }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + }, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), pendingAction: null, - pendingFields: null, + pendingFields: clearedPendingFields, }, }, - - // Remove the temporary transaction used during the creation flow - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, - value: null, - }, - { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, @@ -671,6 +707,15 @@ function buildOnyxDataForMoneyRequest( }), }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + }, + }, + }, ]; // We don't need to compute violations unless we're on a paid policy @@ -789,6 +834,8 @@ function getMoneyRequestInformation( receiptObject.state = receipt.state ?? CONST.IOU.RECEIPT_STATE.SCANREADY; filename = receipt.name; } + const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; + const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; let optimisticTransaction = TransactionUtils.buildOptimisticTransaction( ReportUtils.isExpenseReport(iouReport) ? -amount : amount, currency, @@ -804,6 +851,7 @@ function getMoneyRequestInformation( category, tag, billable, + isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, ); const optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category); @@ -814,8 +862,7 @@ function getMoneyRequestInformation( // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 // to remind me to do this. - const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; - if (existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) { + if (isDistanceRequest) { optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false); } @@ -823,7 +870,8 @@ function getMoneyRequestInformation( // 1. CREATED action for the chatReport // 2. CREATED action for the iouReport // 3. IOU action for the iouReport - // 4. REPORTPREVIEW action for the chatReport + // 4. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread + // 5. REPORTPREVIEW action for the chatReport // Note: The CREATED action for the IOU report must be optimistically generated before the IOU action so there's no chance that it appears after the IOU action in the chat const currentTime = DateUtils.getDBTime(); const optimisticCreatedActionForChat = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail); @@ -843,6 +891,8 @@ function getMoneyRequestInformation( false, currentTime, ); + const optimisticTransactionThread = ReportUtils.buildTransactionThread(iouAction, iouReport.reportID); + const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail); let reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID); if (reportPreviewAction) { @@ -886,6 +936,8 @@ function getMoneyRequestInformation( optimisticPolicyRecentlyUsedCategories, optimisticPolicyRecentlyUsedTags, isNewChatReport, + optimisticTransactionThread, + optimisticCreatedActionForTransactionThread, shouldCreateNewMoneyRequestReport, policy, policyTagList, @@ -904,6 +956,8 @@ function getMoneyRequestInformation( createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : '0', createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOU.reportActionID : '0', reportPreviewAction, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID, onyxData: { optimisticData, successData, @@ -939,7 +993,18 @@ function createDistanceRequest( source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN, }; - const {iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = getMoneyRequestInformation( + const { + iouReport, + chatReport, + transaction, + iouAction, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewAction, + transactionThreadReportID, + createdReportActionIDForThread, + onyxData, + } = getMoneyRequestInformation( currentChatReport, participant, comment, @@ -974,6 +1039,8 @@ function createDistanceRequest( category, tag, billable, + transactionThreadReportID, + createdReportActionIDForThread, }; API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData); @@ -1003,7 +1070,7 @@ function calculateDiffAmount(iouReport: OnyxEntry, updatedTran // Subtract the diff from the total if we change the currency from the currency of IOU report to a different currency return -updatedAmount; } - if (updatedCurrency === iouReport?.currency && updatedTransaction?.modifiedAmount) { + if (updatedCurrency === iouReport?.currency && updatedAmount !== currentAmount) { // Calculate the diff between the updated amount and the current amount if we change the amount and the currency of the transaction is the currency of the report return updatedAmount - currentAmount; } @@ -1127,32 +1194,32 @@ function getUpdateMoneyRequestParams( }, }, }); + } - // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. - let updatedMoneyRequestReport = {...iouReport}; - const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction); - - if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { - // For expense report, the amount is negative so we should subtract total from diff - updatedMoneyRequestReport.total -= diff; - } else { - updatedMoneyRequestReport = iouReport - ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true) - : {}; - } - updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency); + // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. + let updatedMoneyRequestReport = {...iouReport}; + const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: updatedMoneyRequestReport, - }); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {pendingAction: null}, - }); + if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { + // For expense report, the amount is negative so we should subtract total from diff + updatedMoneyRequestReport.total -= diff; + } else { + updatedMoneyRequestReport = iouReport + ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true) + : {}; } + updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: updatedMoneyRequestReport, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null}, + }); // Optimistically modify the transaction and the transaction thread optimisticData.push({ @@ -1438,27 +1505,39 @@ function requestMoney( const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report; const moneyRequestReportID = isMoneyRequestReport ? report.reportID : ''; const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); - const {payerAccountID, payerEmail, iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = - getMoneyRequestInformation( - currentChatReport, - participant, - comment, - amount, - currency, - currentCreated, - merchant, - receipt, - undefined, - category, - tag, - billable, - policy, - policyTagList, - policyCategories, - payeeAccountID, - payeeEmail, - moneyRequestReportID, - ); + const { + payerAccountID, + payerEmail, + iouReport, + chatReport, + transaction, + iouAction, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewAction, + transactionThreadReportID, + createdReportActionIDForThread, + onyxData, + } = getMoneyRequestInformation( + currentChatReport, + participant, + comment, + amount, + currency, + currentCreated, + merchant, + receipt, + undefined, + category, + tag, + billable, + policy, + policyTagList, + policyCategories, + payeeAccountID, + payeeEmail, + moneyRequestReportID, + ); const activeReportID = isMoneyRequestReport ? report.reportID : chatReport.reportID; const parameters: RequestMoneyParams = { @@ -1483,9 +1562,10 @@ function requestMoney( taxCode, taxAmount, billable, - // This needs to be a string of JSON because of limitations with the fetch() API and nested objects gpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined, + transactionThreadReportID, + createdReportActionIDForThread, }; API.write(WRITE_COMMANDS.REQUEST_MONEY, parameters, onyxData); @@ -1605,6 +1685,11 @@ function createSplitsAndOnyxData( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, value: splitTransaction, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, + value: null, + }, ]; const successData: OnyxUpdate[] = [ @@ -1621,11 +1706,6 @@ function createSplitsAndOnyxData( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, value: {pendingAction: null}, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, - value: null, - }, ]; if (!existingSplitChatReport) { @@ -1644,11 +1724,6 @@ function createSplitsAndOnyxData( errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, - value: null, - }, ]; if (existingSplitChatReport) { @@ -1813,6 +1888,10 @@ function createSplitsAndOnyxData( // Add tag to optimistic policy recently used tags when a participant is a workspace const optimisticPolicyRecentlyUsedTags = isPolicyExpenseChat ? Policy.buildOptimisticPolicyRecentlyUsedTags(participant.policyID, tag) : {}; + // Create optimistic transactionThread + const optimisticTransactionThread = ReportUtils.buildTransactionThread(oneOnOneIOUAction, oneOnOneIOUReport.reportID); + const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + // STEP 5: Build Onyx Data const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest( oneOnOneChatReport, @@ -1826,6 +1905,8 @@ function createSplitsAndOnyxData( optimisticPolicyRecentlyUsedCategories, optimisticPolicyRecentlyUsedTags, isNewOneOnOneChatReport, + optimisticTransactionThread, + optimisticCreatedActionForTransactionThread, shouldCreateNewOneOnOneIOUReport, ); @@ -1840,6 +1921,8 @@ function createSplitsAndOnyxData( createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID, createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID, reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID, }; splits.push(individualSplit); @@ -2410,6 +2493,9 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction); } + const optimisticTransactionThread = ReportUtils.buildTransactionThread(oneOnOneIOUAction, oneOnOneIOUReport.reportID); + const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest( oneOnOneChatReport, oneOnOneIOUReport, @@ -2422,6 +2508,8 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA [], {}, isNewOneOnOneChatReport, + optimisticTransactionThread, + optimisticCreatedActionForTransactionThread, shouldCreateNewOneOnOneIOUReport, ); @@ -2436,6 +2524,8 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID, createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID, reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID, }); optimisticData.push(...oneOnOneOptimisticData); @@ -3122,6 +3212,9 @@ function getSendMoneyParams( const reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, optimisticIOUReport); + const optimisticTransactionThread = ReportUtils.buildTransactionThread(optimisticIOUReportAction, optimisticIOUReport.reportID); + const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(recipientEmail); + // Change the method to set for new reports because it doesn't exist yet, is faster, // and we need the data to be available when we navigate to the chat page const optimisticChatReportData: OnyxUpdate = isNewChat @@ -3154,6 +3247,11 @@ function getSendMoneyParams( lastMessageHtml: optimisticIOUReportAction.message?.[0].html, }, }; + const optimisticTransactionThreadData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThread.reportID}`, + value: optimisticTransactionThread, + }; const optimisticIOUReportActionsData: OnyxUpdate = { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, @@ -3171,6 +3269,13 @@ function getSendMoneyParams( [reportPreviewAction.reportActionID]: reportPreviewAction, }, }; + const optimisticTransactionThreadReportActionsData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`, + value: { + [optimisticCreatedActionForTransactionThread.reportActionID]: optimisticCreatedActionForTransactionThread, + }, + }; const successData: OnyxUpdate[] = [ { @@ -3196,6 +3301,15 @@ function getSendMoneyParams( }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`, + value: { + [optimisticCreatedActionForTransactionThread.reportActionID]: { + pendingAction: null, + }, + }, + }, ]; const failureData: OnyxUpdate[] = [ @@ -3206,6 +3320,24 @@ function getSendMoneyParams( errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThread.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTransactionThread.reportID}`, + value: { + [optimisticCreatedActionForTransactionThread.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + }, + }, + }, ]; let optimisticPersonalDetailListData: OnyxUpdate | EmptyObject = {}; @@ -3270,7 +3402,16 @@ function getSendMoneyParams( }); } - const optimisticData: OnyxUpdate[] = [optimisticChatReportData, optimisticIOUReportData, optimisticChatReportActionsData, optimisticIOUReportActionsData, optimisticTransactionData]; + const optimisticData: OnyxUpdate[] = [ + optimisticChatReportData, + optimisticIOUReportData, + optimisticChatReportActionsData, + optimisticIOUReportActionsData, + optimisticTransactionData, + optimisticTransactionThreadData, + optimisticTransactionThreadReportActionsData, + ]; + if (!isEmptyObject(optimisticPersonalDetailListData)) { optimisticData.push(optimisticPersonalDetailListData); } @@ -3285,6 +3426,8 @@ function getSendMoneyParams( newIOUReportDetails, createdReportActionID: isNewChat ? optimisticCreatedAction.reportActionID : '0', reportPreviewReportActionID: reportPreviewAction.reportActionID, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID, }, optimisticData, successData, @@ -3293,9 +3436,10 @@ function getSendMoneyParams( } function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, recipient: Participant, paymentMethodType: PaymentMethodType): PayMoneyRequestData { + const total = iouReport.total ?? 0; const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.PAY, - -(iouReport.total ?? 0), + ReportUtils.isExpenseReport(iouReport) ? -total : total, iouReport.currency ?? '', '', [recipient], @@ -3869,14 +4013,6 @@ function setMoneyRequestCurrency(currency: string) { Onyx.merge(ONYXKEYS.IOU, {currency}); } -function setMoneyRequestCategory(category: string) { - Onyx.merge(ONYXKEYS.IOU, {category}); -} - -function resetMoneyRequestCategory() { - Onyx.merge(ONYXKEYS.IOU, {category: ''}); -} - function setMoneyRequestTaxRate(transactionID: string, taxRate: TaxRate) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxRate}); } @@ -3934,7 +4070,6 @@ function navigateToNextPage(iou: OnyxEntry, iouType: string, repo ? [{reportID: chatReport?.reportID, isPolicyExpenseChat: true, selected: true}] : (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true})); setMoneyRequestParticipants(participants); - resetMoneyRequestCategory(); } Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID)); return; @@ -4095,14 +4230,11 @@ export { payMoneyRequest, sendMoneyWithWallet, startMoneyRequest, + initMoneyRequest, startMoneyRequest_temporaryForRefactor, - resetMoneyRequestCategory, - resetMoneyRequestCategory_temporaryForRefactor, resetMoneyRequestInfo, - clearMoneyRequest, setMoneyRequestAmount_temporaryForRefactor, setMoneyRequestBillable_temporaryForRefactor, - setMoneyRequestCategory_temporaryForRefactor, setMoneyRequestCreated, setMoneyRequestCurrency_temporaryForRefactor, setMoneyRequestDescription, diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index ea675ff6b8f6..13e0a42e839f 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -12,6 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/AddDebitCardForm'; import type {BankAccountList, FundList} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type PaymentMethod from '@src/types/onyx/PaymentMethod'; @@ -205,7 +206,15 @@ function clearDebitCardFormErrorAndSubmit() { Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, { isLoading: false, errors: undefined, - setupComplete: false, + [INPUT_IDS.SETUP_COMPLETE]: false, + [INPUT_IDS.NAME_ON_CARD]: '', + [INPUT_IDS.CARD_NUMBER]: '', + [INPUT_IDS.EXPIRATION_DATE]: '', + [INPUT_IDS.SECURITY_CODE]: '', + [INPUT_IDS.ADDRESS_STREET]: '', + [INPUT_IDS.ADDRESS_ZIP_CODE]: '', + [INPUT_IDS.ADDRESS_STATE]: '', + [INPUT_IDS.ACCEPT_TERMS]: '', }); } diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index bc695911b910..57cd4a6fc071 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -6,6 +6,7 @@ import lodashClone from 'lodash/clone'; import lodashUnion from 'lodash/union'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; import type { AddMembersToWorkspaceParams, @@ -19,6 +20,8 @@ import type { OpenWorkspaceMembersPageParams, OpenWorkspaceParams, OpenWorkspaceReimburseViewParams, + SetWorkspaceApprovalModeParams, + SetWorkspaceAutoReportingParams, UpdateWorkspaceAvatarParams, UpdateWorkspaceCustomUnitAndRateParams, UpdateWorkspaceDescriptionParams, @@ -381,6 +384,87 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[] return announceRoomMembers; } +function setWorkspaceAutoReporting(policyID: string, enabled: boolean) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReporting: enabled, + pendingFields: {isAutoApprovalEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReporting: !enabled, + pendingFields: {isAutoApprovalEnabled: null}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {isAutoApprovalEnabled: null}, + }, + }, + ]; + + const params: SetWorkspaceAutoReportingParams = {policyID, enabled}; + API.write(WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING, params, {optimisticData, failureData, successData}); +} + +function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMode: ValueOf) { + const isAutoApprovalEnabled = approvalMode === CONST.POLICY.APPROVAL_MODE.BASIC; + + const value = { + approver, + approvalMode, + isAutoApprovalEnabled, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + ...value, + pendingFields: {approvalMode: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {approvalMode: null}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {approvalMode: null}, + }, + }, + ]; + + const params: SetWorkspaceApprovalModeParams = {policyID, value: JSON.stringify(value)}; + API.write(WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE, params, {optimisticData, failureData, successData}); +} + /** * Build optimistic data for removing users from the announcement room */ @@ -1635,8 +1719,8 @@ function buildOptimisticPolicyRecentlyUsedCategories(policyID?: string, category return lodashUnion([category], policyRecentlyUsedCategories); } -function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, reportTags?: string): RecentlyUsedTags { - if (!policyID || !reportTags) { +function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, transactionTags?: string): RecentlyUsedTags { + if (!policyID || !transactionTags) { return {}; } @@ -1645,7 +1729,7 @@ function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, reportTags?: s const policyRecentlyUsedTags = allRecentlyUsedTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`] ?? {}; const newOptimisticPolicyRecentlyUsedTags: RecentlyUsedTags = {}; - reportTags.split(CONST.COLON).forEach((tag, index) => { + TransactionUtils.getTagArrayFromName(transactionTags).forEach((tag, index) => { if (!tag) { return; } @@ -2134,5 +2218,7 @@ export { buildOptimisticPolicyRecentlyUsedTags, createDraftInitialWorkspace, setWorkspaceInviteMessageDraft, + setWorkspaceAutoReporting, + setWorkspaceApprovalMode, updateWorkspaceDescription, }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index e236002ee704..f29f8a4fbaab 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -40,6 +40,7 @@ import type { UpdateReportWriteCapabilityParams, UpdateRoomDescriptionParams, } from '@libs/API/parameters'; +import type UpdateRoomVisibilityParams from '@libs/API/parameters/UpdateRoomVisibilityParams'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CollectionUtils from '@libs/CollectionUtils'; import DateUtils from '@libs/DateUtils'; @@ -66,9 +67,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/NewRoomForm'; import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx'; import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; -import type {NotificationPreference, WriteCapability} from '@src/types/onyx/Report'; +import type {NotificationPreference, RoomVisibility, WriteCapability} from '@src/types/onyx/Report'; import type Report from '@src/types/onyx/Report'; import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; @@ -85,12 +87,14 @@ type ActionSubscriber = { callback: SubscriberCallback; }; +let conciergeChatReportID: string | undefined; let currentUserAccountID = -1; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { // When signed out, val is undefined if (!value?.accountID) { + conciergeChatReportID = undefined; return; } @@ -167,7 +171,6 @@ Onyx.connect({ }); const allReports: OnyxCollection = {}; -let conciergeChatReportID: string | undefined; const typingWatchTimers: Record = {}; let reportIDDeeplinkedFromOldDot: string | undefined; @@ -1442,6 +1445,38 @@ function updateNotificationPreference( } } +function updateRoomVisibility(reportID: string, previousValue: RoomVisibility | undefined, newValue: RoomVisibility, navigate: boolean, report: OnyxEntry | EmptyObject = {}) { + if (previousValue === newValue) { + if (navigate && !isEmptyObject(report) && report.reportID) { + ReportUtils.goBackToDetailsPage(report); + } + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {visibility: newValue}, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {visibility: previousValue}, + }, + ]; + + const parameters: UpdateRoomVisibilityParams = {reportID, visibility: newValue}; + + API.write(WRITE_COMMANDS.UPDATE_ROOM_VISIBILITY, parameters, {optimisticData, failureData}); + if (navigate && !isEmptyObject(report)) { + ReportUtils.goBackToDetailsPage(report); + } +} + /** * This will subscribe to an existing thread, or create a new one and then subsribe to it if necessary * @@ -1683,24 +1718,29 @@ function updateWriteCapabilityAndNavigate(report: Report, newValue: WriteCapabil /** * Navigates to the 1:1 report with Concierge - * - * @param ignoreConciergeReportID - Flag to ignore conciergeChatReportID during navigation. The default behavior is to not ignore. */ -function navigateToConciergeChat(ignoreConciergeReportID = false, shouldDismissModal = false) { +function navigateToConciergeChat(shouldDismissModal = false, shouldPopCurrentScreen = false, checkIfCurrentPageActive = () => true) { // If conciergeChatReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID. // Otherwise, we would find the concierge chat and navigate to it. - // Now, when user performs sign-out and a sign-in again, conciergeChatReportID may contain a stale value. - // In order to prevent navigation to a stale value, we use ignoreConciergeReportID to forcefully find and navigate to concierge chat. - if (!conciergeChatReportID || ignoreConciergeReportID) { + if (!conciergeChatReportID) { // In order to avoid creating concierge repeatedly, // we need to ensure that the server data has been successfully pulled Welcome.serverDataIsReadyPromise().then(() => { // If we don't have a chat with Concierge then create it + if (!checkIfCurrentPageActive()) { + return; + } + if (shouldPopCurrentScreen && !shouldDismissModal) { + Navigation.goBack(); + } navigateToAndOpenReport([CONST.EMAIL.CONCIERGE], shouldDismissModal); }); } else if (shouldDismissModal) { Navigation.dismissModal(conciergeChatReportID); } else { + if (shouldPopCurrentScreen) { + Navigation.goBack(); + } Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeChatReportID)); } } @@ -2180,10 +2220,7 @@ function openReportFromDeepLink(url: string, isAuthenticated: boolean) { Session.waitForUserSignIn().then(() => { Navigation.waitForProtectedRoutes().then(() => { const route = ReportUtils.getRouteFromLink(url); - if (route === ROUTES.CONCIERGE) { - navigateToConciergeChat(true); - return; - } + if (route && Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { Session.signOutAndRedirectToSignIn(true); return; @@ -2805,6 +2842,11 @@ function clearNewRoomFormError() { isLoading: false, errorFields: null, errors: null, + [INPUT_IDS.ROOM_NAME]: '', + [INPUT_IDS.REPORT_DESCRIPTION]: '', + [INPUT_IDS.POLICY_ID]: '', + [INPUT_IDS.WRITE_CAPABILITY]: '', + [INPUT_IDS.VISIBILITY]: '', }); } @@ -2926,4 +2968,5 @@ export { updateReportField, updateReportName, resolveActionableMentionWhisper, + updateRoomVisibility, }; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 7416b4f07e5e..013d86049150 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -856,7 +856,7 @@ function handleExitToNavigation(exitTo: Routes | HybridAppRoute) { waitForUserSignIn().then(() => { Navigation.waitForProtectedRoutes().then(() => { const url = NativeModules.HybridAppModule ? Navigation.parseHybridAppUrl(exitTo) : exitTo; - Navigation.navigate(url, CONST.NAVIGATION.TYPE.FORCED_UP); + Navigation.navigate(url); }); }); }); @@ -890,7 +890,7 @@ const canAccessRouteByAnonymousUser = (route: string) => { if (route.startsWith('/')) { routeRemovedReportId = routeRemovedReportId.slice(1); } - const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route]; + const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route, ROUTES.CONCIERGE]; if ((routesCanAccessByAnonymousUser as string[]).includes(routeRemovedReportId)) { return true; diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 1d9af01f2fa0..5b178104d7c7 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -1,6 +1,7 @@ import {isEqual} from 'lodash'; import lodashClone from 'lodash/clone'; import lodashHas from 'lodash/has'; +import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type {GetRouteForDraftParams, GetRouteParams} from '@libs/API/parameters'; @@ -106,7 +107,7 @@ function saveWaypoint(transactionID: string, index: string, waypoint: RecentWayp } } -function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: boolean) { +function removeWaypoint(transaction: OnyxEntry, currentIndex: string, isDraft?: boolean) { // Index comes from the route params and is a string const index = Number(currentIndex); const existingWaypoints = transaction?.comment?.waypoints ?? {}; @@ -134,9 +135,10 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: // to remove nested keys while also preserving other object keys // Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set let newTransaction: Transaction = { - ...transaction, + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + ...(transaction as Transaction), comment: { - ...transaction.comment, + ...transaction?.comment, waypoints: reIndexedWaypoints, }, // We want to reset the amount only for draft transactions (when creating the request). @@ -164,10 +166,10 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: }; } if (isDraft) { - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`, newTransaction); return; } - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, newTransaction); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, newTransaction); } function getOnyxDataForRouteRequest(transactionID: string, isDraft = false): OnyxData { diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 54efe4ba4d8e..d7cef2aca546 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -29,6 +29,7 @@ import * as Pusher from '@libs/Pusher/pusher'; import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import playSound, {SOUNDS} from '@libs/Sound'; +import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -526,13 +527,13 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { } // mention user - if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { - return playSound(SOUNDS.ATTENTION); + if ('html' in message && typeof message.html === 'string' && message.html.includes(`@${currentEmail}`)) { + return playSoundExcludingMobile(SOUNDS.ATTENTION); } // mention @here if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { - return playSound(SOUNDS.ATTENTION); + return playSoundExcludingMobile(SOUNDS.ATTENTION); } // assign a task @@ -552,7 +553,7 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { // plain message if ('html' in message) { - return playSound(SOUNDS.RECEIVE); + return playSoundExcludingMobile(SOUNDS.RECEIVE); } } } catch (e) { @@ -779,6 +780,13 @@ function generateStatementPDF(period: string) { function setContactMethodAsDefault(newDefaultContactMethod: string) { const oldDefaultContactMethod = currentEmail; const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + primaryLogin: newDefaultContactMethod, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SESSION, @@ -825,6 +833,13 @@ function setContactMethodAsDefault(newDefaultContactMethod: string) { }, ]; const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + primaryLogin: oldDefaultContactMethod, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SESSION, diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index df8a589f7bdc..fd03adcffd93 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -1,3 +1,4 @@ +import Str from 'expensify-common/lib/str'; import CONST from '@src/CONST'; import type {Message} from '@src/types/onyx/ReportAction'; @@ -17,5 +18,5 @@ export default function isReportMessageAttachment({text, html, translationKey}: } const regex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)"`, 'i'); - return text === CONST.ATTACHMENT_MESSAGE_TEXT && (!!html.match(regex) || html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML); + return (text === CONST.ATTACHMENT_MESSAGE_TEXT || !!Str.isVideo(text)) && (!!html.match(regex) || html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML); } diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts index cd50938c70b9..68c750b05a5f 100644 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts +++ b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts @@ -1,5 +1,3 @@ import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder'; -// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event -// Because of that - on iOS we can use `keyboardWillHide` that is not available on android -export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardWillHide'); +export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardDidHide'); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts index 8d5ef578b66c..72df7a730e02 100644 --- a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts +++ b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts @@ -5,8 +5,6 @@ import * as Composer from '@userActions/Composer'; import type SetShouldShowComposeInputKeyboardAware from './types'; let keyboardEventListener: EmitterSubscription | null = null; -// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event -// Because of that - on iOS we can use `keyboardWillHide` that is not available on android const setShouldShowComposeInputKeyboardAwareBuilder: (keyboardEvent: KeyboardEventName) => SetShouldShowComposeInputKeyboardAware = (keyboardEvent: KeyboardEventName) => (shouldShow: boolean) => { diff --git a/src/libs/shouldAllowDownloadQRCode/index.native.ts b/src/libs/shouldAllowDownloadQRCode/index.native.ts new file mode 100644 index 000000000000..ea9b2b9c8aa1 --- /dev/null +++ b/src/libs/shouldAllowDownloadQRCode/index.native.ts @@ -0,0 +1,5 @@ +import type ShouldAllowDownloadQRCode from './types'; + +const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = true; + +export default shouldAllowDownloadQRCode; diff --git a/src/libs/shouldAllowDownloadQRCode/index.ts b/src/libs/shouldAllowDownloadQRCode/index.ts new file mode 100644 index 000000000000..8331f7d4821f --- /dev/null +++ b/src/libs/shouldAllowDownloadQRCode/index.ts @@ -0,0 +1,5 @@ +import type ShouldAllowDownloadQRCode from './types'; + +const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = false; + +export default shouldAllowDownloadQRCode; diff --git a/src/libs/shouldAllowDownloadQRCode/types.ts b/src/libs/shouldAllowDownloadQRCode/types.ts new file mode 100644 index 000000000000..3bd6c5dc4dd7 --- /dev/null +++ b/src/libs/shouldAllowDownloadQRCode/types.ts @@ -0,0 +1,3 @@ +type ShouldAllowDownloadQRCode = boolean; + +export default ShouldAllowDownloadQRCode; diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 251728866a54..4abf8f0d2033 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -1,11 +1,16 @@ import {useFocusEffect} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; -import React from 'react'; +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; +import * as App from '@userActions/App'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -24,19 +29,39 @@ type ConciergePageProps = ConciergePageOnyxProps & StackScreenProps { if (session && 'authToken' in session) { + App.confirmReadyToOpenApp(); // Pop the concierge loading page before opening the concierge report. Navigation.isNavigationReady().then(() => { - Navigation.goBack(); - Report.navigateToConciergeChat(); + if (isUnmounted.current) { + return; + } + Report.navigateToConciergeChat(undefined, true, () => !isUnmounted.current); }); } else { Navigation.navigate(); } }); - return ; + useEffect( + () => () => { + isUnmounted.current = true; + }, + [], + ); + + return ( + + + + + + + ); } ConciergePage.displayName = 'ConciergePage'; diff --git a/src/pages/DetailsPage.tsx b/src/pages/DetailsPage.tsx index d4438d3141bf..a9adb5310e58 100755 --- a/src/pages/DetailsPage.tsx +++ b/src/pages/DetailsPage.tsx @@ -64,22 +64,13 @@ function DetailsPage({personalDetails, route, session}: DetailsPageProps) { let details = Object.values(personalDetails ?? {}).find((personalDetail) => personalDetail?.login === login.toLowerCase()); if (!details) { - if (login === CONST.EMAIL.CONCIERGE) { - details = { - accountID: CONST.ACCOUNT_ID.CONCIERGE, - login, - displayName: 'Concierge', - avatar: UserUtils.getDefaultAvatar(CONST.ACCOUNT_ID.CONCIERGE), - }; - } else { - const optimisticAccountID = UserUtils.generateAccountID(login); - details = { - accountID: optimisticAccountID, - login, - displayName: login, - avatar: UserUtils.getDefaultAvatar(optimisticAccountID), - }; - } + const optimisticAccountID = UserUtils.generateAccountID(login); + details = { + accountID: optimisticAccountID, + login, + displayName: login, + avatar: UserUtils.getDefaultAvatar(optimisticAccountID), + }; } const isSMSLogin = details.login ? Str.isSMSLogin(details.login) : false; diff --git a/src/pages/EditRequestCategoryPage.js b/src/pages/EditRequestCategoryPage.js deleted file mode 100644 index 205b4bf66dfa..000000000000 --- a/src/pages/EditRequestCategoryPage.js +++ /dev/null @@ -1,55 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import CategoryPicker from '@components/CategoryPicker'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; - -const propTypes = { - /** Transaction default category value */ - defaultCategory: PropTypes.string.isRequired, - - /** The policyID we are getting categories for */ - policyID: PropTypes.string.isRequired, - - /** Callback to fire when the Save button is pressed */ - onSubmit: PropTypes.func.isRequired, -}; - -function EditRequestCategoryPage({defaultCategory, policyID, onSubmit}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - const selectCategory = (category) => { - onSubmit({ - category: category.searchText, - }); - }; - - return ( - - - {translate('iou.categorySelection')} - - - ); -} - -EditRequestCategoryPage.propTypes = propTypes; -EditRequestCategoryPage.displayName = 'EditRequestCategoryPage'; - -export default EditRequestCategoryPage; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 29917154a527..de17d16a7c38 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -1,5 +1,4 @@ import lodashGet from 'lodash/get'; -import lodashValues from 'lodash/values'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; @@ -21,7 +20,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import EditRequestAmountPage from './EditRequestAmountPage'; -import EditRequestCategoryPage from './EditRequestCategoryPage'; import EditRequestDistancePage from './EditRequestDistancePage'; import EditRequestReceiptPage from './EditRequestReceiptPage'; import EditRequestTagPage from './EditRequestTagPage'; @@ -77,7 +75,7 @@ const defaultProps = { function EditRequestPage({report, route, policy, policyCategories, policyTags, parentReportActions, transaction}) { const parentReportActionID = lodashGet(report, 'parentReportActionID', '0'); const parentReportAction = lodashGet(parentReportActions, parentReportActionID, {}); - const {amount: transactionAmount, currency: transactionCurrency, category: transactionCategory, tag: transactionTag} = ReportUtils.getTransactionDetails(transaction); + const {amount: transactionAmount, currency: transactionCurrency, tag: transactionTag} = ReportUtils.getTransactionDetails(transaction); const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency; const fieldToEdit = lodashGet(route, ['params', 'field'], ''); @@ -90,9 +88,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p // A flag for verifying that the current report is a sub-report of a workspace chat const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report); - // A flag for showing the categories page - const shouldShowCategories = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories))); - // A flag for showing the tags page const shouldShowTags = useMemo(() => isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists)), [isPolicyExpenseChat, policyTagLists, transactionTag]); @@ -135,7 +130,7 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p IOU.updateMoneyRequestTag( transaction.transactionID, report.reportID, - IOUUtils.insertTagIntoReportTagsString(transactionTag, updatedTag, tagIndex), + IOUUtils.insertTagIntoTransactionTagsString(transactionTag, updatedTag, tagIndex), policy, policyTags, policyCategories, @@ -145,16 +140,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p [tag, transaction.transactionID, report.reportID, transactionTag, tagIndex, policy, policyTags, policyCategories], ); - const saveCategory = useCallback( - ({category: newCategory}) => { - // In case the same category has been selected, reset the category. - const updatedCategory = newCategory === transactionCategory ? '' : newCategory; - IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, policy, policyTags, policyCategories); - Navigation.dismissModal(); - }, - [transactionCategory, transaction.transactionID, report.reportID, policy, policyTags, policyCategories], - ); - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { return ( - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG && shouldShowTags) { return ( { - setDraftSplitTransaction({category: transactionChanges.category.trim()}); - }} - /> - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG) { return ( Navigation.dismissModal()} + onBackButtonPress={navigation.goBack} /> ( + tabBar={({state, navigation: tabNavigation, position}) => ( )} diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.tsx b/src/pages/PrivateNotes/PrivateNotesListPage.tsx index 97ebc7dee2fb..0a6a2659ffb6 100644 --- a/src/pages/PrivateNotes/PrivateNotesListPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesListPage.tsx @@ -88,7 +88,12 @@ function PrivateNotesListPage({report, personalDetailsList, session}: PrivateNot onCloseButtonPress={() => Navigation.dismissModal()} /> {translate('privateNotes.personalNoteMessage')} - {privateNotes.map((item) => getMenuItem(item))} + + {privateNotes.map((item) => getMenuItem(item))} + ); } diff --git a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx index bb352acd4732..ed00fbcff422 100644 --- a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx @@ -136,7 +136,6 @@ function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkTok testID={BankInfo.displayName} includeSafeAreaPaddingBottom={false} shouldEnablePickerAvoiding={false} - shouldEnableMaxHeight > )}