diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index 75f46e68fe5a..e6da6fff1446 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -11,7 +11,7 @@ jobs: validateActor: runs-on: ubuntu-latest outputs: - IS_DEPLOYER: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' }} + IS_DEPLOYER: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' || github.actor == 'os-botify[bot]' }} steps: - name: Check if user is deployer id: isDeployer diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 3666e8c7d343..308404b74bc0 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -191,14 +191,15 @@ jobs: if: ${{ always() && runner.debug != null && fromJSON(runner.debug) }} run: cat "./Host_Machine_Files/\$WORKING_DIRECTORY/debug.log" - - name: Check if test failed, if so post the results and add the DeployBlocker label - run: | - if grep -q '🔴' ./Host_Machine_Files/\$WORKING_DIRECTORY/output.md; then - gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash - gh pr comment ${{ inputs.PR_NUMBER }} -F ./Host_Machine_Files/\$WORKING_DIRECTORY/output.md - gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers 📣 Please look into this performance regression as it's a deploy blocker." - else - echo '✅ no performance regression detected' - fi - env: - GITHUB_TOKEN: ${{ github.token }} +# TODO: Once tests are more reliable we should uncomment this +# - name: Check if test failed, if so post the results and add the DeployBlocker label +# run: | +# if grep -q '🔴' ./Host_Machine_Files/\$WORKING_DIRECTORY/output.md; then +# gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash +# gh pr comment ${{ inputs.PR_NUMBER }} -F ./Host_Machine_Files/\$WORKING_DIRECTORY/output.md +# gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers 📣 Please look into this performance regression as it's a deploy blocker." +# else +# echo '✅ no performance regression detected' +# fi +# env: +# GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index ad002e164837..f5a5dc5e1616 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -16,7 +16,7 @@ jobs: validateActor: runs-on: ubuntu-latest outputs: - IS_DEPLOYER: ${{ fromJSON(steps.isUserDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' }} + IS_DEPLOYER: ${{ fromJSON(steps.isUserDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' || github.actor == 'os-botify[bot]' }} steps: - name: Check if user is deployer id: isUserDeployer diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association index d6da0232f2fc..1e63fdcb2d52 100644 --- a/.well-known/apple-app-site-association +++ b/.well-known/apple-app-site-association @@ -79,6 +79,10 @@ { "/": "/search/*", "comment": "Search" + }, + { + "/": "/money2020/*", + "comment": "Money 2020" } ] } diff --git a/__mocks__/react-native.js b/__mocks__/react-native.js index 006d1aee38af..1eeea877ca0f 100644 --- a/__mocks__/react-native.js +++ b/__mocks__/react-native.js @@ -28,6 +28,7 @@ jest.doMock('react-native', () => { BootSplash: { getVisibilityStatus: jest.fn(), hide: jest.fn(), + logoSizeRatio: 1, navigationBarHeight: 0, }, StartupTimer: {stop: jest.fn()}, diff --git a/android/app/build.gradle b/android/app/build.gradle index 9995d0dc9bb5..74c0e2f63a1f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001038311 - versionName "1.3.83-11" + versionCode 1001038404 + versionName "1.3.84-4" } flavorDimensions "default" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d823324f50bf..7419d5b1e1a7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -70,6 +70,7 @@ + @@ -87,6 +88,7 @@ + diff --git a/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashDialog.java b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashDialog.java index f5b1ceff60e2..b65cb7306a3d 100644 --- a/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashDialog.java +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashDialog.java @@ -6,6 +6,7 @@ import android.view.Window; import android.view.WindowManager.LayoutParams; import androidx.annotation.NonNull; +import com.expensify.chat.R; public class BootSplashDialog extends Dialog { @@ -26,6 +27,10 @@ protected void onCreate(Bundle savedInstanceState) { if (window != null) { window.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + + if (BootSplashModule.isSamsungOneUI4()) { + window.setBackgroundDrawableResource(R.drawable.bootsplash_samsung_oneui_4); + } } super.onCreate(savedInstanceState); diff --git a/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashModule.java b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashModule.java index c286ebf7a935..7498fa6594fb 100644 --- a/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashModule.java +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashModule.java @@ -23,6 +23,7 @@ import com.facebook.react.common.ReactConstants; import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.uimanager.PixelUtil; +import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; import java.util.Timer; @@ -47,6 +48,19 @@ public String getName() { return NAME; } + // From https://stackoverflow.com/a/61062773 + public static boolean isSamsungOneUI4() { + String name = "SEM_PLATFORM_INT"; + + try { + Field field = Build.VERSION.class.getDeclaredField(name); + int version = (field.getInt(null) - 90000) / 10000; + return version == 4; + } catch (Exception ignored) { + return false; + } + } + @Override public Map getConstants() { final HashMap constants = new HashMap<>(); @@ -61,6 +75,7 @@ public Map getConstants() { ? Math.round(PixelUtil.toDIPFromPixel(resources.getDimensionPixelSize(heightResId))) : 0; + constants.put("logoSizeRatio", isSamsungOneUI4() ? 0.5 : 1); constants.put("navigationBarHeight", height); return constants; } diff --git a/android/app/src/main/res/mipmap-hdpi/bootsplash_logo.png b/android/app/src/main/res/drawable-hdpi/bootsplash_logo.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/bootsplash_logo.png rename to android/app/src/main/res/drawable-hdpi/bootsplash_logo.png diff --git a/android/app/src/main/res/mipmap-mdpi/bootsplash_logo.png b/android/app/src/main/res/drawable-mdpi/bootsplash_logo.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/bootsplash_logo.png rename to android/app/src/main/res/drawable-mdpi/bootsplash_logo.png diff --git a/android/app/src/main/res/mipmap-xhdpi/bootsplash_logo.png b/android/app/src/main/res/drawable-xhdpi/bootsplash_logo.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/bootsplash_logo.png rename to android/app/src/main/res/drawable-xhdpi/bootsplash_logo.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/bootsplash_logo.png b/android/app/src/main/res/drawable-xxhdpi/bootsplash_logo.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/bootsplash_logo.png rename to android/app/src/main/res/drawable-xxhdpi/bootsplash_logo.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/bootsplash_logo.png b/android/app/src/main/res/drawable-xxxhdpi/bootsplash_logo.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/bootsplash_logo.png rename to android/app/src/main/res/drawable-xxxhdpi/bootsplash_logo.png diff --git a/android/app/src/main/res/drawable/bootsplash_samsung_oneui_4.xml b/android/app/src/main/res/drawable/bootsplash_samsung_oneui_4.xml new file mode 100644 index 000000000000..9861004d368f --- /dev/null +++ b/android/app/src/main/res/drawable/bootsplash_samsung_oneui_4.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 34d33d240458..aa0e8136957f 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -72,7 +72,7 @@ diff --git a/assets/images/new-expensify-dark.svg b/assets/images/new-expensify-dark.svg index bcdb3c87f164..ad34f1d9dfce 100644 --- a/assets/images/new-expensify-dark.svg +++ b/assets/images/new-expensify-dark.svg @@ -1,29 +1,10 @@ - - - - - - - - - - - - - - - - - - + + diff --git a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md index f61f26d91059..14ade143a35b 100644 --- a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md +++ b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md @@ -1,8 +1,63 @@ --- title: Third Party Payments -description: Third Party Payments +description: A help article that covers Third Party Payment options including PayPal, Venmo, Wise, and Paylocity. --- -## Resource Coming Soon! +# Expensify Third Party Payment Options +Expensify offers convenient third party payment options that allow you to streamline the process of reimbursing expenses and managing your finances. With these options, you can pay your expenses and get reimbursed faster and more efficiently. In this guide, we'll walk you through the steps to set up and use Expensify's third party payment options. - \ No newline at end of file +## Overview + +Expensify offers integration with various third party payment providers, making it easy to reimburse employees and manage your expenses seamlessly. Some of the key benefits of using third-party payment options in Expensify include: + +- Faster Reimbursements: Expedite the reimbursement process and reduce the time it takes for employees to receive their funds. +- Secure Transactions: Benefit from the security features and protocols provided by trusted payment providers. +- Centralized Expense Management: Consolidate all your expenses and payments within Expensify for a more efficient financial workflow. + +## Setting Up Third Party Payments + +To get started with third party payments in Expensify, follow these steps: + +1. **Log in to Expensify**: Access your Expensify account using your credentials. + +2. **Navigate to Settings**: Click on the "Settings" option in the top-right corner of the Expensify dashboard. + +3. **Select Payments**: In the Settings menu, find and click on the "Payments" or "Payment Methods" section. + +4. **Choose Third Party Payment Provider**: Select your preferred third party payment provider from the available options. Expensify may support providers such as PayPal, Venmo, Wise, and Paylocity. + +5. **Link Your Account**: Follow the prompts to link your third party payment account with Expensify. You may need to enter your account details and grant necessary permissions. + +6. **Verify Your Account**: Confirm your linked account to ensure it's correctly integrated with Expensify. + +## Using Third Party Payments + +Once you've set up your third party payment option, you can start using it to reimburse expenses and manage payments: + +1. **Create an Expense Report**: Begin by creating an expense report in Expensify, adding all relevant expenses. + +2. **Submit for Approval**: After reviewing and verifying the expenses, submit the report for approval within Expensify. + +3. **Approval and Reimbursement**: Once the report is approved, the approved expenses can be reimbursed directly through your chosen third party payment provider. Expensify will automatically initiate the payment process. + +4. **Track Payment Status**: You can track the status of payments and view transaction details within your Expensify account. + +## FAQ’s + +### Q: Are there any fees associated with using third party payment options in Expensify? + +A: The fees associated with third party payments may vary depending on the payment provider you choose. Be sure to review the terms and conditions of your chosen provider for details on any applicable fees. + +### Q: Can I use multiple third party payment providers with Expensify? + +A: Expensify allows you to link multiple payment providers if needed. You can select the most suitable payment method for each expense report. + +### Q: Is there a limit on the amount I can reimburse using third party payments? + +A: The reimbursement limit may depend on the policies and settings configured within your Expensify account and the limits imposed by your chosen payment provider. + +With Expensify's third party payment options, you can simplify your expense management and reimbursement processes. By following the steps outlined in this guide, you can set up and use third party payments efficiently. + + + + diff --git a/docs/assets/Files/Hosting b/docs/assets/Files/Hosting new file mode 100644 index 000000000000..ad2a361edc03 --- /dev/null +++ b/docs/assets/Files/Hosting @@ -0,0 +1 @@ +Holding tank for help.expensify.com support files diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index e484cbfa4281..15b0891c1ac0 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.83 + 1.3.84 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.83.11 + 1.3.84.4 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 21e0a02a0664..343378adca90 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.83 + 1.3.84 CFBundleSignature ???? CFBundleVersion - 1.3.83.11 + 1.3.84.4 diff --git a/package-lock.json b/package-lock.json index 7a2207cf58c7..71fadf62e2d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.83-11", + "version": "1.3.84-4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.83-11", + "version": "1.3.84-4", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -94,7 +94,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.98", + "react-native-onyx": "1.0.100", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -44691,9 +44691,9 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.98", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.98.tgz", - "integrity": "sha512-2wJNmZVBJs2Y0p1G/es4tQZnplJR8rOyVbHv9KZaq/SXluLUnIovttf1MMhVXidDLT+gcE+u20Mck/Gpb8bY0w==", + "version": "1.0.100", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.100.tgz", + "integrity": "sha512-m4bOF/uOtYpfL83fqoWhw7TYV4oKGXt0sfGoel/fhaT1HzXKheXc//ibt/G3VrTCf5nmRv7bXgsXkWjUYLH3UQ==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -85253,9 +85253,9 @@ } }, "react-native-onyx": { - "version": "1.0.98", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.98.tgz", - "integrity": "sha512-2wJNmZVBJs2Y0p1G/es4tQZnplJR8rOyVbHv9KZaq/SXluLUnIovttf1MMhVXidDLT+gcE+u20Mck/Gpb8bY0w==", + "version": "1.0.100", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.100.tgz", + "integrity": "sha512-m4bOF/uOtYpfL83fqoWhw7TYV4oKGXt0sfGoel/fhaT1HzXKheXc//ibt/G3VrTCf5nmRv7bXgsXkWjUYLH3UQ==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index b872e19dcd61..d39b9ef8bfec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.83-11", + "version": "1.3.84-4", "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.", @@ -139,7 +139,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.98", + "react-native-onyx": "1.0.100", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", diff --git a/src/CONST.ts b/src/CONST.ts index d207553ccdbd..3760c93ee7e2 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -472,6 +472,7 @@ const CONST = { HAND_ICON_HEIGHT: 152, HAND_ICON_WIDTH: 200, SHUTTER_SIZE: 90, + MAX_REPORT_PREVIEW_RECEIPTS: 3, }, REPORT: { MAXIMUM_PARTICIPANTS: 8, @@ -1243,6 +1244,7 @@ const CONST = { CLOSED: 6, STATE_SUSPENDED: 7, }, + ACTIVE_STATES: [2, 3, 4, 7], }, AVATAR_ROW_SIZE: { DEFAULT: 4, @@ -2717,6 +2719,7 @@ const CONST = { DEMO_PAGES: { SAASTR: 'SaaStrDemoSetup', SBE: 'SbeDemoSetup', + MONEY2020: 'Money2020DemoSetup', }, MAPBOX: { diff --git a/src/Expensify.js b/src/Expensify.js index 642b8ceb456c..6010824cf275 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -29,6 +29,7 @@ import SplashScreenHider from './components/SplashScreenHider'; import AppleAuthWrapper from './components/SignInButtons/AppleAuthWrapper'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; +import * as DemoActions from './libs/actions/DemoActions'; import DeeplinkWrapper from './components/DeeplinkWrapper'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection @@ -167,11 +168,13 @@ function Expensify(props) { // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report Linking.getInitialURL().then((url) => { + DemoActions.runDemoByURL(url); Report.openReportFromDeepLink(url, isAuthenticated); }); // Open chat report from a deep link (only mobile native) Linking.addEventListener('url', (state) => { + DemoActions.runDemoByURL(state.url); Report.openReportFromDeepLink(state.url, isAuthenticated); }); diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 06f8de303e2c..e4505af81721 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -342,9 +342,10 @@ export default { getRoute: (policyID: string) => `workspace/${policyID}/members`, }, - // These are some on-off routes that will be removed once they're no longer needed (see GH issues for details) + // These are some one-off routes that will be removed once they're no longer needed (see GH issues for details) SAASTR: 'saastr', SBE: 'sbe', + MONEY2020: 'money2020', // Iframe screens from olddot HOME_OLDDOT: 'home', diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index bd12020341be..bcea50698b3b 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -104,10 +104,10 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, * @returns {JSX.Element} */ const renderItem = useCallback( - ({item}) => ( + ({item, isActive}) => ( setShouldShowArrows(!shouldShowArrows)} /> ), diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index 3dfc5f59bb38..3a7551a872e9 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -457,12 +457,11 @@ class EmojiPickerMenu extends Component { return ( this.props.onEmojiSelected(emoji, item)} - onHoverIn={() => this.setState({highlightedIndex: index, isUsingKeyboardMovement: false})} - onHoverOut={() => { - if (this.state.arePointerEventsDisabled) { + onHoverIn={() => { + if (!this.state.isUsingKeyboardMovement) { return; } - this.setState({highlightedIndex: -1}); + this.setState({isUsingKeyboardMovement: false}); }} emoji={emojiCode} onFocus={() => this.setState({highlightedIndex: index})} @@ -474,8 +473,6 @@ class EmojiPickerMenu extends Component { })) } isFocused={isEmojiFocused} - isHighlighted={index === this.state.highlightedIndex} - isUsingKeyboardMovement={this.state.isUsingKeyboardMovement} /> ); } diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js index b51a8b07537c..5c753120301a 100644 --- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js @@ -27,14 +27,8 @@ const propTypes = { /** Handles what to do when the pressable is blurred */ onBlur: PropTypes.func, - /** Whether this menu item is currently highlighted or not */ - isHighlighted: PropTypes.bool, - /** Whether this menu item is currently focused or not */ isFocused: PropTypes.bool, - - /** Whether the emoji is highlighted by the keyboard/mouse */ - isUsingKeyboardMovement: PropTypes.bool, }; class EmojiPickerMenuItem extends PureComponent { @@ -43,6 +37,9 @@ class EmojiPickerMenuItem extends PureComponent { this.ref = null; this.focusAndScroll = this.focusAndScroll.bind(this); + this.state = { + isHovered: false, + }; } componentDidMount() { @@ -73,14 +70,26 @@ class EmojiPickerMenuItem extends PureComponent { shouldUseAutoHitSlop={false} onPress={() => this.props.onPress(this.props.emoji)} onPressOut={Browser.isMobile() ? this.props.onHoverOut : undefined} - onHoverIn={this.props.onHoverIn} - onHoverOut={this.props.onHoverOut} + onHoverIn={() => { + if (this.props.onHoverIn) { + this.props.onHoverIn(); + } + + this.setState({isHovered: true}); + }} + onHoverOut={() => { + if (this.props.onHoverOut) { + this.props.onHoverOut(); + } + + this.setState({isHovered: false}); + }} onFocus={this.props.onFocus} onBlur={this.props.onBlur} ref={(ref) => (this.ref = ref)} style={({pressed}) => [ - this.props.isHighlighted && this.props.isUsingKeyboardMovement ? styles.emojiItemKeyboardHighlighted : {}, - this.props.isHighlighted && !this.props.isUsingKeyboardMovement ? styles.emojiItemHighlighted : {}, + this.props.isFocused ? styles.emojiItemKeyboardHighlighted : {}, + this.state.isHovered ? styles.emojiItemHighlighted : {}, Browser.isMobile() && StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)), styles.emojiItem, ]} @@ -95,9 +104,7 @@ class EmojiPickerMenuItem extends PureComponent { EmojiPickerMenuItem.propTypes = propTypes; EmojiPickerMenuItem.defaultProps = { - isHighlighted: false, isFocused: false, - isUsingKeyboardMovement: false, onHoverIn: () => {}, onHoverOut: () => {}, onFocus: () => {}, @@ -106,8 +113,4 @@ EmojiPickerMenuItem.defaultProps = { // Significantly speeds up re-renders of the EmojiPickerMenu's FlatList // by only re-rendering at most two EmojiPickerMenuItems that are highlighted/un-highlighted per user action. -export default React.memo( - EmojiPickerMenuItem, - (prevProps, nextProps) => - prevProps.isHighlighted === nextProps.isHighlighted && prevProps.emoji === nextProps.emoji && prevProps.isUsingKeyboardMovement === nextProps.isUsingKeyboardMovement, -); +export default React.memo(EmojiPickerMenuItem, (prevProps, nextProps) => prevProps.isFocused === nextProps.isFocused && prevProps.emoji === nextProps.emoji); diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 4e6564646cac..17c2ef0c1998 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -26,7 +26,9 @@ import * as ReportUtils from '../../libs/ReportUtils'; import useLocalize from '../../hooks/useLocalize'; import Permissions from '../../libs/Permissions'; import Tooltip from '../Tooltip'; +import DomUtils from '../../libs/DomUtils'; import useWindowDimensions from '../../hooks/useWindowDimensions'; +import ReportActionComposeFocusManager from '../../libs/ReportActionComposeFocusManager'; const propTypes = { /** Style for hovered state */ @@ -167,12 +169,13 @@ function OptionRowLHN(props) { if (e) { e.preventDefault(); } - + // Enable Composer to focus on clicking the same chat after opening the context menu. + ReportActionComposeFocusManager.focus(); props.onSelectRow(optionItem, popoverAnchor); }} onMouseDown={(e) => { // Allow composer blur on right click - if (!e || e.button === 2) { + if (!e) { return; } @@ -180,7 +183,11 @@ function OptionRowLHN(props) { e.preventDefault(); }} testID={optionItem.reportID} - onSecondaryInteraction={(e) => showPopover(e)} + onSecondaryInteraction={(e) => { + showPopover(e); + // Ensure that we blur the composer when opening context menu, so that only one component is focused at a time + DomUtils.getActiveElement().blur(); + }} withoutFocusOnSecondaryInteraction activeOpacity={0.8} style={[ diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index b2f15100b0b3..fefacc385116 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -150,6 +150,9 @@ const propTypes = { /** Whether the money request is a scan request */ isScanRequest: PropTypes.bool, + /** Whether we're editing a split bill */ + isEditingSplitBill: PropTypes.bool, + /** Whether we should show the amount, date, and merchant fields. */ shouldShowSmartScanFields: PropTypes.bool, @@ -553,6 +556,7 @@ function MoneyRequestConfirmationList(props) { optionHoveredStyle={canModifyParticipants ? styles.hoveredComponentBG : {}} footerContent={footerContent} listStyles={props.listStyles} + shouldAllowScrollingChildren > {props.isDistanceRequest && ( @@ -588,8 +592,8 @@ function MoneyRequestConfirmationList(props) { style={[styles.moneyRequestMenuItem, styles.mt2]} titleStyle={styles.moneyRequestConfirmationAmount} disabled={didConfirm} - brickRoadIndicator={shouldDisplayFieldError && !transaction.modifiedAmount ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={shouldDisplayFieldError && !transaction.modifiedAmount ? translate('common.error.enterAmount') : ''} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} /> )} )} {props.isDistanceRequest && ( @@ -675,16 +679,8 @@ function MoneyRequestConfirmationList(props) { }} disabled={didConfirm} interactive={!props.isReadOnly} - brickRoadIndicator={ - shouldDisplayFieldError && (transaction.modifiedMerchant === '' || transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : '' - } - error={ - shouldDisplayFieldError && (transaction.modifiedMerchant === '' || transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) - ? translate('common.error.enterMerchant') - : '' - } + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? translate('common.error.enterMerchant') : ''} /> )} {shouldShowCategories && ( diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index e35574486e21..086e1429baef 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -82,7 +82,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, }, [parentReportAction, setIsDeleteModalVisible]); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); - const isPending = TransactionUtils.isPending(transaction); + const isPending = TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction); const canModifyRequest = isActionOwner && !isSettled && !isApproved && !ReportActionsUtils.isDeletedAction(parentReportAction); diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js index edea0b8d1aba..91fd77dbea30 100644 --- a/src/components/OptionsList/BaseOptionsList.js +++ b/src/components/OptionsList/BaseOptionsList.js @@ -67,6 +67,8 @@ function BaseOptionsList({ innerRef, isRowMultilineSupported, isLoadingNewOptions, + nestedScrollEnabled, + bounces, }) { const flattenedData = useRef(); const previousSections = usePrevious(sections); @@ -255,11 +257,12 @@ function BaseOptionsList({ ) : null} )} diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js index dc716453b2a8..caabf39a41bb 100644 --- a/src/components/OptionsList/optionsListPropTypes.js +++ b/src/components/OptionsList/optionsListPropTypes.js @@ -90,6 +90,12 @@ const propTypes = { /** Whether we are loading new options */ isLoadingNewOptions: PropTypes.bool, + + /** Whether nested scroll of options is enabled, true by default */ + nestedScrollEnabled: PropTypes.bool, + + /** Whether the list should have a bounce effect on iOS */ + bounces: PropTypes.bool, }; const defaultProps = { @@ -117,6 +123,8 @@ const defaultProps = { showScrollIndicator: false, isRowMultilineSupported: false, isLoadingNewOptions: false, + nestedScrollEnabled: true, + bounces: true, }; export {propTypes, defaultProps}; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 3c9d401cdbdb..4ffddd700359 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {View} from 'react-native'; +import {ScrollView, View} from 'react-native'; import Button from '../Button'; import FixedFooter from '../FixedFooter'; import OptionsList from '../OptionsList'; @@ -432,8 +432,21 @@ class BaseOptionsSelector extends Component { isRowMultilineSupported={this.props.isRowMultilineSupported} isLoadingNewOptions={this.props.isLoadingNewOptions} shouldPreventDefaultFocusOnSelectRow={this.props.shouldPreventDefaultFocusOnSelectRow} + nestedScrollEnabled={this.props.nestedScrollEnabled} + bounces={!this.props.shouldTextInputAppearBelowOptions || !this.props.shouldAllowScrollingChildren} /> ); + + const optionsAndInputsBelowThem = ( + <> + {optionsList} + + {this.props.children} + {this.props.shouldShowTextInput && textInput} + + + ); + return ( - {this.props.shouldTextInputAppearBelowOptions ? ( - <> - {optionsList} - - {this.props.children} - {this.props.shouldShowTextInput && textInput} - - - ) : ( + {/* + * The OptionsList component uses a SectionList which uses a VirtualizedList internally. + * VirtualizedList cannot be directly nested within ScrollViews of the same orientation. + * To work around this, we wrap the OptionsList component with a horizontal ScrollView. + */} + {this.props.shouldTextInputAppearBelowOptions && this.props.shouldAllowScrollingChildren && ( + + + {optionsAndInputsBelowThem} + + + )} + + {this.props.shouldTextInputAppearBelowOptions && !this.props.shouldAllowScrollingChildren && optionsAndInputsBelowThem} + + {!this.props.shouldTextInputAppearBelowOptions && ( <> {this.props.children} diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index 9e028510e608..bfef8ca3a925 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -128,6 +128,12 @@ const propTypes = { /** Whether the text input should intercept swipes or not */ shouldTextInputInterceptSwipe: PropTypes.bool, + + /** Whether we should allow the view wrapping the nested children to be scrollable */ + shouldAllowScrollingChildren: PropTypes.bool, + + /** Whether nested scroll of options is enabled, true by default */ + nestedScrollEnabled: PropTypes.bool, }; const defaultProps = { @@ -165,6 +171,8 @@ const defaultProps = { isRowMultilineSupported: false, initialFocusedIndex: undefined, shouldTextInputInterceptSwipe: false, + shouldAllowScrollingChildren: false, + nestedScrollEnabled: true, }; export {propTypes, defaultProps}; diff --git a/src/components/PDFView/PDFPasswordForm.js b/src/components/PDFView/PDFPasswordForm.js index 42d2202de8b7..58a4e64a28a5 100644 --- a/src/components/PDFView/PDFPasswordForm.js +++ b/src/components/PDFView/PDFPasswordForm.js @@ -50,6 +50,8 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat const [shouldShowForm, setShouldShowForm] = useState(false); const textInputRef = useRef(null); + const focusTimeoutRef = useRef(null); + const errorText = useMemo(() => { if (isPasswordInvalid) { return translate('attachmentView.passwordIncorrect'); @@ -67,7 +69,19 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat if (!textInputRef.current) { return; } - textInputRef.current.focus(); + /** + * We recommend using setTimeout to wait for the animation to finish and then focus on the input + * Relevant thread: https://expensify.slack.com/archives/C01GTK53T8Q/p1694660990479979 + */ + focusTimeoutRef.current = setTimeout(() => { + textInputRef.current.focus(); + }, CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; }, [isFocused]); const updatePassword = (newPassword) => { diff --git a/src/components/ReportActionItem/ReportActionItemImages.js b/src/components/ReportActionItem/ReportActionItemImages.js index 98c5e5133836..bd1ee6d45a07 100644 --- a/src/components/ReportActionItem/ReportActionItemImages.js +++ b/src/components/ReportActionItem/ReportActionItemImages.js @@ -49,8 +49,10 @@ const defaultProps = { */ function ReportActionItemImages({images, size, total, isHovered}) { - const numberOfShownImages = size || images.length; - const shownImages = images.slice(0, size); + // Calculate the number of images to be shown, limited by the value of 'size' (if defined) + // or the total number of images. + const numberOfShownImages = Math.min(size || images.length, images.length); + const shownImages = images.slice(0, numberOfShownImages); const remaining = (total || images.length) - size; const MAX_REMAINING = 9; diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 57331af56a03..82fbf0cb9195 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -195,9 +195,9 @@ function ReportPreview(props) { {hasReceipts && ( )} @@ -241,7 +241,7 @@ function ReportPreview(props) { onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)} enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} - style={[styles.requestPreviewBox]} + style={[styles.mt3]} anchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, @@ -253,7 +253,7 @@ function ReportPreview(props) { medium success={props.chatReport.isOwnPolicyExpenseChat} text={translate('common.submit')} - style={styles.requestPreviewBox} + style={styles.mt3} onPress={() => IOU.submitReport(props.iouReport)} /> )} diff --git a/src/components/SplashScreenHider/index.native.js b/src/components/SplashScreenHider/index.native.js index f4c234bb877d..dbfac3331484 100644 --- a/src/components/SplashScreenHider/index.native.js +++ b/src/components/SplashScreenHider/index.native.js @@ -18,6 +18,9 @@ const defaultProps = { function SplashScreenHider(props) { const {onHide} = props; + const logoSizeRatio = BootSplash.logoSizeRatio || 1; + const navigationBarHeight = BootSplash.navigationBarHeight || 0; + const opacity = useSharedValue(1); const scale = useSharedValue(1); @@ -64,15 +67,15 @@ function SplashScreenHider(props) { opacityStyle, { // Apply negative margins to center the logo on window (instead of screen) - marginBottom: -(BootSplash.navigationBarHeight || 0), + marginBottom: -navigationBarHeight, }, ]} > diff --git a/src/components/TabSelector/TabSelectorItem.js b/src/components/TabSelector/TabSelectorItem.js index 6611b8acf914..04a576f9dbf0 100644 --- a/src/components/TabSelector/TabSelectorItem.js +++ b/src/components/TabSelector/TabSelectorItem.js @@ -54,13 +54,13 @@ function TabSelectorItem({icon, title, onPress, backgroundColor, activeOpacity, )} diff --git a/src/languages/en.ts b/src/languages/en.ts index 2fb9805520a2..b321903a9781 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -479,8 +479,8 @@ export default { sidebarScreen: { buttonSearch: 'Search', buttonMySettings: 'My settings', - fabNewChat: 'Send message', - fabNewChatExplained: 'Send message (Floating action)', + fabNewChat: 'Start chat', + fabNewChatExplained: 'Start chat (Floating action)', chatPinned: 'Chat pinned', draftedMessage: 'Drafted message', listOfChatMessages: 'List of chat messages', @@ -852,6 +852,9 @@ export default { bankAccounts: 'Bank accounts', addBankAccountToSendAndReceive: 'Add a bank account to send and receive payments directly in the app.', addBankAccount: 'Add bank account', + assignedCards: 'Assigned cards', + assignedCardsDescription: 'These are cards assigned by a Workspace admin to manage company spend.', + expensifyCard: 'Expensify Card', }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/languages/es.ts b/src/languages/es.ts index 26881a44811b..51d9923a570b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -471,8 +471,8 @@ export default { sidebarScreen: { buttonSearch: 'Buscar', buttonMySettings: 'Mi configuración', - fabNewChat: 'Enviar mensaje', - fabNewChatExplained: 'Enviar mensaje', + fabNewChat: 'Iniciar chat', + fabNewChatExplained: 'Iniciar chat', chatPinned: 'Chat fijado', draftedMessage: 'Mensaje borrador', listOfChatMessages: 'Lista de mensajes del chat', @@ -848,6 +848,9 @@ export default { bankAccounts: 'Cuentas bancarias', addBankAccountToSendAndReceive: 'Añade una cuenta bancaria para enviar y recibir pagos directamente en la aplicación.', addBankAccount: 'Agregar cuenta bancaria', + assignedCards: 'Tarjetas asignadas', + assignedCardsDescription: 'Son tarjetas asignadas por un administrador del Espacio de Trabajo para gestionar los gastos de la empresa.', + expensifyCard: 'Tarjeta Expensify', }, cardPage: { expensifyCard: 'Tarjeta Expensify', diff --git a/src/libs/BootSplash/index.native.ts b/src/libs/BootSplash/index.native.ts index 0790b4de89bc..307d0d62c8dd 100644 --- a/src/libs/BootSplash/index.native.ts +++ b/src/libs/BootSplash/index.native.ts @@ -11,5 +11,6 @@ function hide(): Promise { export default { hide, getVisibilityStatus: BootSplash.getVisibilityStatus, + logoSizeRatio: BootSplash.logoSizeRatio || 1, navigationBarHeight: BootSplash.navigationBarHeight || 0, }; diff --git a/src/libs/BootSplash/index.ts b/src/libs/BootSplash/index.ts index 24842fe631f4..e58763039129 100644 --- a/src/libs/BootSplash/index.ts +++ b/src/libs/BootSplash/index.ts @@ -30,5 +30,6 @@ function getVisibilityStatus(): Promise { export default { hide, getVisibilityStatus, + logoSizeRatio: 1, navigationBarHeight: 0, }; diff --git a/src/libs/BootSplash/types.ts b/src/libs/BootSplash/types.ts index 2329d5315817..b50b5a3397aa 100644 --- a/src/libs/BootSplash/types.ts +++ b/src/libs/BootSplash/types.ts @@ -1,6 +1,7 @@ type VisibilityStatus = 'visible' | 'hidden'; type BootSplashModule = { + logoSizeRatio: number; navigationBarHeight: number; hide: () => Promise; getVisibilityStatus: () => Promise; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index c0a496907149..9ac1362e3e47 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -73,8 +73,8 @@ function getCompanyCards(cardList: {string: Card}) { */ function getDomainCards(cardList: Record) { // eslint-disable-next-line you-dont-need-lodash-underscore/filter - const activeCards = lodash.filter(cardList, (card) => [2, 3, 4, 7].includes(card.state)); - return lodash.groupBy(activeCards, (card) => card.domainName.toLowerCase()); + const activeCards = lodash.filter(cardList, (card) => (CONST.EXPENSIFY_CARD.ACTIVE_STATES as ReadonlyArray).includes(card.state)); + return lodash.groupBy(activeCards, (card) => card.domainName); } /** diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js index af498831f4a4..a44a69f087ab 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -426,7 +426,7 @@ function suggestEmojis(text, lang, limit = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMO * @returns {Number} */ const getPreferredSkinToneIndex = (val) => { - if (!_.isNull(val) && Number.isInteger(Number(val))) { + if (!_.isNull(val) && !_.isUndefined(val) && Number.isInteger(Number(val))) { return val; } diff --git a/src/libs/HeaderUtils.js b/src/libs/HeaderUtils.js index ccc7bac3f571..16d375ea1124 100644 --- a/src/libs/HeaderUtils.js +++ b/src/libs/HeaderUtils.js @@ -1,5 +1,4 @@ import * as Localize from './Localize'; -import themeColors from '../styles/themes/default'; import * as Session from './actions/Session'; import * as Report from './actions/Report'; import * as Expensicons from '../components/Icon/Expensicons'; @@ -12,14 +11,12 @@ function getPinMenuItem(report) { if (!report.isPinned) { return { icon: Expensicons.Pin, - iconFill: themeColors.icon, text: Localize.translateLocal('common.pin'), onSelected: Session.checkIfActionIsAllowed(() => Report.togglePinnedState(report.reportID, report.isPinned)), }; } return { icon: Expensicons.Pin, - iconFill: themeColors.icon, text: Localize.translateLocal('common.unPin'), onSelected: Session.checkIfActionIsAllowed(() => Report.togglePinnedState(report.reportID, report.isPinned)), }; diff --git a/src/libs/IntlPolyfill/index.native.ts b/src/libs/IntlPolyfill/index.native.ts index d81b9d90cbe8..9e578558faed 100644 --- a/src/libs/IntlPolyfill/index.native.ts +++ b/src/libs/IntlPolyfill/index.native.ts @@ -1,7 +1,6 @@ import polyfillNumberFormat from './polyfillNumberFormat'; import polyfillListFormat from './polyfillListFormat'; import IntlPolyfill from './types'; -import polyfillDateTimeFormat from './polyfillDateTimeFormat'; /** * Polyfill the Intl API, always performed for native devices. @@ -11,8 +10,8 @@ const intlPolyfill: IntlPolyfill = () => { require('@formatjs/intl-getcanonicallocales/polyfill'); require('@formatjs/intl-locale/polyfill'); require('@formatjs/intl-pluralrules/polyfill'); + require('@formatjs/intl-datetimeformat'); polyfillNumberFormat(); - polyfillDateTimeFormat(); polyfillListFormat(); }; diff --git a/src/libs/IntlPolyfill/index.ts b/src/libs/IntlPolyfill/index.ts index be3e392b35cd..bef12ef093e2 100644 --- a/src/libs/IntlPolyfill/index.ts +++ b/src/libs/IntlPolyfill/index.ts @@ -1,13 +1,13 @@ import polyfillNumberFormat from './polyfillNumberFormat'; import IntlPolyfill from './types'; -import polyfillDateTimeFormat from './polyfillDateTimeFormat'; /** * Polyfill the Intl API if the ICU version is old. * This ensures that the currency data is consistent across platforms and browsers. */ const intlPolyfill: IntlPolyfill = () => { + // Just need to polyfill Intl.NumberFormat for web based platforms polyfillNumberFormat(); - polyfillDateTimeFormat(); + require('@formatjs/intl-datetimeformat'); }; export default intlPolyfill; diff --git a/src/libs/IntlPolyfill/polyfillDateTimeFormat.ts b/src/libs/IntlPolyfill/polyfillDateTimeFormat.ts deleted file mode 100644 index 25983aa71f5a..000000000000 --- a/src/libs/IntlPolyfill/polyfillDateTimeFormat.ts +++ /dev/null @@ -1,52 +0,0 @@ -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '../../ONYXKEYS'; -import {Timezone} from '../../types/onyx/PersonalDetails'; -import CONST from '../../CONST'; - -let currentUserAccountID: number | undefined; -Onyx.connect({ - key: ONYXKEYS.SESSION, - callback: (val) => { - // When signed out, val is undefined - if (!val) { - return; - } - - currentUserAccountID = val.accountID; - }, -}); - -let timezone: Required = CONST.DEFAULT_TIME_ZONE; -Onyx.connect({ - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - callback: (value) => { - if (!currentUserAccountID) { - return; - } - - const personalDetailsTimezone = value?.[currentUserAccountID]?.timezone; - - timezone = { - selected: personalDetailsTimezone?.selected ?? CONST.DEFAULT_TIME_ZONE.selected, - automatic: personalDetailsTimezone?.automatic ?? CONST.DEFAULT_TIME_ZONE.automatic, - }; - }, -}); - -export default function () { - // Because JS Engines do not expose default timezone, the polyfill cannot detect local timezone that a browser is in. - // We must manually do this by getting the local timezone before adding polyfill. - const currentTimezone = timezone.automatic ? Intl.DateTimeFormat().resolvedOptions().timeZone : timezone.selected; - - require('@formatjs/intl-datetimeformat/polyfill-force'); - require('@formatjs/intl-datetimeformat/locale-data/en'); - require('@formatjs/intl-datetimeformat/locale-data/es'); - require('@formatjs/intl-datetimeformat/add-all-tz'); - - if ('__setDefaultTimeZone' in Intl.DateTimeFormat) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // eslint-disable-next-line no-underscore-dangle - Intl.DateTimeFormat.__setDefaultTimeZone(currentTimezone); - } -} diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 0869306bb491..a4d934faec43 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -117,6 +117,13 @@ const propTypes = { /** The last Onyx update ID was applied to the client */ lastUpdateIDAppliedToClient: PropTypes.number, + /** Information about any currently running demos */ + demoInfo: PropTypes.shape({ + money2020: PropTypes.shape({ + isBeginningDemo: PropTypes.bool, + }), + }), + ...windowDimensionsPropTypes, }; @@ -127,6 +134,7 @@ const defaultProps = { }, lastOpenedPublicRoomID: null, lastUpdateIDAppliedToClient: null, + demoInfo: {}, }; class AuthScreens extends React.Component { @@ -169,6 +177,10 @@ class AuthScreens extends React.Component { App.setUpPoliciesAndNavigate(this.props.session, !this.props.isSmallScreenWidth); App.redirectThirdPartyDesktopSignIn(); + // Check if we should be running any demos immediately after signing in. + if (lodashGet(this.props.demoInfo, 'money2020.isBeginningDemo', false)) { + Navigation.navigate(ROUTES.MONEY2020, CONST.NAVIGATION.TYPE.FORCED_UP); + } if (this.props.lastOpenedPublicRoomID) { // Re-open the last opened public room if the user logged in from a public room link Report.openLastOpenedPublicRoom(this.props.lastOpenedPublicRoomID); @@ -299,6 +311,11 @@ class AuthScreens extends React.Component { options={defaultScreenOptions} component={DemoSetupPage} /> + void; + +type Phase = 'mount' | 'update'; + +type WithRenderTraceHOC =

>(WrappedComponent: React.ComponentType

) => React.ComponentType

>; + +type BlankHOC =

>(Component: React.ComponentType

) => React.ComponentType

; + +type SetupPerformanceObserver = () => void; +type DiffObject = (object: Record, base: Record) => Record; +type GetPerformanceMetrics = () => PerformanceEntry[]; +type PrintPerformanceMetrics = () => void; +type MarkStart = (name: string, detail?: Record) => PerformanceMark | void; +type MarkEnd = (name: string, detail?: Record) => PerformanceMark | void; +type MeasureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark: string) => void; +type MeasureTTI = (endMark: string) => void; +type TraceRender = (id: string, phase: Phase, actualDuration: number, baseDuration: number, startTime: number, commitTime: number, interactions: Set) => PerformanceMeasure | void; +type WithRenderTrace = ({id}: WrappedComponentConfig) => WithRenderTraceHOC | BlankHOC; +type SubscribeToMeasurements = (callback: PerformanceEntriesCallback) => void; + +type PerformanceModule = { + diffObject: DiffObject; + setupPerformanceObserver: SetupPerformanceObserver; + getPerformanceMetrics: GetPerformanceMetrics; + printPerformanceMetrics: PrintPerformanceMetrics; + markStart: MarkStart; + markEnd: MarkEnd; + measureFailSafe: MeasureFailSafe; + measureTTI: MeasureTTI; + traceRender: TraceRender; + withRenderTrace: WithRenderTrace; + subscribeToMeasurements: SubscribeToMeasurements; +}; + +let rnPerformance: RNPerformance; /** * Deep diff between two objects. Useful for figuring out what changed about an object from one render to the next so * that state and props updates can be optimized. - * - * @param {Object} object - * @param {Object} base - * @return {Object} */ -function diffObject(object, base) { - function changes(obj, comparisonObject) { +function diffObject(object: Record, base: Record): Record { + function changes(obj: Record, comparisonObject: Record): Record { return lodashTransform(obj, (result, value, key) => { - if (_.isEqual(value, comparisonObject[key])) { + if (isEqual(value, comparisonObject[key])) { return; } // eslint-disable-next-line no-param-reassign - result[key] = _.isObject(value) && _.isObject(comparisonObject[key]) ? changes(value, comparisonObject[key]) : value; + result[key] = isObject(value) && isObject(comparisonObject[key]) ? changes(value as Record, comparisonObject[key] as Record) : value; }); } return changes(object, base); } -const Performance = { +const Performance: PerformanceModule = { // When performance monitoring is disabled the implementations are blank diffObject, setupPerformanceObserver: () => {}, @@ -44,7 +78,11 @@ const Performance = { measureFailSafe: () => {}, measureTTI: () => {}, traceRender: () => {}, - withRenderTrace: () => (Component) => Component, + withRenderTrace: + () => + // eslint-disable-next-line @typescript-eslint/naming-convention +

>(Component: React.ComponentType

): React.ComponentType

=> + Component, subscribeToMeasurements: () => {}, }; @@ -53,20 +91,21 @@ if (Metrics.canCapturePerformanceMetrics()) { perfModule.setResourceLoggingEnabled(true); rnPerformance = perfModule.default; - Performance.measureFailSafe = (measureName, startOrMeasureOptions, endMark) => { + Performance.measureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark: string) => { try { rnPerformance.measure(measureName, startOrMeasureOptions, endMark); } catch (error) { // Sometimes there might be no start mark recorded and the measure will fail with an error - console.debug(error.message); + if (error instanceof Error) { + console.debug(error.message); + } } }; /** * Measures the TTI time. To be called when the app is considered to be interactive. - * @param {String} [endMark] Optional end mark name */ - Performance.measureTTI = (endMark) => { + Performance.measureTTI = (endMark: string) => { // Make sure TTI is captured when the app is really usable InteractionManager.runAfterInteractions(() => { requestAnimationFrame(() => { @@ -88,8 +127,8 @@ if (Metrics.canCapturePerformanceMetrics()) { performanceReported.setupDefaultFlipperReporter(); // Monitor some native marks that we want to put on the timeline - new perfModule.PerformanceObserver((list, observer) => { - list.getEntries().forEach((entry) => { + new perfModule.PerformanceObserver((list: PerformanceObserverEntryList, observer: PerformanceObserver) => { + list.getEntries().forEach((entry: PerformanceEntry) => { if (entry.name === 'nativeLaunchEnd') { Performance.measureFailSafe('nativeLaunch', 'nativeLaunchStart', 'nativeLaunchEnd'); } @@ -108,8 +147,8 @@ if (Metrics.canCapturePerformanceMetrics()) { }).observe({type: 'react-native-mark', buffered: true}); // Monitor for "_end" marks and capture "_start" to "_end" measures - new perfModule.PerformanceObserver((list) => { - list.getEntriesByType('mark').forEach((mark) => { + new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => { + list.getEntriesByType('mark').forEach((mark: PerformanceEntry) => { if (mark.name.endsWith('_end')) { const end = mark.name; const name = end.replace(/_end$/, ''); @@ -125,65 +164,64 @@ if (Metrics.canCapturePerformanceMetrics()) { }).observe({type: 'mark', buffered: true}); }; - Performance.getPerformanceMetrics = () => - _.chain([ + Performance.getPerformanceMetrics = (): PerformanceEntry[] => + [ ...rnPerformance.getEntriesByName('nativeLaunch'), ...rnPerformance.getEntriesByName('runJsBundle'), ...rnPerformance.getEntriesByName('jsBundleDownload'), ...rnPerformance.getEntriesByName('TTI'), ...rnPerformance.getEntriesByName('regularAppStart'), ...rnPerformance.getEntriesByName('appStartedToReady'), - ]) - .filter((entry) => entry.duration > 0) - .value(); + ].filter((entry) => entry.duration > 0); /** * Outputs performance stats. We alert these so that they are easy to access in release builds. */ Performance.printPerformanceMetrics = () => { const stats = Performance.getPerformanceMetrics(); - const statsAsText = _.map(stats, (entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n'); + const statsAsText = stats.map((entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n'); if (stats.length > 0) { Alert.alert('Performance', statsAsText); } }; - Performance.subscribeToMeasurements = (callback) => { - new perfModule.PerformanceObserver((list) => { + Performance.subscribeToMeasurements = (callback: PerformanceEntriesCallback) => { + new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => { list.getEntriesByType('measure').forEach(callback); }).observe({type: 'measure', buffered: true}); }; /** * Add a start mark to the performance entries - * @param {string} name - * @param {Object} [detail] - * @returns {PerformanceMark} */ - Performance.markStart = (name, detail) => rnPerformance.mark(`${name}_start`, {detail}); + Performance.markStart = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_start`, {detail}); /** * Add an end mark to the performance entries * A measure between start and end is captured automatically - * @param {string} name - * @param {Object} [detail] - * @returns {PerformanceMark} */ - Performance.markEnd = (name, detail) => rnPerformance.mark(`${name}_end`, {detail}); + Performance.markEnd = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_end`, {detail}); /** * Put data emitted by Profiler components on the timeline - * @param {string} id the "id" prop of the Profiler tree that has just committed - * @param {'mount'|'update'} phase either "mount" (if the tree just mounted) or "update" (if it re-rendered) - * @param {number} actualDuration time spent rendering the committed update - * @param {number} baseDuration estimated time to render the entire subtree without memoization - * @param {number} startTime when React began rendering this update - * @param {number} commitTime when React committed this update - * @param {Set} interactions the Set of interactions belonging to this update - * @returns {PerformanceMeasure} + * @param id the "id" prop of the Profiler tree that has just committed + * @param phase either "mount" (if the tree just mounted) or "update" (if it re-rendered) + * @param actualDuration time spent rendering the committed update + * @param baseDuration estimated time to render the entire subtree without memoization + * @param startTime when React began rendering this update + * @param commitTime when React committed this update + * @param interactions the Set of interactions belonging to this update */ - Performance.traceRender = (id, phase, actualDuration, baseDuration, startTime, commitTime, interactions) => + Performance.traceRender = ( + id: string, + phase: Phase, + actualDuration: number, + baseDuration: number, + startTime: number, + commitTime: number, + interactions: Set, + ): PerformanceMeasure => rnPerformance.measure(id, { start: startTime, duration: actualDuration, @@ -197,14 +235,12 @@ if (Metrics.canCapturePerformanceMetrics()) { /** * A HOC that captures render timings of the Wrapped component - * @param {object} config - * @param {string} config.id - * @returns {function(React.Component): React.FunctionComponent} */ Performance.withRenderTrace = - ({id}) => - (WrappedComponent) => { - const WithRenderTrace = forwardRef((props, ref) => ( + ({id}: WrappedComponentConfig) => + // eslint-disable-next-line @typescript-eslint/naming-convention +

>(WrappedComponent: React.ComponentType

): React.ComponentType

> => { + const WithRenderTrace: React.ComponentType

> = forwardRef((props: P, ref) => ( )); - WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent)})`; + WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent as React.ComponentType)})`; return WithRenderTrace; }; } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 0a3c98155940..e5994b4e0c94 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1542,6 +1542,9 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip if (_.isEmpty(linkedTransaction)) { return reportActionMessage; } + if (TransactionUtils.isReceiptBeingScanned(linkedTransaction)) { + return Localize.translateLocal('iou.receiptScanning'); + } const {amount, currency, comment} = getTransactionDetails(linkedTransaction); const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); return Localize.translateLocal('iou.didSplitAmount', {formattedAmount, comment}); @@ -3432,8 +3435,12 @@ function getMoneyRequestOptions(report, reportParticipants) { // User created policy rooms and default rooms like #admins or #announce will always have the Split Bill option // unless there are no participants at all (e.g. #admins room for a policy with only 1 admin) // DM chats will have the Split Bill option only when there are at least 3 people in the chat. - // There is no Split Bill option for Workspace chats, IOU or Expense reports which are threads - if ((isChatRoom(report) && participants.length > 0) || (hasMultipleParticipants && !isPolicyExpenseChat(report) && !isMoneyRequestReport(report)) || isControlPolicyExpenseChat(report)) { + // There is no Split Bill option for IOU or Expense reports which are threads + if ( + (isChatRoom(report) && participants.length > 0) || + (hasMultipleParticipants && !isPolicyExpenseChat(report) && !isMoneyRequestReport(report)) || + (isControlPolicyExpenseChat(report) && report.isOwnPolicyExpenseChat) + ) { return [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]; } @@ -3589,7 +3596,8 @@ function shouldDisableWriteActions(report) { * @returns {String} */ function getOriginalReportID(reportID, reportAction) { - return isThreadFirstChat(reportAction, reportID) ? lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, 'parentReportID']) : reportID; + const currentReportAction = ReportActionsUtils.getReportAction(reportID, reportAction.reportActionID); + return isThreadFirstChat(reportAction, reportID) && _.isEmpty(currentReportAction) ? lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, 'parentReportID']) : reportID; } /** diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 7a32db660021..314a1d63760e 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -347,17 +347,17 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread || result.isTaskReport) && !result.isArchivedRoom) { const lastAction = visibleReportActionItems[report.reportID]; - if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.RENAMED) { + if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { const newName = lodashGet(lastAction, 'originalMessage.newName', ''); result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName}); - } else if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED) { + } else if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED) { result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.reopened')}`; - } else if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) { + } else if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) { result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.completed')}`; - } else if (lodashGet(lastAction, 'actionName', '') !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { + } else if (lastAction && lastAction.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`; } else { - result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); + result.alternateText = lastAction && lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } } else { if (!lastMessageText) { diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 7746e73370ba..6a45bef5780b 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -87,7 +87,7 @@ function hasReceipt(transaction: Transaction | undefined | null): boolean { return !!transaction?.receipt?.state || hasEReceipt(transaction); } -function areRequiredFieldsEmpty(transaction: Transaction): boolean { +function isMerchantMissing(transaction: Transaction) { const isMerchantEmpty = transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === ''; @@ -97,10 +97,19 @@ function areRequiredFieldsEmpty(transaction: Transaction): boolean { transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.modifiedMerchant === ''; - const isModifiedAmountEmpty = !transaction.modifiedAmount || transaction.modifiedAmount === 0; - const isModifiedCreatedEmpty = !transaction.modifiedCreated || transaction.modifiedCreated === ''; + return isMerchantEmpty && isModifiedMerchantEmpty; +} + +function isAmountMissing(transaction: Transaction) { + return transaction.amount === 0 && (!transaction.modifiedAmount || transaction.modifiedAmount === 0); +} - return (isModifiedMerchantEmpty && isMerchantEmpty) || (isModifiedAmountEmpty && transaction.amount === 0) || (isModifiedCreatedEmpty && transaction.created === ''); +function isCreatedMissing(transaction: Transaction) { + return transaction.created === '' && (!transaction.created || transaction.modifiedCreated === ''); +} + +function areRequiredFieldsEmpty(transaction: Transaction): boolean { + return isMerchantMissing(transaction) || isAmountMissing(transaction) || isCreatedMissing(transaction); } /** @@ -472,6 +481,9 @@ export { isPending, isPosted, getWaypoints, + isAmountMissing, + isMerchantMissing, + isCreatedMissing, areRequiredFieldsEmpty, hasMissingSmartscanFields, getWaypointIndex, diff --git a/src/libs/actions/DemoActions.js b/src/libs/actions/DemoActions.js new file mode 100644 index 000000000000..29c983c35262 --- /dev/null +++ b/src/libs/actions/DemoActions.js @@ -0,0 +1,70 @@ +import Config from 'react-native-config'; +import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import * as API from '../API'; +import * as ReportUtils from '../ReportUtils'; +import Navigation from '../Navigation/Navigation'; +import ROUTES from '../../ROUTES'; +import ONYXKEYS from '../../ONYXKEYS'; + +let currentUserEmail; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (val) => { + currentUserEmail = lodashGet(val, 'email', ''); + }, +}); + +function runMoney2020Demo() { + // Try to navigate to existing demo chat if it exists in Onyx + const money2020AccountID = Number(Config ? Config.EXPENSIFY_ACCOUNT_ID_MONEY2020 : 15864555); + const existingChatReport = ReportUtils.getChatByParticipants([money2020AccountID]); + if (existingChatReport) { + // We must call goBack() to remove the demo route from nav history + Navigation.goBack(); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(existingChatReport.reportID)); + return; + } + + // We use makeRequestWithSideEffects here because we need to get the chat report ID to navigate to it after it's created + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects('CreateChatReport', { + emailList: `${currentUserEmail},money2020@expensify.com`, + activationConference: 'money2020', + }).then((response) => { + // If there's no response or no reportID in the response, navigate the user home so user doesn't get stuck. + if (!response || !response.reportID) { + Navigation.goBack(); + Navigation.navigate(ROUTES.HOME); + return; + } + + // Get reportID & navigate to it + // Note: We must call goBack() to remove the demo route from history + const chatReportID = response.reportID; + Navigation.goBack(); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chatReportID)); + }); +} + +/** + * Runs code for specific demos, based on the provided URL + * + * @param {String} url - URL user is navigating to via deep link (or regular link in web) + */ +function runDemoByURL(url = '') { + const cleanUrl = (url || '').toLowerCase(); + + if (cleanUrl.endsWith(ROUTES.MONEY2020)) { + Onyx.set(ONYXKEYS.DEMO_INFO, { + money2020: { + isBeginningDemo: true, + }, + }); + } else { + // No demo is being run, so clear out demo info + Onyx.set(ONYXKEYS.DEMO_INFO, null); + } +} + +export {runMoney2020Demo, runDemoByURL}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index a95d69243ec8..4e3fc91fc4d9 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1303,7 +1303,18 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co const receiptObject = {state, source}; // ReportID is -2 (aka "deleted") on the group transaction - const splitTransaction = TransactionUtils.buildOptimisticTransaction(0, CONST.CURRENCY.USD, CONST.REPORT.SPLIT_REPORTID, comment, '', '', '', '', receiptObject, filename); + const splitTransaction = TransactionUtils.buildOptimisticTransaction( + 0, + CONST.CURRENCY.USD, + CONST.REPORT.SPLIT_REPORTID, + comment, + '', + '', + '', + CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + receiptObject, + filename, + ); // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat const splitChatCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); @@ -1419,7 +1430,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, [splitIOUReportAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateFailureMessage'), + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, }, @@ -1688,15 +1699,23 @@ function completeSplitBill(chatReportID, reportAction, updatedTransaction, sessi failureData.push(...oneOnOneFailureData); }); + const { + amount: transactionAmount, + currency: transactionCurrency, + created: transactionCreated, + merchant: transactionMerchant, + comment: transactionComment, + } = ReportUtils.getTransactionDetails(updatedTransaction); + API.write( 'CompleteSplitBill', { transactionID, - amount: updatedTransaction.modifiedAmount, - currency: updatedTransaction.modifiedCurrency, - created: updatedTransaction.modifiedCreated, - merchant: updatedTransaction.modifiedMerchant, - comment: updatedTransaction.comment.comment, + amount: transactionAmount, + currency: transactionCurrency, + created: transactionCreated, + merchant: transactionMerchant, + comment: transactionComment, splits: JSON.stringify(splits), }, {optimisticData, successData, failureData}, diff --git a/src/libs/actions/Timing.js b/src/libs/actions/Timing.ts similarity index 76% rename from src/libs/actions/Timing.js rename to src/libs/actions/Timing.ts index 2be2cdc6fa63..13f40bab87c9 100644 --- a/src/libs/actions/Timing.js +++ b/src/libs/actions/Timing.ts @@ -4,15 +4,20 @@ import Firebase from '../Firebase'; import * as API from '../API'; import Log from '../Log'; -let timestampData = {}; +type TimestampData = { + startTime: number; + shouldUseFirebase: boolean; +}; + +let timestampData: Record = {}; /** * Start a performance timing measurement * - * @param {String} eventName - * @param {Boolean} shouldUseFirebase - adds an additional trace in Firebase + * @param eventName + * @param shouldUseFirebase - adds an additional trace in Firebase */ -function start(eventName, shouldUseFirebase = false) { +function start(eventName: string, shouldUseFirebase = false) { timestampData[eventName] = {startTime: Date.now(), shouldUseFirebase}; if (!shouldUseFirebase) { @@ -25,11 +30,11 @@ function start(eventName, shouldUseFirebase = false) { /** * End performance timing. Measure the time between event start/end in milliseconds, and push to Grafana * - * @param {String} eventName - event name used as timestamp key - * @param {String} [secondaryName] - optional secondary event name, passed to grafana - * @param {number} [maxExecutionTime] - optional amount of time (ms) to wait before logging a warn + * @param eventName - event name used as timestamp key + * @param [secondaryName] - optional secondary event name, passed to grafana + * @param [maxExecutionTime] - optional amount of time (ms) to wait before logging a warn */ -function end(eventName, secondaryName = '', maxExecutionTime = 0) { +function end(eventName: string, secondaryName = '', maxExecutionTime = 0) { if (!timestampData[eventName]) { return; } diff --git a/src/libs/localFileDownload/index.android.js b/src/libs/localFileDownload/index.android.ts similarity index 88% rename from src/libs/localFileDownload/index.android.js rename to src/libs/localFileDownload/index.android.ts index b3e39e7a7a53..ad13b5c5cfa7 100644 --- a/src/libs/localFileDownload/index.android.js +++ b/src/libs/localFileDownload/index.android.ts @@ -1,15 +1,13 @@ import RNFetchBlob from 'react-native-blob-util'; import * as FileUtils from '../fileDownload/FileUtils'; +import LocalFileDownload from './types'; /** * Writes a local file to the app's internal directory with the given fileName * and textContent, so we're able to copy it to the Android public download dir. * After the file is copied, it is removed from the internal dir. - * - * @param {String} fileName - * @param {String} textContent */ -export default function localFileDownload(fileName, textContent) { +const localFileDownload: LocalFileDownload = (fileName, textContent) => { const newFileName = FileUtils.appendTimeToFileName(fileName); const dir = RNFetchBlob.fs.dirs.DocumentDir; const path = `${dir}/${newFileName}.txt`; @@ -34,4 +32,6 @@ export default function localFileDownload(fileName, textContent) { RNFetchBlob.fs.unlink(path); }); }); -} +}; + +export default localFileDownload; diff --git a/src/libs/localFileDownload/index.ios.js b/src/libs/localFileDownload/index.ios.ts similarity index 82% rename from src/libs/localFileDownload/index.ios.js rename to src/libs/localFileDownload/index.ios.ts index 1241f5a535db..3597ea5f6d3c 100644 --- a/src/libs/localFileDownload/index.ios.js +++ b/src/libs/localFileDownload/index.ios.ts @@ -1,16 +1,14 @@ import {Share} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; import * as FileUtils from '../fileDownload/FileUtils'; +import LocalFileDownload from './types'; /** * Writes a local file to the app's internal directory with the given fileName * and textContent, so we're able to share it using iOS' share API. * After the file is shared, it is removed from the internal dir. - * - * @param {String} fileName - * @param {String} textContent */ -export default function localFileDownload(fileName, textContent) { +const localFileDownload: LocalFileDownload = (fileName, textContent) => { const newFileName = FileUtils.appendTimeToFileName(fileName); const dir = RNFetchBlob.fs.dirs.DocumentDir; const path = `${dir}/${newFileName}.txt`; @@ -20,4 +18,6 @@ export default function localFileDownload(fileName, textContent) { RNFetchBlob.fs.unlink(path); }); }); -} +}; + +export default localFileDownload; diff --git a/src/libs/localFileDownload/index.js b/src/libs/localFileDownload/index.ts similarity index 77% rename from src/libs/localFileDownload/index.js rename to src/libs/localFileDownload/index.ts index 427928834c9c..7b9b4973d5c1 100644 --- a/src/libs/localFileDownload/index.js +++ b/src/libs/localFileDownload/index.ts @@ -1,18 +1,18 @@ import * as FileUtils from '../fileDownload/FileUtils'; +import LocalFileDownload from './types'; /** * Creates a Blob with the given fileName and textContent, then dynamically * creates a temporary anchor, just to programmatically click it, so the file * is downloaded by the browser. - * - * @param {String} fileName - * @param {String} textContent */ -export default function localFileDownload(fileName, textContent) { +const localFileDownload: LocalFileDownload = (fileName, textContent) => { const blob = new Blob([textContent], {type: 'text/plain'}); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.download = FileUtils.appendTimeToFileName(`${fileName}.txt`); link.href = url; link.click(); -} +}; + +export default localFileDownload; diff --git a/src/libs/localFileDownload/types.ts b/src/libs/localFileDownload/types.ts new file mode 100644 index 000000000000..2086e2334d39 --- /dev/null +++ b/src/libs/localFileDownload/types.ts @@ -0,0 +1,3 @@ +type LocalFileDownload = (fileName: string, textContent: string) => void; + +export default LocalFileDownload; diff --git a/src/pages/DemoSetupPage.js b/src/pages/DemoSetupPage.js index 5d4b99a0daf9..5432bea0c806 100644 --- a/src/pages/DemoSetupPage.js +++ b/src/pages/DemoSetupPage.js @@ -1,9 +1,11 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import PropTypes from 'prop-types'; import {useFocusEffect} from '@react-navigation/native'; import FullScreenLoadingIndicator from '../components/FullscreenLoadingIndicator'; import Navigation from '../libs/Navigation/Navigation'; import ROUTES from '../ROUTES'; +import CONST from '../CONST'; +import * as DemoActions from '../libs/actions/DemoActions'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -18,12 +20,16 @@ const propTypes = { * route that led the user here. Now, it's just used to route the user home so we * don't show them a "Hmm... It's not here" message (which looks broken). */ -function DemoSetupPage() { - useFocusEffect(() => { - Navigation.isNavigationReady().then(() => { - Navigation.goBack(ROUTES.HOME); - }); - }); +function DemoSetupPage(props) { + useFocusEffect( + useCallback(() => { + if (props.route.name === CONST.DEMO_PAGES.MONEY2020) { + DemoActions.runMoney2020Demo(); + } else { + Navigation.goBack(ROUTES.HOME); + } + }, [props.route.name]), + ); return ; } diff --git a/src/pages/EditRequestReceiptPage.js b/src/pages/EditRequestReceiptPage.js index 6744f027b404..54ed5a8897a4 100644 --- a/src/pages/EditRequestReceiptPage.js +++ b/src/pages/EditRequestReceiptPage.js @@ -1,5 +1,6 @@ import React, {useState} from 'react'; import PropTypes from 'prop-types'; +import {View} from 'react-native'; import ScreenWrapper from '../components/ScreenWrapper'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; import Navigation from '../libs/Navigation/Navigation'; @@ -40,17 +41,21 @@ function EditRequestReceiptPage({route, transactionID}) { testID={EditRequestReceiptPage.displayName} headerGapStyles={isDraggingOver ? [styles.receiptDropHeaderGap] : []} > - - - - + {({safeAreaPaddingBottomStyle}) => ( + + + + + + + )} ); } diff --git a/src/pages/EditSplitBillPage.js b/src/pages/EditSplitBillPage.js index 217b1a100572..d10803cd40ea 100644 --- a/src/pages/EditSplitBillPage.js +++ b/src/pages/EditSplitBillPage.js @@ -37,11 +37,11 @@ const propTypes = { transaction: transactionPropTypes.isRequired, /** The draft transaction that holds data to be persisted on the current transaction */ - draftTransaction: PropTypes.shape(transactionPropTypes), + draftTransaction: transactionPropTypes, }; const defaultProps = { - draftTransaction: {}, + draftTransaction: undefined, }; function EditSplitBillPage({route, transaction, draftTransaction}) { diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index bd068ad9abcc..13091ab3f845 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -23,7 +23,6 @@ import DatePicker from '../../components/DatePicker'; import Form from '../../components/Form'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../components/withCurrentUserPersonalDetails'; import * as PersonalDetails from '../../libs/actions/PersonalDetails'; -import OfflineIndicator from '../../components/OfflineIndicator'; const propTypes = { ...withLocalizePropTypes, @@ -148,6 +147,7 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP if (!_.isEmpty(walletAdditionalDetails.questions)) { return ( - diff --git a/src/pages/EnablePayments/EnablePaymentsPage.js b/src/pages/EnablePayments/EnablePaymentsPage.js index f7ef2a174208..3f179e309a98 100644 --- a/src/pages/EnablePayments/EnablePaymentsPage.js +++ b/src/pages/EnablePayments/EnablePaymentsPage.js @@ -47,6 +47,7 @@ function EnablePaymentsPage({userWallet}) { return ( diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index fc913fb201e0..d7f8c3605564 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -100,7 +100,6 @@ function HeaderView(props) { if (ReportUtils.isCompletedTaskReport(props.report) && canModifyTask) { threeDotMenuItems.push({ icon: Expensicons.Checkmark, - iconFill: themeColors.icon, text: props.translate('task.markAsIncomplete'), onSelected: () => Task.reopenTask(props.report), }); @@ -110,7 +109,6 @@ function HeaderView(props) { if (props.report.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED && props.report.statusNum !== CONST.REPORT.STATUS.CLOSED && canModifyTask) { threeDotMenuItems.push({ icon: Expensicons.Trashcan, - iconFill: themeColors.icon, text: props.translate('common.cancel'), onSelected: () => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum), }); @@ -121,14 +119,12 @@ function HeaderView(props) { if (props.report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { threeDotMenuItems.push({ icon: Expensicons.ChatBubbles, - iconFill: themeColors.icon, text: props.translate('common.joinThread'), onSelected: () => Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false), }); } else if (props.report.notificationPreference.length) { threeDotMenuItems.push({ icon: Expensicons.ChatBubbles, - iconFill: themeColors.icon, text: props.translate('common.leaveThread'), onSelected: () => Report.leaveRoom(props.report.reportID), }); @@ -140,7 +136,6 @@ function HeaderView(props) { if (isConcierge && props.guideCalendarLink) { threeDotMenuItems.push({ icon: Expensicons.Phone, - iconFill: themeColors.icon, text: props.translate('videoChatButtonAndMenu.tooltip'), onSelected: () => { Link.openExternalLink(props.guideCalendarLink); @@ -149,7 +144,6 @@ function HeaderView(props) { } else if (!isAutomatedExpensifyAccount && !isTaskReport && !isArchivedRoom) { threeDotMenuItems.push({ icon: ZoomIcon, - iconFill: themeColors.icon, text: props.translate('videoChatButtonAndMenu.zoom'), onSelected: () => { Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL); @@ -157,7 +151,6 @@ function HeaderView(props) { }); threeDotMenuItems.push({ icon: GoogleMeetIcon, - iconFill: themeColors.icon, text: props.translate('videoChatButtonAndMenu.googleMeet'), onSelected: () => { Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL); diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 39f950b18ebe..f76f884dca52 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -246,16 +246,16 @@ function ReportActionItemMessageEdit(props) { } } emojisPresentBefore.current = emojis; - setDraft((prevDraft) => { - if (newDraftInput !== newDraft) { - const remainder = ComposerUtils.getCommonSuffixLength(prevDraft, newDraft); - setSelection({ - start: newDraft.length - remainder, - end: newDraft.length - remainder, - }); - } - return newDraft; - }); + + setDraft(newDraft); + + if (newDraftInput !== newDraft) { + const remainder = ComposerUtils.getCommonSuffixLength(newDraftInput, newDraft); + setSelection({ + start: newDraft.length - remainder, + end: newDraft.length - remainder, + }); + } // This component is rendered only when draft is set to a non-empty string. In order to prevent component // unmount when user deletes content of textarea, we set previous message instead of empty string. diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 6b232cf31f40..d3ff3070466f 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -70,7 +70,7 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority const unsubscribeOnyxModal = onyxSubscribe({ key: ONYXKEYS.MODAL, callback: (modalArg) => { - if (_.isNull(modalArg)) { + if (_.isNull(modalArg) || typeof modalArg !== 'object') { return; } modal.current = modalArg; diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 80fd1d39239d..c87d4f06e1f4 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -62,6 +62,13 @@ const propTypes = { /** Forwarded ref to FloatingActionButtonAndPopover */ innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + + /** Information about any currently running demos */ + demoInfo: PropTypes.shape({ + money2020: PropTypes.shape({ + isBeginningDemo: PropTypes.bool, + }), + }), }; const defaultProps = { onHideCreateMenu: () => {}, @@ -70,6 +77,7 @@ const defaultProps = { betas: [], isLoading: false, innerRef: null, + demoInfo: {}, }; /** @@ -152,6 +160,9 @@ function FloatingActionButtonAndPopover(props) { if (currentRoute && ![NAVIGATORS.CENTRAL_PANE_NAVIGATOR, SCREENS.HOME].includes(currentRoute.name)) { return; } + if (lodashGet(props.demoInfo, 'money2020.isBeginningDemo', false)) { + return; + } Welcome.show({routes, showCreateMenu}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.isLoading]); @@ -262,6 +273,9 @@ export default compose( isLoading: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, + demoInfo: { + key: ONYXKEYS.DEMO_INFO, + }, }), )( forwardRef((props, ref) => ( diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.js index a419af5768b5..e9cb81003979 100644 --- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.js +++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.js @@ -13,9 +13,6 @@ const propTypes = { /* Callback function passing torch/flashlight capability as bool param of the browser */ onTorchAvailability: PropTypes.func, - - /* Whether we're in a tab navigator */ - isInTabNavigator: PropTypes.bool.isRequired, }; const defaultProps = { @@ -25,7 +22,7 @@ const defaultProps = { }; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -function NavigationAwareCamera({torchOn, onTorchAvailability, ...props}, ref) { +const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, ...props}, ref) => { const trackRef = useRef(null); const isCameraActive = useIsFocused(); @@ -69,10 +66,10 @@ function NavigationAwareCamera({torchOn, onTorchAvailability, ...props}, ref) { /> ); -} +}); NavigationAwareCamera.propTypes = propTypes; NavigationAwareCamera.displayName = 'NavigationAwareCamera'; NavigationAwareCamera.defaultProps = defaultProps; -export default React.forwardRef(NavigationAwareCamera); +export default NavigationAwareCamera; diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js index 56fc311f0d46..9fb420791539 100644 --- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js +++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js @@ -3,21 +3,17 @@ import {Camera} from 'react-native-vision-camera'; import {useTabAnimation} from '@react-navigation/material-top-tabs'; import {useNavigation} from '@react-navigation/native'; import PropTypes from 'prop-types'; -import refPropTypes from '../../../components/refPropTypes'; const propTypes = { /* The index of the tab that contains this camera */ cameraTabIndex: PropTypes.number.isRequired, - /* Forwarded ref */ - forwardedRef: refPropTypes.isRequired, - /* Whether we're in a tab navigator */ isInTabNavigator: PropTypes.bool.isRequired, }; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -function NavigationAwareCamera({cameraTabIndex, forwardedRef, isInTabNavigator, ...props}) { +const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigator, ...props}, ref) => { // Get navigation to get initial isFocused value (only needed once during init!) const navigation = useNavigation(); const [isCameraActive, setIsCameraActive] = useState(navigation.isFocused()); @@ -66,21 +62,15 @@ function NavigationAwareCamera({cameraTabIndex, forwardedRef, isInTabNavigator, return ( ); -} +}); NavigationAwareCamera.propTypes = propTypes; NavigationAwareCamera.displayName = 'NavigationAwareCamera'; -export default React.forwardRef((props, ref) => ( - -)); +export default NavigationAwareCamera; diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js index 1b3052e9e72c..86844ce8c66e 100644 --- a/src/pages/iou/SplitBillDetailsPage.js +++ b/src/pages/iou/SplitBillDetailsPage.js @@ -9,12 +9,12 @@ import ONYXKEYS from '../../ONYXKEYS'; import CONST from '../../CONST'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; import personalDetailsPropType from '../personalDetailsPropType'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import compose from '../../libs/compose'; import reportActionPropTypes from '../home/report/reportActionPropTypes'; import reportPropTypes from '../reportPropTypes'; import transactionPropTypes from '../../components/transactionPropTypes'; import withReportAndReportActionOrNotFound from '../home/report/withReportAndReportActionOrNotFound'; +import useLocalize from '../../hooks/useLocalize'; import * as TransactionUtils from '../../libs/TransactionUtils'; import * as ReportUtils from '../../libs/ReportUtils'; import * as IOU from '../../libs/actions/IOU'; @@ -40,7 +40,7 @@ const propTypes = { transaction: transactionPropTypes.isRequired, /** The draft transaction that holds data to be persisited on the current transaction */ - draftTransaction: PropTypes.shape(transactionPropTypes), + draftTransaction: transactionPropTypes, /** Route params */ route: PropTypes.shape({ @@ -61,8 +61,6 @@ const propTypes = { /** Currently logged in user email */ email: PropTypes.string, }).isRequired, - - ...withLocalizePropTypes, }; const defaultProps = { @@ -72,6 +70,7 @@ const defaultProps = { }; function SplitBillDetailsPage(props) { + const {translate} = useLocalize(); const {reportID} = props.report; const reportAction = props.reportActions[`${props.route.params.reportActionID.toString()}`]; const participantAccountIDs = reportAction.originalMessage.participantAccountIDs; @@ -90,10 +89,9 @@ function SplitBillDetailsPage(props) { const payeePersonalDetails = props.personalDetails[reportAction.actorAccountID]; const participantsExcludingPayee = _.filter(participants, (participant) => participant.accountID !== reportAction.actorAccountID); - const isScanning = - TransactionUtils.hasReceipt(props.transaction) && TransactionUtils.isReceiptBeingScanned(props.transaction) && TransactionUtils.areRequiredFieldsEmpty(props.transaction); + const isScanning = TransactionUtils.hasReceipt(props.transaction) && TransactionUtils.isReceiptBeingScanned(props.transaction); const hasSmartScanFailed = TransactionUtils.hasReceipt(props.transaction) && props.transaction.receipt.state === CONST.IOU.RECEIPT_STATE.SCANFAILED; - const isEditingSplitBill = props.session.accountID === reportAction.actorAccountID && (TransactionUtils.areRequiredFieldsEmpty(props.transaction) || hasSmartScanFailed); + const isEditingSplitBill = props.session.accountID === reportAction.actorAccountID && TransactionUtils.areRequiredFieldsEmpty(props.transaction); const { amount: splitAmount, @@ -112,12 +110,18 @@ function SplitBillDetailsPage(props) { return ( - + - {isScanning && } + {isScanning && ( + + )} {Boolean(participants.length) && ( - {/* - * The MoneyRequestConfirmationList component uses a SectionList which uses a VirtualizedList internally. - * VirtualizedList cannot be directly nested within ScrollViews of the same orientation. - * To work around this, we wrap the MoneyRequestConfirmationList component with a horizontal ScrollView. - */} - - - { - const newParticipants = _.map(props.iou.participants, (participant) => { - if (participant.accountID === option.accountID) { - return {...participant, selected: !participant.selected}; - } - return participant; - }); - IOU.setMoneyRequestParticipants(newParticipants); - }} - receiptPath={props.iou.receiptPath} - receiptFilename={props.iou.receiptFilename} - iouType={iouType.current} - reportID={reportID.current} - isPolicyExpenseChat={isPolicyExpenseChat} - // The participants can only be modified when the action is initiated from directly within a group chat and not the floating-action-button. - // This is because when there is a group of people, say they are on a trip, and you have some shared expenses with some of the people, - // but not all of them (maybe someone skipped out on dinner). Then it's nice to be able to select/deselect people from the group chat bill - // split rather than forcing the user to create a new group, just for that expense. The reportID is empty, when the action was initiated from - // the floating-action-button (since it is something that exists outside the context of a report). - canModifyParticipants={!_.isEmpty(reportID.current)} - policyID={props.report.policyID} - bankAccountRoute={ReportUtils.getBankAccountRoute(props.report)} - iouMerchant={props.iou.merchant} - iouCreated={props.iou.created} - isScanRequest={isScanRequest} - isDistanceRequest={isDistanceRequest} - listStyles={[StyleUtils.getMaximumHeight(windowHeight / 3)]} - shouldShowSmartScanFields={_.isEmpty(props.iou.receiptPath)} - /> - - + { + const newParticipants = _.map(props.iou.participants, (participant) => { + if (participant.accountID === option.accountID) { + return {...participant, selected: !participant.selected}; + } + return participant; + }); + IOU.setMoneyRequestParticipants(newParticipants); + }} + receiptPath={props.iou.receiptPath} + receiptFilename={props.iou.receiptFilename} + iouType={iouType.current} + reportID={reportID.current} + isPolicyExpenseChat={isPolicyExpenseChat} + // The participants can only be modified when the action is initiated from directly within a group chat and not the floating-action-button. + // This is because when there is a group of people, say they are on a trip, and you have some shared expenses with some of the people, + // but not all of them (maybe someone skipped out on dinner). Then it's nice to be able to select/deselect people from the group chat bill + // split rather than forcing the user to create a new group, just for that expense. The reportID is empty, when the action was initiated from + // the floating-action-button (since it is something that exists outside the context of a report). + canModifyParticipants={!_.isEmpty(reportID.current)} + policyID={props.report.policyID} + bankAccountRoute={ReportUtils.getBankAccountRoute(props.report)} + iouMerchant={props.iou.merchant} + iouCreated={props.iou.created} + isScanRequest={isScanRequest} + isDistanceRequest={isDistanceRequest} + shouldShowSmartScanFields={_.isEmpty(props.iou.receiptPath)} + /> )} diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js index 7dc9ff7773de..64e6bdfb4b5b 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.js +++ b/src/pages/settings/Report/NotificationPreferencePage.js @@ -3,8 +3,6 @@ import _ from 'underscore'; import ScreenWrapper from '../../../components/ScreenWrapper'; import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import styles from '../../../styles/styles'; -import OptionsList from '../../../components/OptionsList'; import Navigation from '../../../libs/Navigation/Navigation'; import compose from '../../../libs/compose'; import withReportOrNotFound from '../../home/report/withReportOrNotFound'; @@ -14,8 +12,7 @@ import ROUTES from '../../../ROUTES'; import CONST from '../../../CONST'; import * as Report from '../../../libs/actions/Report'; import * as ReportUtils from '../../../libs/ReportUtils'; -import * as Expensicons from '../../../components/Icon/Expensicons'; -import themeColors from '../../../styles/themes/default'; +import SelectionList from '../../../components/SelectionList'; const propTypes = { ...withLocalizePropTypes, @@ -23,7 +20,6 @@ const propTypes = { /** The report for which we are setting notification preferences */ report: reportPropTypes.isRequired, }; -const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; function NotificationPreferencePage(props) { const shouldDisableNotificationPreferences = ReportUtils.isArchivedRoom(props.report); @@ -33,12 +29,7 @@ function NotificationPreferencePage(props) { value: preference, text: props.translate(`notificationPreferencesPage.notificationPreferences.${preference}`), keyForList: preference, - - // Include the green checkmark icon to indicate the currently selected value - customIcon: preference === props.report.notificationPreference ? greenCheckmark : null, - - // This property will make the currently selected value have bold text - boldStyle: preference === props.report.notificationPreference, + isSelected: preference === props.report.notificationPreference, }), ); @@ -52,18 +43,10 @@ function NotificationPreferencePage(props) { title={props.translate('notificationPreferencesPage.header')} onBackButtonPress={() => Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(props.report.reportID))} /> - Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, option.value, true)} - hideSectionHeaders - optionHoveredStyle={{ - ...styles.hoveredComponentBG, - ...styles.mhn5, - ...styles.ph5, - }} - shouldHaveOptionSeparator - shouldDisableRowInnerPadding - contentContainerStyles={[styles.ph5]} + initiallyFocusedOptionKey={_.find(notificationPreferenceOptions, (locale) => locale.isSelected).keyForList} /> diff --git a/src/pages/settings/Report/WriteCapabilityPage.js b/src/pages/settings/Report/WriteCapabilityPage.js index 9c4814902117..1558d98a830a 100644 --- a/src/pages/settings/Report/WriteCapabilityPage.js +++ b/src/pages/settings/Report/WriteCapabilityPage.js @@ -6,20 +6,17 @@ import CONST from '../../../CONST'; import ScreenWrapper from '../../../components/ScreenWrapper'; import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import styles from '../../../styles/styles'; -import OptionsList from '../../../components/OptionsList'; import Navigation from '../../../libs/Navigation/Navigation'; import compose from '../../../libs/compose'; import withReportOrNotFound from '../../home/report/withReportOrNotFound'; import reportPropTypes from '../../reportPropTypes'; import ROUTES from '../../../ROUTES'; import * as Report from '../../../libs/actions/Report'; -import * as Expensicons from '../../../components/Icon/Expensicons'; -import themeColors from '../../../styles/themes/default'; import * as ReportUtils from '../../../libs/ReportUtils'; import FullPageNotFoundView from '../../../components/BlockingViews/FullPageNotFoundView'; import * as PolicyUtils from '../../../libs/PolicyUtils'; import {policyPropTypes, policyDefaultProps} from '../../workspace/withPolicy'; +import SelectionList from '../../../components/SelectionList'; const propTypes = { ...withLocalizePropTypes, @@ -33,19 +30,12 @@ const defaultProps = { ...policyDefaultProps, }; -const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; - function WriteCapabilityPage(props) { const writeCapabilityOptions = _.map(CONST.REPORT.WRITE_CAPABILITIES, (value) => ({ value, text: props.translate(`writeCapabilityPage.writeCapability.${value}`), keyForList: value, - - // Include the green checkmark icon to indicate the currently selected value - customIcon: value === (props.report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL) ? greenCheckmark : null, - - // This property will make the currently selected value have bold text - boldStyle: value === (props.report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL), + isSelected: value === (props.report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL), })); const isAbleToEdit = !ReportUtils.isAdminRoom(props.report) && PolicyUtils.isPolicyAdmin(props.policy) && !ReportUtils.isArchivedRoom(props.report); @@ -61,18 +51,10 @@ function WriteCapabilityPage(props) { shouldShowBackButton onBackButtonPress={() => Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(props.report.reportID))} /> - Report.updateWriteCapabilityAndNavigate(props.report, option.value)} - hideSectionHeaders - optionHoveredStyle={{ - ...styles.hoveredComponentBG, - ...styles.mhn5, - ...styles.ph5, - }} - shouldHaveOptionSeparator - shouldDisableRowInnerPadding - contentContainerStyles={[styles.ph5]} + initiallyFocusedOptionKey={_.find(writeCapabilityOptions, (locale) => locale.isSelected).keyForList} /> diff --git a/src/pages/settings/Wallet/PaymentMethodList.js b/src/pages/settings/Wallet/PaymentMethodList.js index abee54d9fcd4..2943fa9544ae 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.js +++ b/src/pages/settings/Wallet/PaymentMethodList.js @@ -23,6 +23,10 @@ import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import * as PaymentMethods from '../../../libs/actions/PaymentMethods'; import Log from '../../../libs/Log'; import stylePropTypes from '../../../styles/stylePropTypes'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; +import getBankIcon from '../../../components/Icon/BankIcons'; +import assignedCardPropTypes from './assignedCardPropTypes'; const propTypes = { /** What to do when a menu item is pressed */ @@ -31,12 +35,21 @@ const propTypes = { /** List of bank accounts */ bankAccountList: PropTypes.objectOf(bankAccountPropTypes), + /** List of assigned cards */ + cardList: PropTypes.objectOf(assignedCardPropTypes), + /** List of user's cards */ fundList: PropTypes.objectOf(cardPropTypes), + /** Whether the add bank account button should be shown on the list */ + shouldShowAddBankAccount: PropTypes.bool, + /** Whether the add Payment button be shown on the list */ shouldShowAddPaymentMethodButton: PropTypes.bool, + /** Whether the assigned cards should be shown on the list */ + shouldShowAssignedCards: PropTypes.bool, + /** Whether the empty list message should be shown when the list is empty */ shouldShowEmptyListMessage: PropTypes.bool, @@ -84,13 +97,16 @@ const propTypes = { const defaultProps = { bankAccountList: {}, + cardList: {}, fundList: null, userWallet: { walletLinkedAccountID: 0, walletLinkedAccountType: '', }, isLoadingPaymentMethods: true, + shouldShowAddBankAccount: true, shouldShowAddPaymentMethodButton: true, + shouldShowAssignedCards: false, shouldShowEmptyListMessage: true, filterType: '', actionPaymentMethodType: '', @@ -161,6 +177,7 @@ function PaymentMethodList({ activePaymentMethodID, bankAccountList, buttonRef, + cardList, fundList, filterType, isLoadingPaymentMethods, @@ -171,13 +188,30 @@ function PaymentMethodList({ shouldEnableScroll, shouldShowSelectedState, shouldShowAddPaymentMethodButton, + shouldShowAddBankAccount, shouldShowEmptyListMessage, + shouldShowAssignedCards, selectedMethodID, style, translate, }) { const filteredPaymentMethods = useMemo(() => { const paymentCardList = fundList || {}; + + if (shouldShowAssignedCards) { + const assignedCards = _.filter(cardList, (card) => CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state)); + return _.map(assignedCards, (card) => { + const icon = getBankIcon(card.bank); + return { + key: card.key, + title: translate('walletPage.expensifyCard'), + description: card.domainName, + onPress: () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(card.domainName)), + ...icon, + }; + }); + } + // Hide any billing cards that are not P2P debit cards for now because you cannot make them your default method, or delete them const filteredCardList = _.filter(paymentCardList, (card) => card.accountData.additionalData.isP2PDebitCard); let combinedPaymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList, filteredCardList); @@ -204,14 +238,14 @@ function PaymentMethodList({ })); return combinedPaymentMethods; - }, [actionPaymentMethodType, activePaymentMethodID, bankAccountList, filterType, network, onPress, fundList]); + }, [fundList, shouldShowAssignedCards, bankAccountList, filterType, network.isOffline, cardList, translate, actionPaymentMethodType, activePaymentMethodID, onPress]); /** * Render placeholder when there are no payments methods * * @return {React.Component} */ - const renderListEmptyComponent = useCallback(() => {translate('paymentMethodList.addFirstPaymentMethod')}, [translate]); + const renderListEmptyComponent = () => {translate('paymentMethodList.addFirstPaymentMethod')}; const renderListFooterComponent = useCallback( () => ( @@ -252,12 +286,13 @@ function PaymentMethodList({ iconWidth={item.iconSize} badgeText={shouldShowDefaultBadge(filteredPaymentMethods, item.isDefault) ? translate('paymentMethodList.defaultPaymentMethod') : null} wrapperStyle={styles.paymentMethod} + shouldShowRightIcon={shouldShowAssignedCards} shouldShowSelectedState={shouldShowSelectedState} isSelected={selectedMethodID === item.methodID} /> ), - [filteredPaymentMethods, translate, shouldShowSelectedState, selectedMethodID], + [filteredPaymentMethods, translate, shouldShowAssignedCards, shouldShowSelectedState, selectedMethodID], ); return ( @@ -266,9 +301,9 @@ function PaymentMethodList({ data={filteredPaymentMethods} renderItem={renderItem} keyExtractor={(item) => item.key} - ListEmptyComponent={shouldShowEmptyListMessage ? renderListEmptyComponent(translate) : null} + ListEmptyComponent={shouldShowEmptyListMessage ? renderListEmptyComponent : null} ListHeaderComponent={listHeaderComponent} - ListFooterComponent={renderListFooterComponent} + ListFooterComponent={shouldShowAddBankAccount ? renderListFooterComponent : null} onContentSizeChange={onListContentSizeChange} scrollEnabled={shouldEnableScroll} style={style} @@ -307,6 +342,9 @@ export default compose( bankAccountList: { key: ONYXKEYS.BANK_ACCOUNT_LIST, }, + cardList: { + key: ONYXKEYS.CARD_LIST, + }, fundList: { key: ONYXKEYS.FUND_LIST, }, diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.js b/src/pages/settings/Wallet/WalletPage/WalletPage.js index 4115335bbcd5..ec9ff537189e 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.js +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.js @@ -373,6 +373,27 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen )} + {hasAssignedCard ? ( + + {}} + /> + + ) : null} taskTitleMenuItem: { ...writingDirection.ltr, ...headlineFont, - ...flex.flexWrap, - ...flex.flex1, fontSize: variables.fontSizeXLarge, maxWidth: '100%', ...wordBreak.breakWord, @@ -3690,7 +3688,7 @@ const styles = (theme: ThemeDefault) => reportPreviewBox: { backgroundColor: theme.cardBG, borderRadius: variables.componentBorderRadiusLarge, - maxWidth: variables.sideBarWidth, + maxWidth: variables.reportPreviewMaxWidth, width: '100%', }, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index b3a074234828..e7efcf4052d4 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -167,7 +167,7 @@ export default { eReceiptWordmarkWidth: 86, eReceiptBGHeight: 540, eReceiptBGHWidth: 335, - reportPreviewMaxWidth: 302, + reportPreviewMaxWidth: 335, reportActionImagesSingleImageHeight: 147, reportActionImagesDoubleImageHeight: 138, reportActionImagesMultipleImageHeight: 110, diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 3df3b137bab3..63fd7a0dd78b 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -1638,7 +1638,7 @@ describe('actions/IOU', () => { expect(resultAction.message).toEqual(REPORT_ACTION.message); expect(resultAction.person).toEqual(REPORT_ACTION.person); - expect(resultAction.pendingAction).toBeNull(); + expect(resultAction.pendingAction).toBeUndefined(); await waitForBatchedUpdates(); @@ -1647,7 +1647,7 @@ describe('actions/IOU', () => { // Then check the loading state of our action const resultActionAfterUpdate = reportActions[reportActionID]; - expect(resultActionAfterUpdate.pendingAction).toBeNull(); + expect(resultActionAfterUpdate.pendingAction).toBeUndefined(); // When we attempt to delete a money request from the IOU report fetch.pause(); @@ -1818,7 +1818,7 @@ describe('actions/IOU', () => { // Then the report should have 2 actions expect(_.size(reportActions)).toBe(2); const resultActionAfter = reportActions[reportActionID]; - expect(resultActionAfter.pendingAction).toBeNull(); + expect(resultActionAfter.pendingAction).toBeUndefined(); fetch.pause(); // When deleting money request @@ -1903,7 +1903,7 @@ describe('actions/IOU', () => { expect(resultAction.message).toEqual(REPORT_ACTION.message); expect(resultAction.person).toEqual(REPORT_ACTION.person); - expect(resultAction.pendingAction).toBeNull(); + expect(resultAction.pendingAction).toBeUndefined(); await waitForBatchedUpdates(); @@ -1913,7 +1913,7 @@ describe('actions/IOU', () => { let resultActionAfterUpdate = reportActions[reportActionID]; // Verify that our action is no longer in the loading state - expect(resultActionAfterUpdate.pendingAction).toBeNull(); + expect(resultActionAfterUpdate.pendingAction).toBeUndefined(); await waitForBatchedUpdates(); @@ -1935,7 +1935,7 @@ describe('actions/IOU', () => { expect(resultAction.message).toEqual(REPORT_ACTION.message); expect(resultAction.person).toEqual(REPORT_ACTION.person); - expect(resultAction.pendingAction).toBeNull(); + expect(resultAction.pendingAction).toBeUndefined(); await waitForBatchedUpdates(); @@ -1945,7 +1945,7 @@ describe('actions/IOU', () => { resultActionAfterUpdate = reportActions[reportActionID]; // Verify that our action is no longer in the loading state - expect(resultActionAfterUpdate.pendingAction).toBeNull(); + expect(resultActionAfterUpdate.pendingAction).toBeUndefined(); fetch.pause(); // When we delete the money request diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js index c7ef68547cdc..68a50fe4f130 100644 --- a/tests/actions/ReportTest.js +++ b/tests/actions/ReportTest.js @@ -93,7 +93,7 @@ describe('actions/Report', () => { expect(resultAction.message).toEqual(REPORT_ACTION.message); expect(resultAction.person).toEqual(REPORT_ACTION.person); - expect(resultAction.pendingAction).toBeNull(); + expect(resultAction.pendingAction).toBeUndefined(); // We subscribed to the Pusher channel above and now we need to simulate a reportComment action // Pusher event so we can verify that action was handled correctly and merged into the reportActions. @@ -130,7 +130,7 @@ describe('actions/Report', () => { const resultAction = reportActions[reportActionID]; // Verify that our action is no longer in the loading state - expect(resultAction.pendingAction).toBeNull(); + expect(resultAction.pendingAction).toBeUndefined(); }); }); @@ -608,7 +608,7 @@ describe('actions/Report', () => { // Expect the reaction to have null where the users reaction used to be expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`); const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`]; - expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeNull(); + expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined(); }) .then(() => { reportAction = _.first(_.values(reportActions)); @@ -650,7 +650,7 @@ describe('actions/Report', () => { // Expect the reaction to have null where the users reaction used to be expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`); const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`]; - expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeNull(); + expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined(); }); }); }); @@ -717,7 +717,7 @@ describe('actions/Report', () => { // Expect the reaction to have null where the users reaction used to be expect(reportActionsReactions).toHaveProperty(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${resultAction.reportActionID}`); const reportActionReaction = reportActionsReactions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${resultAction.reportActionID}`]; - expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeNull(); + expect(reportActionReaction[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined(); }); }); }); diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js index bed273213c90..d0e7f19d3d3f 100644 --- a/tests/unit/MigrationTest.js +++ b/tests/unit/MigrationTest.js @@ -37,18 +37,12 @@ describe('Migrations', () => { }) .then(PersonalDetailsByAccountID) .then(() => { - expect(LogSpy).toHaveBeenCalledWith( - `[Migrate Onyx] Skipped migration PersonalDetailsByAccountID for ${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1 because there were no reportActions`, - ); - expect(LogSpy).toHaveBeenCalledWith( - `[Migrate Onyx] Skipped migration PersonalDetailsByAccountID for ${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2 because there were no reportActions`, - ); const connectionID = Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, callback: (allReportActions) => { Onyx.disconnect(connectionID); - _.each(allReportActions, (reportActionsForReport) => expect(reportActionsForReport).toBeNull()); + _.each(allReportActions, (reportActionsForReport) => expect(reportActionsForReport).toBeUndefined()); }, }); })); @@ -377,8 +371,8 @@ describe('Migrations', () => { waitForCollectionCallback: true, callback: (allPolicyMemberLists) => { Onyx.disconnect(connectionID); - expect(allPolicyMemberLists[`${ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST}1`]).toBeNull(); - expect(allPolicyMemberLists[`${ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST}2`]).toBeNull(); + + expect(allPolicyMemberLists).toBeFalsy(); }, }); })); @@ -554,8 +548,8 @@ describe('Migrations', () => { Onyx.disconnect(connectionID); const expectedReportAction = {}; expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toMatchObject(expectedReportAction); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toMatchObject(expectedReportAction); + expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toBeUndefined(); + expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toBeUndefined(); expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toMatchObject(expectedReportAction); }, }); @@ -597,8 +591,8 @@ describe('Migrations', () => { }, }; expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toMatchObject(expectedReportAction1); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toBeNull(); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toBeNull(); + expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toBeUndefined(); + expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toBeUndefined(); expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toMatchObject(expectedReportAction4); }, }); @@ -620,10 +614,10 @@ describe('Migrations', () => { callback: (allReportActions) => { Onyx.disconnect(connectionID); const expectedReportAction = {}; - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toBeNull(); + expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toBeUndefined(); expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]).toMatchObject(expectedReportAction); expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]).toMatchObject(expectedReportAction); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toBeNull(); + expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}4`]).toBeUndefined(); }, }); }));