diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js index 31a4c6d4246a..1132c137061e 100644 --- a/.github/actions/javascript/bumpVersion/index.js +++ b/.github/actions/javascript/bumpVersion/index.js @@ -19,6 +19,7 @@ const getBuildVersion = __nccwpck_require__(4016); const BUILD_GRADLE_PATH = process.env.NODE_ENV === 'test' ? path.resolve(__dirname, '../../android/app/build.gradle') : './android/app/build.gradle'; const PLIST_PATH = './ios/NewExpensify/Info.plist'; const PLIST_PATH_TEST = './ios/NewExpensifyTests/Info.plist'; +const PLIST_PATH_NSE = './ios/NotificationServiceExtension/Info.plist'; exports.BUILD_GRADLE_PATH = BUILD_GRADLE_PATH; exports.PLIST_PATH = PLIST_PATH; @@ -90,8 +91,10 @@ exports.updateiOSVersion = function updateiOSVersion(version) { // Update Plists execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH}`); execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_TEST}`); + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_NSE}`); execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH}`); execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_TEST}`); + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_NSE}`); // Return the cfVersion so we can set the NEW_IOS_VERSION in ios.yml return cfVersion; diff --git a/.github/libs/nativeVersionUpdater.js b/.github/libs/nativeVersionUpdater.js index 07d36d823c78..ab129f4eb04a 100644 --- a/.github/libs/nativeVersionUpdater.js +++ b/.github/libs/nativeVersionUpdater.js @@ -10,6 +10,7 @@ const getBuildVersion = require('semver/functions/prerelease'); const BUILD_GRADLE_PATH = process.env.NODE_ENV === 'test' ? path.resolve(__dirname, '../../android/app/build.gradle') : './android/app/build.gradle'; const PLIST_PATH = './ios/NewExpensify/Info.plist'; const PLIST_PATH_TEST = './ios/NewExpensifyTests/Info.plist'; +const PLIST_PATH_NSE = './ios/NotificationServiceExtension/Info.plist'; exports.BUILD_GRADLE_PATH = BUILD_GRADLE_PATH; exports.PLIST_PATH = PLIST_PATH; @@ -81,8 +82,10 @@ exports.updateiOSVersion = function updateiOSVersion(version) { // Update Plists execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH}`); execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_TEST}`); + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_NSE}`); execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH}`); execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_TEST}`); + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_NSE}`); // Return the cfVersion so we can set the NEW_IOS_VERSION in ios.yml return cfVersion; diff --git a/android/app/build.gradle b/android/app/build.gradle index 395e87664e99..645f36ef876a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,8 +96,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001042300 - versionName "1.4.23-0" + versionCode 1001042400 + versionName "1.4.24-0" } flavorDimensions "default" diff --git a/docs/articles/expensify-classic/get-paid-back/Referral-Program.md b/docs/articles/expensify-classic/get-paid-back/Referral-Program.md index 4cc646c613a1..24605dd17d3f 100644 --- a/docs/articles/expensify-classic/get-paid-back/Referral-Program.md +++ b/docs/articles/expensify-classic/get-paid-back/Referral-Program.md @@ -1,56 +1,49 @@ --- -title: Expensify Referral Program -description: Send your joining link, submit a receipt or invoice, and we'll pay you if your referral adopts Expensify. +title: Earn money with Expensify referrals +description: Get paid with the Expensify referral program! Share your link, earn $250 per successful sign-up, and enjoy unlimited income potential. It’s that easy. redirect_from: articles/other/Referral-Program/ --- -# About +# Earn money with Expensify referrals -Expensify has grown thanks to our users who love Expensify so much that they tell their friends, colleagues, managers, and fellow business founders to use it, too. +Picture this: You've found Expensify and it's transformed your approach to expense management and financial organization. You love it so much that you can't help but recommend it to friends, family, and colleagues. Wouldn’t it be nice if you could get rewarded just for spreading the word? -As a thank you, every time you bring a new user into the platform who directly or indirectly leads to the adoption of a paid annual plan on Expensify, you will earn $250. +With Expensify referrals, you can. Every time someone you invite to the platform signs up for a paid annual plan on Expensify, you’ll earn $250. Think of it as a thank-you gift from us to you! -# How to get paid for referring people to Expensify +## How to get paid for Expensify referrals -1. Submit a report or invoice, or share your referral link with anyone you know who is spending too much time on expenses, or works at a company that could benefit from using Expensify. +Here are a few easy ways to get paid for Expensify friend referrals: -2. You will get $250 for any referred business that commits to an annual subscription, has 2 or more active users, and makes two monthly payments. +- Submit an expense report to your boss (even just one receipt!) +- Send an invoice to a client or customer +- Share your referral link with a friend + - To find your referral link, open your Expensify mobile app and go to **Settings > Refer a friend, earn cash! > Share invite link**. -That’s right! You can refer anyone working at any company you know. +**If the person you referred commits to an annual subscription with two or more active users and makes two monthly payments, you’ll get $250. Cha-ching!** -If their company goes on to become an Expensify customer with an annual subscription, and you are the earliest recorded referrer of a user on that company’s paid Expensify Policy, you'll get paid a referral reward. +## Who can you refer? -The best way to start is to submit any receipt to your manager (you'll get paid back and set yourself up for $250 if they start a subscription: win-win!) +You can refer anyone who might benefit from Expensify. Seriously. Anybody. -Referral rewards for the Spring/Summer 2023 campaign will be paid by direct deposit. +Know a small business owner? Refer them! An [accountant](https://use.expensify.com/accountants-program)? Refer them! A best friend from childhood who keeps losing paper receipts? Refer them! -{% include faq-begin.md %} +Plus, you can [refer an unlimited amount of new users](https://use.expensify.com/blog/earn-50000-by-referring-your-friends-to-expensify/) with the Expensify referral program, so your earning potential is truly sky-high. -- **How will I know if I am the first person to refer a company to Expensify?** +## Common questions about Expensify benefits -Successful referrers are notified after their referral pays for 2 months of an annual subscription. We will check for the earliest recorded referrer of a user on the policy, and if that is you, then we will let you know. +Still have questions about the Expensify referral program? We’ve got answers. Check out our FAQ below. -- **How will you pay me if I am successful?** +### How will I know if I am the first person to refer someone to Expensify? -In the Spring 2023 campaign, Expensify will be paying successful referrers via direct deposit to the Deposit-Only account you have on file. Referral payouts will happen once a month for the duration of the campaign. If you do not have a Deposit-Only account at the time of your referral payout, your deposit will be processed in the next batch. +You’ll know if you’re the first person to refer someone to Expensify if we reach out to let you know that they’ve successfully adopted Expensify and have paid for two months of an annual subscription. -Learn how to add a Deposit-Only account [here](https://community.expensify.com/discussion/4641/how-to-add-a-deposit-only-bank-account-both-personal-and-business). +Simply put, we check for the earliest recorded referrer of a member on the workspace, and if that’s you, then we’ll let you know. -- **I’m outside of the US, how do I get paid?** +### My referral wasn’t counted! How can I appeal? -While our referral payouts are in USD, you will be able to get paid via a Wise Borderless account. Learn more [here](https://community.expensify.com/discussion/5940/how-to-get-reimbursed-outside-the-us-with-wise-for-non-us-employees). +If you think your Expensify friend referral wasn’t counted, please send a message to concierge@expensify.com with the email of the person you referred. Our team will review the referral and get back to you. -- **My referral wasn’t counted! How can I appeal?** +## Share the Expensify love — and get paid in the process -Expensify reserves the right to modify the terms of the referral program at any time, and pays out referral bonuses for eligible companies at its own discretion. - -Please send a message to concierge@expensify.com with the billing owner of the company you have referred and our team will review the referral and get back to you. - -- **Where can I find my referral link?** - -Expensify members who are opted-in for our newsletters will have received an email containing their unique referral link. - -On the mobile app, go to **Settings** > **Invite a Friend** > **Share Invite Link** to retrieve your referral link. - -{% include faq-end.md %} +Who needs a side hustle when you have Expensify? With Expensify benefits, it’s not just about managing your expenses — it's about expanding your income too. Share your Expensify referral link now or send over an invoice to unlock unlimited earning potential. diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ff0d5c910e6e..443e35990496 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.23 + 1.4.24 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.23.0 + 1.4.24.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 80cb37367088..7756d9837e9a 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.23 + 1.4.24 CFBundleSignature ???? CFBundleVersion - 1.4.23.0 + 1.4.24.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 57421ebf9b75..ccc7422fe3b4 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -9,5 +9,9 @@ NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).NotificationService + CFBundleVersion + 1.4.23.0 + CFBundleShortVersionString + 1.4.23 - + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 11a5ee7f2e56..b1af06e89fed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.23-0", + "version": "1.4.24-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.23-0", + "version": "1.4.24-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -161,6 +161,7 @@ "@testing-library/jest-native": "5.4.1", "@testing-library/react-native": "11.5.1", "@trivago/prettier-plugin-sort-imports": "^4.2.0", + "@types/canvas-size": "^1.2.2", "@types/concurrently": "^7.0.0", "@types/jest": "^29.5.2", "@types/jest-when": "^3.5.2", @@ -20916,6 +20917,12 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/canvas-size": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/canvas-size/-/canvas-size-1.2.2.tgz", + "integrity": "sha512-yuTXFWC4tHV3lt5ZtbIP9VeeMNbDYm5mPyqaQnaMuSSx2mjsfZGXMNmHTnfdsR5qZdB6dtbaV5IP2PKv79vmKg==", + "dev": true + }, "node_modules/@types/concurrently": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@types/concurrently/-/concurrently-7.0.0.tgz", @@ -71204,6 +71211,12 @@ "@types/responselike": "^1.0.0" } }, + "@types/canvas-size": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/canvas-size/-/canvas-size-1.2.2.tgz", + "integrity": "sha512-yuTXFWC4tHV3lt5ZtbIP9VeeMNbDYm5mPyqaQnaMuSSx2mjsfZGXMNmHTnfdsR5qZdB6dtbaV5IP2PKv79vmKg==", + "dev": true + }, "@types/concurrently": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@types/concurrently/-/concurrently-7.0.0.tgz", diff --git a/package.json b/package.json index d7bc53713f21..192f42ee45fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.23-0", + "version": "1.4.24-0", "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.", @@ -209,6 +209,7 @@ "@testing-library/jest-native": "5.4.1", "@testing-library/react-native": "11.5.1", "@trivago/prettier-plugin-sort-imports": "^4.2.0", + "@types/canvas-size": "^1.2.2", "@types/concurrently": "^7.0.0", "@types/jest": "^29.5.2", "@types/jest-when": "^3.5.2", diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 89ddbdc06883..98e3856f4544 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -458,6 +458,7 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean; [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup; [ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction; + [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; [ONYXKEYS.COLLECTION.SELECTED_TAB]: string; [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 4580f3b7e4d4..e9e1054427b9 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -141,6 +141,7 @@ function AvatarWithDisplayName({ )} {!!subtitle && ( diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.tsx similarity index 67% rename from src/components/ContextMenuItem.js rename to src/components/ContextMenuItem.tsx index e7d2bda3a667..781d2f718bcf 100644 --- a/src/components/ContextMenuItem.js +++ b/src/components/ContextMenuItem.tsx @@ -1,58 +1,52 @@ -import PropTypes from 'prop-types'; +import type {ForwardedRef} from 'react'; import React, {forwardRef, useImperativeHandle} from 'react'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useWindowDimensions from '@hooks/useWindowDimensions'; import getButtonState from '@libs/getButtonState'; +import type IconAsset from '@src/types/utils/IconAsset'; import BaseMiniContextMenuItem from './BaseMiniContextMenuItem'; import Icon from './Icon'; -import sourcePropTypes from './Image/sourcePropTypes'; import MenuItem from './MenuItem'; -const propTypes = { +type ContextMenuItemProps = { /** Icon Component */ - icon: sourcePropTypes.isRequired, + icon: IconAsset; /** Text to display */ - text: PropTypes.string.isRequired, + text: string; /** Icon to show when interaction was successful */ - successIcon: sourcePropTypes, + successIcon?: IconAsset; /** Text to show when interaction was successful */ - successText: PropTypes.string, + successText?: string; /** Whether to show the mini menu */ - isMini: PropTypes.bool, + isMini?: boolean; /** Callback to fire when the item is pressed */ - onPress: PropTypes.func.isRequired, + onPress: () => void; /** A description text to show under the title */ - description: PropTypes.string, + description?: string; /** The action accept for anonymous user or not */ - isAnonymousAction: PropTypes.bool, + isAnonymousAction?: boolean; /** Whether the menu item is focused or not */ - isFocused: PropTypes.bool, - - /** Forwarded ref to ContextMenuItem */ - innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + isFocused?: boolean; }; -const defaultProps = { - isMini: false, - successIcon: null, - successText: '', - description: '', - isAnonymousAction: false, - isFocused: false, - innerRef: null, +type ContextMenuItemHandle = { + triggerPressAndUpdateSuccess?: () => void; }; -function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini, description, isAnonymousAction, isFocused, innerRef}) { +function ContextMenuItem( + {onPress, successIcon, successText = '', icon, text, isMini = false, description = '', isAnonymousAction = false, isFocused = false}: ContextMenuItemProps, + ref: ForwardedRef, +) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {windowWidth} = useWindowDimensions(); @@ -66,12 +60,12 @@ function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini, // We only set the success state when we have icon or text to represent the success state // We may want to replace this check by checking the Result from OnPress Callback in future. - if (successIcon || successText) { + if (!!successIcon || successText) { setThrottledButtonInactive(); } }; - useImperativeHandle(innerRef, () => ({triggerPressAndUpdateSuccess})); + useImperativeHandle(ref, () => ({triggerPressAndUpdateSuccess})); const itemIcon = !isThrottledButtonActive && successIcon ? successIcon : icon; const itemText = !isThrottledButtonActive && successText ? successText : text; @@ -107,18 +101,6 @@ function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini, ); } -ContextMenuItem.propTypes = propTypes; -ContextMenuItem.defaultProps = defaultProps; ContextMenuItem.displayName = 'ContextMenuItem'; -const ContextMenuItemWithRef = forwardRef((props, ref) => ( - -)); - -ContextMenuItemWithRef.displayName = 'ContextMenuItemWithRef'; - -export default ContextMenuItemWithRef; +export default forwardRef(ContextMenuItem); diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 2e9996a92f87..5f75bf535319 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import React, {useEffect, useRef} from 'react'; +import React, {forwardRef, useEffect, useRef} from 'react'; import type {GestureResponderEvent, Role} from 'react-native'; import {Platform, View} from 'react-native'; import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; @@ -134,4 +134,4 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo FloatingActionButton.displayName = 'FloatingActionButton'; -export default FloatingActionButton; +export default forwardRef(FloatingActionButton); diff --git a/src/components/Form.js b/src/components/Form.js deleted file mode 100644 index 7b6f587e7bd1..000000000000 --- a/src/components/Form.js +++ /dev/null @@ -1,592 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {Keyboard, ScrollView, StyleSheet} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import FormUtils from '@libs/FormUtils'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import Visibility from '@libs/Visibility'; -import stylePropTypes from '@styles/stylePropTypes'; -import * as FormActions from '@userActions/FormActions'; -import CONST from '@src/CONST'; -import FormAlertWithSubmitButton from './FormAlertWithSubmitButton'; -import FormSubmit from './FormSubmit'; -import networkPropTypes from './networkPropTypes'; -import {withNetwork} from './OnyxProvider'; -import SafeAreaConsumer from './SafeAreaConsumer'; -import ScrollViewWithContext from './ScrollViewWithContext'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; - -const propTypes = { - /** A unique Onyx key identifying the form */ - formID: PropTypes.string.isRequired, - - /** Text to be displayed in the submit button */ - submitButtonText: PropTypes.string, - - /** Controls the submit button's visibility */ - isSubmitButtonVisible: PropTypes.bool, - - /** Callback to validate the form */ - validate: PropTypes.func, - - /** Callback to submit the form */ - onSubmit: PropTypes.func.isRequired, - - /** Children to render. */ - children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, - - /* Onyx Props */ - - /** Contains the form state that must be accessed outside of the component */ - formState: PropTypes.shape({ - /** Controls the loading state of the form */ - isLoading: PropTypes.bool, - - /** Server side errors keyed by microtime */ - errors: PropTypes.objectOf(PropTypes.string), - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), - }), - - /** Contains draft values for each input in the form */ - // eslint-disable-next-line react/forbid-prop-types - draftValues: PropTypes.object, - - /** Should the button be enabled when offline */ - enabledWhenOffline: PropTypes.bool, - - /** Whether the form submit action is dangerous */ - isSubmitActionDangerous: PropTypes.bool, - - /** Whether the validate() method should run on input changes */ - shouldValidateOnChange: PropTypes.bool, - - /** Whether the validate() method should run on blur */ - shouldValidateOnBlur: PropTypes.bool, - - /** Whether ScrollWithContext should be used instead of regular ScrollView. - * Set to true when there's a nested Picker component in Form. - */ - scrollContextEnabled: PropTypes.bool, - - /** Container styles */ - style: stylePropTypes, - - /** Submit button container styles */ - // eslint-disable-next-line react/forbid-prop-types - submitButtonStyles: PropTypes.arrayOf(PropTypes.object), - - /** Custom content to display in the footer after submit button */ - footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), - - /** Information about the network */ - network: networkPropTypes.isRequired, - - /** Style for the error message for submit button */ - errorMessageStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - isSubmitButtonVisible: true, - formState: { - isLoading: false, - }, - draftValues: {}, - enabledWhenOffline: false, - isSubmitActionDangerous: false, - scrollContextEnabled: false, - shouldValidateOnChange: true, - shouldValidateOnBlur: true, - footerContent: null, - style: [], - errorMessageStyle: [], - submitButtonStyles: [], - validate: () => ({}), - submitButtonText: '', -}; - -const Form = forwardRef((props, forwardedRef) => { - const styles = useThemeStyles(); - const [errors, setErrors] = useState({}); - const [inputValues, setInputValues] = useState(() => ({...props.draftValues})); - const formRef = useRef(null); - const formContentRef = useRef(null); - const inputRefs = useRef({}); - const touchedInputs = useRef({}); - const focusedInput = useRef(null); - const isFirstRender = useRef(true); - - const {validate, onSubmit, children} = props; - - const hasServerError = useMemo(() => Boolean(props.formState) && !_.isEmpty(props.formState.errors), [props.formState]); - - /** - * @param {Object} values - An object containing the value of each inputID, e.g. {inputID1: value1, inputID2: value2} - * @returns {Object} - An object containing the errors for each inputID, e.g. {inputID1: error1, inputID2: error2} - */ - const onValidate = useCallback( - (values, shouldClearServerError = true) => { - // Trim all string values - const trimmedStringValues = ValidationUtils.prepareValues(values); - - if (shouldClearServerError) { - FormActions.setErrors(props.formID, null); - } - FormActions.setErrorFields(props.formID, null); - - // Run any validations passed as a prop - const validationErrors = validate(trimmedStringValues); - - // Validate the input for html tags. It should supercede any other error - _.each(trimmedStringValues, (inputValue, inputID) => { - // If the input value is empty OR is non-string, we don't need to validate it for HTML tags - if (!inputValue || !_.isString(inputValue)) { - return; - } - const foundHtmlTagIndex = inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX); - const leadingSpaceIndex = inputValue.search(CONST.VALIDATE_FOR_LEADINGSPACES_HTML_TAG_REGEX); - - // Return early if there are no HTML characters - if (leadingSpaceIndex === -1 && foundHtmlTagIndex === -1) { - return; - } - - const matchedHtmlTags = inputValue.match(CONST.VALIDATE_FOR_HTML_TAG_REGEX); - let isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(inputValue)); - // Check for any matches that the original regex (foundHtmlTagIndex) matched - if (matchedHtmlTags) { - // Check if any matched inputs does not match in WHITELISTED_TAGS list and return early if needed. - for (let i = 0; i < matchedHtmlTags.length; i++) { - const htmlTag = matchedHtmlTags[i]; - isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(htmlTag)); - if (!isMatch) { - break; - } - } - } - - if (isMatch && leadingSpaceIndex === -1) { - return; - } - - // Add a validation error here because it is a string value that contains HTML characters - validationErrors[inputID] = 'common.error.invalidCharacter'; - }); - - if (!_.isObject(validationErrors)) { - throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}'); - } - - const touchedInputErrors = _.pick(validationErrors, (inputValue, inputID) => Boolean(touchedInputs.current[inputID])); - - if (!_.isEqual(errors, touchedInputErrors)) { - setErrors(touchedInputErrors); - } - - return touchedInputErrors; - }, - [props.formID, validate, errors], - ); - - useEffect(() => { - // We want to skip Form validation on initial render. - // This also avoids a bug where we immediately clear server errors when the loading indicator unmounts and Form remounts with server errors. - if (isFirstRender.current) { - isFirstRender.current = false; - return; - } - - onValidate(inputValues); - - // eslint-disable-next-line react-hooks/exhaustive-deps -- we just want to revalidate the form on update if the preferred locale changed on another device so that errors get translated - }, [props.preferredLocale]); - - const errorMessage = useMemo(() => { - const latestErrorMessage = ErrorUtils.getLatestErrorMessage(props.formState); - return typeof latestErrorMessage === 'string' ? latestErrorMessage : ''; - }, [props.formState]); - - /** - * @param {String} inputID - The inputID of the input being touched - */ - const setTouchedInput = useCallback( - (inputID) => { - touchedInputs.current[inputID] = true; - }, - [touchedInputs], - ); - - const submit = useCallback(() => { - // Return early if the form is already submitting to avoid duplicate submission - if (props.formState.isLoading) { - return; - } - - // Trim all string values - const trimmedStringValues = ValidationUtils.prepareValues(inputValues); - - // Touches all form inputs so we can validate the entire form - _.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true)); - - // Validate form and return early if any errors are found - if (!_.isEmpty(onValidate(trimmedStringValues))) { - return; - } - - // Do not submit form if network is offline and the form is not enabled when offline - if (props.network.isOffline && !props.enabledWhenOffline) { - return; - } - - // Call submit handler - onSubmit(trimmedStringValues); - }, [props.formState.isLoading, props.network.isOffline, props.enabledWhenOffline, inputValues, onValidate, onSubmit]); - - /** - * Resets the form - */ - const resetForm = useCallback( - (optionalValue) => { - _.each(inputValues, (inputRef, inputID) => { - setInputValues((prevState) => { - const copyPrevState = _.clone(prevState); - - touchedInputs.current[inputID] = false; - copyPrevState[inputID] = optionalValue[inputID] || ''; - - return copyPrevState; - }); - }); - setErrors({}); - }, - [inputValues], - ); - - useImperativeHandle(forwardedRef, () => ({ - resetForm, - })); - - /** - * Loops over Form's children and automatically supplies Form props to them - * - * @param {Array | Function | Node} children - An array containing all Form children - * @returns {React.Component} - */ - const childrenWrapperWithProps = useCallback( - (childNodes) => { - const childrenElements = React.Children.map(childNodes, (child) => { - // Just render the child if it is not a valid React element, e.g. text within a component - if (!React.isValidElement(child)) { - return child; - } - - // Depth first traversal of the render tree as the input element is likely to be the last node - if (child.props.children) { - return React.cloneElement(child, { - children: childrenWrapperWithProps(child.props.children), - }); - } - - // Look for any inputs nested in a custom component, e.g AddressForm or IdentityForm - if (_.isFunction(child.type)) { - const childNode = new child.type(child.props); - - // If the custom component has a render method, use it to get the nested children - const nestedChildren = _.isFunction(childNode.render) ? childNode.render() : childNode; - - // Render the custom component if it's a valid React element - // If the custom component has nested children, Loop over them and supply From props - if (React.isValidElement(nestedChildren) || lodashGet(nestedChildren, 'props.children')) { - return childrenWrapperWithProps(nestedChildren); - } - - // Just render the child if it's custom component not a valid React element, or if it hasn't children - return child; - } - - // We check if the child has the inputID prop. - // We don't want to pass form props to non form components, e.g. View, Text, etc - if (!child.props.inputID) { - return child; - } - - // We clone the child passing down all form props - const inputID = child.props.inputID; - let defaultValue; - - // We need to make sure that checkboxes have correct - // value assigned from the list of draft values - // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065 - if (_.isBoolean(props.draftValues[inputID])) { - defaultValue = props.draftValues[inputID]; - } else { - defaultValue = props.draftValues[inputID] || child.props.defaultValue; - } - - // We want to initialize the input value if it's undefined - if (_.isUndefined(inputValues[inputID])) { - // eslint-disable-next-line es/no-nullish-coalescing-operators - inputValues[inputID] = defaultValue ?? ''; - } - - // We force the form to set the input value from the defaultValue props if there is a saved valid value - if (child.props.shouldUseDefaultValue) { - inputValues[inputID] = child.props.defaultValue; - } - - if (!_.isUndefined(child.props.value)) { - inputValues[inputID] = child.props.value; - } - - const errorFields = lodashGet(props.formState, 'errorFields', {}); - const fieldErrorMessage = - _.chain(errorFields[inputID]) - .keys() - .sortBy() - .reverse() - .map((key) => errorFields[inputID][key]) - .first() - .value() || ''; - - return React.cloneElement(child, { - ref: (node) => { - inputRefs.current[inputID] = node; - - const {ref} = child; - if (_.isFunction(ref)) { - ref(node); - } - }, - value: inputValues[inputID], - // As the text input is controlled, we never set the defaultValue prop - // as this is already happening by the value prop. - defaultValue: undefined, - errorText: errors[inputID] || fieldErrorMessage, - onFocus: (event) => { - focusedInput.current = inputID; - if (_.isFunction(child.props.onFocus)) { - child.props.onFocus(event); - } - }, - onBlur: (event) => { - // Only run validation when user proactively blurs the input. - if (Visibility.isVisible() && Visibility.hasFocus()) { - const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id'); - // We delay the validation in order to prevent Checkbox loss of focus when - // the user are focusing a TextInput and proceeds to toggle a CheckBox in - // web and mobile web platforms. - - setTimeout(() => { - if ( - relatedTargetId && - _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId) - ) { - return; - } - setTouchedInput(inputID); - if (props.shouldValidateOnBlur) { - onValidate(inputValues, !hasServerError); - } - }, 200); - } - - if (_.isFunction(child.props.onBlur)) { - child.props.onBlur(event); - } - }, - onTouched: () => { - setTouchedInput(inputID); - }, - onInputChange: (value, key) => { - const inputKey = key || inputID; - - if (focusedInput.current && focusedInput.current !== inputKey) { - setTouchedInput(focusedInput.current); - } - - setInputValues((prevState) => { - const newState = { - ...prevState, - [inputKey]: value, - }; - - if (props.shouldValidateOnChange) { - onValidate(newState); - } - return newState; - }); - - if (child.props.shouldSaveDraft) { - FormActions.setDraftValues(props.formID, {[inputKey]: value}); - } - - if (child.props.onValueChange) { - child.props.onValueChange(value, inputKey); - } - }, - }); - }); - - return childrenElements; - }, - [ - errors, - inputRefs, - inputValues, - onValidate, - props.draftValues, - props.formID, - props.formState, - setTouchedInput, - props.shouldValidateOnBlur, - props.shouldValidateOnChange, - hasServerError, - ], - ); - - const scrollViewContent = useCallback( - (safeAreaPaddingBottomStyle) => ( - - {childrenWrapperWithProps(_.isFunction(children) ? children({inputValues}) : children)} - {props.isSubmitButtonVisible && ( - 0 || Boolean(errorMessage) || !_.isEmpty(props.formState.errorFields)} - isLoading={props.formState.isLoading} - message={_.isEmpty(props.formState.errorFields) ? errorMessage : null} - onSubmit={submit} - footerContent={props.footerContent} - onFixTheErrorsLinkPressed={() => { - const errorFields = !_.isEmpty(errors) ? errors : props.formState.errorFields; - const focusKey = _.find(_.keys(inputRefs.current), (key) => _.keys(errorFields).includes(key)); - const focusInput = inputRefs.current[focusKey]; - - // Dismiss the keyboard for non-text fields by checking if the component has the isFocused method, as only TextInput has this method. - if (typeof focusInput.isFocused !== 'function') { - Keyboard.dismiss(); - } - - // We subtract 10 to scroll slightly above the input - if (focusInput.measureLayout && typeof focusInput.measureLayout === 'function') { - // We measure relative to the content root, not the scroll view, as that gives - // consistent results across mobile and web - focusInput.measureLayout(formContentRef.current, (x, y) => formRef.current.scrollTo({y: y - 10, animated: false})); - } - - // Focus the input after scrolling, as on the Web it gives a slightly better visual result - if (focusInput.focus && typeof focusInput.focus === 'function') { - focusInput.focus(); - } - }} - containerStyles={[styles.mh0, styles.mt5, styles.flex1, ...props.submitButtonStyles]} - enabledWhenOffline={props.enabledWhenOffline} - isSubmitActionDangerous={props.isSubmitActionDangerous} - useSmallerSubmitButtonSize={props.useSmallerSubmitButtonSize} - disablePressOnEnter - errorMessageStyle={props.errorMessageStyle} - /> - )} - - ), - - [ - props.style, - props.isSubmitButtonVisible, - props.submitButtonText, - props.useSmallerSubmitButtonSize, - props.errorMessageStyle, - props.formState.errorFields, - props.formState.isLoading, - props.footerContent, - props.submitButtonStyles, - props.enabledWhenOffline, - props.isSubmitActionDangerous, - submit, - childrenWrapperWithProps, - children, - inputValues, - errors, - errorMessage, - styles.mh0, - styles.mt5, - styles.flex1, - ], - ); - - useEffect(() => { - _.each(inputRefs.current, (inputRef, inputID) => { - if (inputRef) { - return; - } - - delete inputRefs.current[inputID]; - delete touchedInputs.current[inputID]; - - setInputValues((prevState) => { - const copyPrevState = _.clone(prevState); - - delete copyPrevState[inputID]; - - return copyPrevState; - }); - }); - // We need to verify that all references and values are still actual. - // We should not store it when e.g. some input has been unmounted. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [children]); - - return ( - - {({safeAreaPaddingBottomStyle}) => - props.scrollContextEnabled ? ( - - {scrollViewContent(safeAreaPaddingBottomStyle)} - - ) : ( - - {scrollViewContent(safeAreaPaddingBottomStyle)} - - ) - } - - ); -}); - -Form.displayName = 'Form'; -Form.propTypes = propTypes; -Form.defaultProps = defaultProps; - -export default compose( - withLocalize, - withNetwork(), - withOnyx({ - formState: { - key: (props) => props.formID, - }, - draftValues: { - key: (props) => FormUtils.getDraftKey(props.formID), - }, - }), -)(Form); diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js index 71b14b6fadcd..d1bf02b08191 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.js +++ b/src/components/LHNOptionsList/LHNOptionsList.js @@ -1,7 +1,7 @@ import {FlashList} from '@shopify/flash-list'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback} from 'react'; +import React, {memo, useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -190,4 +190,4 @@ export default compose( key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, }, }), -)(LHNOptionsList); +)(memo(LHNOptionsList)); diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index ce44db72598a..334fa9895205 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -66,7 +66,7 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { badgeText?: string; /** Used to apply offline styles to child text components */ - style?: ViewStyle; + style?: StyleProp; /** Any additional styles to apply */ wrapperStyle?: StyleProp; @@ -291,7 +291,7 @@ function MenuItem( const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const combinedStyle = StyleUtils.combineStyles(style ?? {}, styles.popoverMenuItem); + const combinedStyle = [style, styles.popoverMenuItem]; const {isSmallScreenWidth} = useWindowDimensions(); const [html, setHtml] = useState(''); const titleRef = useRef(''); diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js index bd3695eb7aa9..8b24066af969 100644 --- a/src/components/OptionsList/BaseOptionsList.js +++ b/src/components/OptionsList/BaseOptionsList.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {forwardRef, memo, useEffect, useRef} from 'react'; +import React, {forwardRef, memo, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import _ from 'underscore'; import OptionRow from '@components/OptionRow'; @@ -36,268 +36,278 @@ const defaultProps = { ...optionsListDefaultProps, }; -function BaseOptionsList({ - keyboardDismissMode, - onScrollBeginDrag, - onScroll, - listStyles, - focusedIndex, - selectedOptions, - headerMessage, - isLoading, - sections, - onLayout, - hideSectionHeaders, - shouldHaveOptionSeparator, - showTitleTooltip, - optionHoveredStyle, - contentContainerStyles, - sectionHeaderStyle, - showScrollIndicator, - listContainerStyles: listContainerStylesProp, - shouldDisableRowInnerPadding, - shouldPreventDefaultFocusOnSelectRow, - disableFocusOptions, - canSelectMultipleOptions, - shouldShowMultipleOptionSelectorAsButton, - multipleOptionSelectorButtonText, - onAddToSelection, - highlightSelectedOptions, - onSelectRow, - boldStyle, - isDisabled, - innerRef, - isRowMultilineSupported, - isLoadingNewOptions, - nestedScrollEnabled, - bounces, - renderFooterContent, -}) { - const styles = useThemeStyles(); - const flattenedData = useRef(); - const previousSections = usePrevious(sections); - const didLayout = useRef(false); - - const listContainerStyles = listContainerStylesProp || [styles.flex1]; - - /** - * This helper function is used to memoize the computation needed for getItemLayout. It is run whenever section data changes. - * - * @returns {Array} - */ - const buildFlatSectionArray = () => { - let offset = 0; - - // Start with just an empty list header - const flatArray = [{length: 0, offset}]; - - // Build the flat array - for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { - const section = sections[sectionIndex]; - // Add the section header - const sectionHeaderHeight = section.title && !hideSectionHeaders ? variables.optionsListSectionHeaderHeight : 0; - flatArray.push({length: sectionHeaderHeight, offset}); - offset += sectionHeaderHeight; - - // Add section items - for (let i = 0; i < section.data.length; i++) { - let fullOptionHeight = variables.optionRowHeight; - if (i > 0 && shouldHaveOptionSeparator) { - fullOptionHeight += variables.borderTopWidth; +const viewabilityConfig = {viewAreaCoveragePercentThreshold: 95}; + +const BaseOptionsList = forwardRef( + ( + { + keyboardDismissMode, + onScrollBeginDrag, + onScroll, + listStyles, + focusedIndex, + selectedOptions, + headerMessage, + isLoading, + sections, + onLayout, + hideSectionHeaders, + shouldHaveOptionSeparator, + showTitleTooltip, + optionHoveredStyle, + sectionHeaderStyle, + showScrollIndicator, + contentContainerStyles: contentContainerStylesProp, + listContainerStyles: listContainerStylesProp, + shouldDisableRowInnerPadding, + shouldPreventDefaultFocusOnSelectRow, + disableFocusOptions, + canSelectMultipleOptions, + shouldShowMultipleOptionSelectorAsButton, + multipleOptionSelectorButtonText, + onAddToSelection, + highlightSelectedOptions, + onSelectRow, + boldStyle, + isDisabled, + isRowMultilineSupported, + isLoadingNewOptions, + nestedScrollEnabled, + bounces, + renderFooterContent, + safeAreaPaddingBottomStyle, + }, + innerRef, + ) => { + const styles = useThemeStyles(); + const flattenedData = useRef(); + const previousSections = usePrevious(sections); + const didLayout = useRef(false); + + const listContainerStyles = useMemo(() => listContainerStylesProp || [styles.flex1], [listContainerStylesProp, styles.flex1]); + const contentContainerStyles = useMemo(() => [safeAreaPaddingBottomStyle, ...contentContainerStylesProp], [contentContainerStylesProp, safeAreaPaddingBottomStyle]); + + /** + * This helper function is used to memoize the computation needed for getItemLayout. It is run whenever section data changes. + * + * @returns {Array} + */ + const buildFlatSectionArray = () => { + let offset = 0; + + // Start with just an empty list header + const flatArray = [{length: 0, offset}]; + + // Build the flat array + for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { + const section = sections[sectionIndex]; + + // Add the section header + const sectionHeaderHeight = section.title && !hideSectionHeaders ? variables.optionsListSectionHeaderHeight : 0; + flatArray.push({length: sectionHeaderHeight, offset}); + offset += sectionHeaderHeight; + + // Add section items + for (let i = 0; i < section.data.length; i++) { + let fullOptionHeight = variables.optionRowHeight; + if (i > 0 && shouldHaveOptionSeparator) { + fullOptionHeight += variables.borderTopWidth; + } + flatArray.push({length: fullOptionHeight, offset}); + offset += fullOptionHeight; } - flatArray.push({length: fullOptionHeight, offset}); - offset += fullOptionHeight; + + // Add the section footer + flatArray.push({length: 0, offset}); } - // Add the section footer + // Then add the list footer flatArray.push({length: 0, offset}); - } - - // Then add the list footer - flatArray.push({length: 0, offset}); - return flatArray; - }; - - useEffect(() => { - if (_.isEqual(sections, previousSections)) { - return; - } - flattenedData.current = buildFlatSectionArray(); - }); - - const onViewableItemsChanged = () => { - if (didLayout.current || !onLayout) { - return; - } - - didLayout.current = true; - onLayout(); - }; - - /** - * This function is used to compute the layout of any given item in our list. - * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList. - * - * @param {Array} data - This is the same as the data we pass into the component - * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks: - * - * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those. - * 2. Each section includes a header, even if we don't provide/render one. - * - * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this: - * - * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}] - * - * @returns {Object} - */ - const getItemLayout = (data, flatDataArrayIndex) => { - if (!_.has(flattenedData.current, flatDataArrayIndex)) { + return flatArray; + }; + + useEffect(() => { + if (_.isEqual(sections, previousSections)) { + return; + } flattenedData.current = buildFlatSectionArray(); - } + }); + + const onViewableItemsChanged = () => { + if (didLayout.current || !onLayout) { + return; + } - const targetItem = flattenedData.current[flatDataArrayIndex]; - return { - length: targetItem.length, - offset: targetItem.offset, - index: flatDataArrayIndex, + didLayout.current = true; + onLayout(); }; - }; - - /** - * Returns the key used by the list - * @param {Object} option - * @return {String} - */ - const extractKey = (option) => option.keyForList; - - /** - * Function which renders a row in the list - * - * @param {Object} params - * @param {Object} params.item - * @param {Number} params.index - * @param {Object} params.section - * - * @return {Component} - */ - const renderItem = ({item, index, section}) => { - const isItemDisabled = isDisabled || section.isDisabled || !!item.isDisabled; - const isSelected = _.some(selectedOptions, (option) => { - if (option.accountID && option.accountID === item.accountID) { - return true; + + /** + * This function is used to compute the layout of any given item in our list. + * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList. + * + * @param {Array} data - This is the same as the data we pass into the component + * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks: + * + * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those. + * 2. Each section includes a header, even if we don't provide/render one. + * + * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this: + * + * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}] + * + * @returns {Object} + */ + const getItemLayout = (data, flatDataArrayIndex) => { + if (!_.has(flattenedData.current, flatDataArrayIndex)) { + flattenedData.current = buildFlatSectionArray(); } - if (option.reportID && option.reportID === item.reportID) { - return true; + const targetItem = flattenedData.current[flatDataArrayIndex]; + return { + length: targetItem.length, + offset: targetItem.offset, + index: flatDataArrayIndex, + }; + }; + + /** + * Returns the key used by the list + * + * @param {Object} option + * @return {String} + */ + const extractKey = (option) => option.keyForList; + + /** + * Function which renders a row in the list + * + * @param {Object} params + * @param {Object} params.item + * @param {Number} params.index + * @param {Object} params.section + * + * @return {Component} + */ + const renderItem = ({item, index, section}) => { + const isItemDisabled = isDisabled || section.isDisabled || !!item.isDisabled; + const isSelected = _.some(selectedOptions, (option) => { + if (option.accountID && option.accountID === item.accountID) { + return true; + } + + if (option.reportID && option.reportID === item.reportID) { + return true; + } + + if (_.isEmpty(option.name)) { + return false; + } + + return option.name === item.searchText; + }); + + return ( + 0 && shouldHaveOptionSeparator} + shouldDisableRowInnerPadding={shouldDisableRowInnerPadding} + shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} + isMultilineSupported={isRowMultilineSupported} + /> + ); + }; + + /** + * Function which renders a section header component + * + * @param {Object} params + * @param {Object} params.section + * @param {String} params.section.title + * @param {Boolean} params.section.shouldShow + * + * @return {Component} + */ + const renderSectionHeader = ({section: {title, shouldShow}}) => { + if (!title && shouldShow && !hideSectionHeaders && sectionHeaderStyle) { + return ; } - if (_.isEmpty(option.name)) { - return false; + if (title && shouldShow && !hideSectionHeaders) { + return ( + // Note: The `optionsListSectionHeader` style provides an explicit height to section headers. + // We do this so that we can reference the height in `getItemLayout` – + // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item. + // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well. + + {title} + + ); } - return option.name === item.searchText; - }); + return ; + }; return ( - 0 && shouldHaveOptionSeparator} - shouldDisableRowInnerPadding={shouldDisableRowInnerPadding} - shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} - isMultilineSupported={isRowMultilineSupported} - /> + + {isLoading ? ( + + ) : ( + <> + {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} + {/* This is misleading because we might be in the process of loading fresh options from the server. */} + {!isLoadingNewOptions && headerMessage ? ( + + {headerMessage} + + ) : null} + + + )} + ); - }; - - /** - * Function which renders a section header component - * - * @param {Object} params - * @param {Object} params.section - * @param {String} params.section.title - * @param {Boolean} params.section.shouldShow - * - * @return {Component} - */ - const renderSectionHeader = ({section: {title, shouldShow}}) => { - if (!title && shouldShow && !hideSectionHeaders && sectionHeaderStyle) { - return ; - } - - if (title && shouldShow && !hideSectionHeaders) { - return ( - // Note: The `optionsListSectionHeader` style provides an explicit height to section headers. - // We do this so that we can reference the height in `getItemLayout` – - // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item. - // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well. - - {title} - - ); - } - - return ; - }; - - return ( - - {isLoading ? ( - - ) : ( - <> - {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} - {/* This is misleading because we might be in the process of loading fresh options from the server. */} - {!isLoadingNewOptions && headerMessage ? ( - - {headerMessage} - - ) : null} - - - )} - - ); -} + }, +); BaseOptionsList.propTypes = propTypes; BaseOptionsList.defaultProps = defaultProps; @@ -305,13 +315,7 @@ BaseOptionsList.displayName = 'BaseOptionsList'; // using memo to avoid unnecessary rerenders when parents component rerenders (thus causing this component to rerender because shallow comparison is used for some props). export default memo( - forwardRef((props, ref) => ( - - )), + BaseOptionsList, (prevProps, nextProps) => nextProps.focusedIndex === prevProps.focusedIndex && nextProps.selectedOptions.length === prevProps.selectedOptions.length && diff --git a/src/components/OptionsList/index.js b/src/components/OptionsList/index.js index 36b8e7fccf12..6046a6124ccc 100644 --- a/src/components/OptionsList/index.js +++ b/src/components/OptionsList/index.js @@ -1,4 +1,4 @@ -import React, {forwardRef, useCallback, useEffect, useRef} from 'react'; +import React, {forwardRef, memo, useCallback, useEffect, useRef} from 'react'; import {Keyboard} from 'react-native'; import _ from 'underscore'; import withWindowDimensions from '@components/withWindowDimensions'; @@ -64,4 +64,4 @@ const OptionsListWithRef = forwardRef((props, ref) => ( OptionsListWithRef.displayName = 'OptionsListWithRef'; -export default withWindowDimensions(OptionsListWithRef); +export default withWindowDimensions(memo(OptionsListWithRef)); diff --git a/src/components/OptionsList/index.native.js b/src/components/OptionsList/index.native.js index ab2db4f20967..8a70e1e060b1 100644 --- a/src/components/OptionsList/index.native.js +++ b/src/components/OptionsList/index.native.js @@ -1,4 +1,4 @@ -import React, {forwardRef} from 'react'; +import React, {forwardRef, memo} from 'react'; import {Keyboard} from 'react-native'; import BaseOptionsList from './BaseOptionsList'; import {defaultProps, propTypes} from './optionsListPropTypes'; @@ -8,7 +8,7 @@ const OptionsList = forwardRef((props, ref) => ( // eslint-disable-next-line react/jsx-props-no-spreading {...props} ref={ref} - onScrollBeginDrag={() => Keyboard.dismiss()} + onScrollBeginDrag={Keyboard.dismiss} /> )); @@ -16,4 +16,4 @@ OptionsList.propTypes = propTypes; OptionsList.defaultProps = defaultProps; OptionsList.displayName = 'OptionsList'; -export default OptionsList; +export default memo(OptionsList); diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 792073b72613..b44c5375d720 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -1,7 +1,7 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {Component} from 'react'; -import {ScrollView, View} from 'react-native'; +import {InteractionManager, ScrollView, View} from 'react-native'; import _ from 'underscore'; import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; import Button from '@components/Button'; @@ -15,7 +15,7 @@ import ShowMoreButton from '@components/ShowMoreButton'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withNavigationFocus from '@components/withNavigationFocus'; +import withNavigation from '@components/withNavigation'; import withTheme, {withThemePropTypes} from '@components/withTheme'; import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; import compose from '@libs/compose'; @@ -40,9 +40,6 @@ const propTypes = { /** List styles for OptionsList */ listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - /** Whether navigation is focused */ - isFocused: PropTypes.bool.isRequired, - /** Whether referral CTA should be displayed */ shouldShowReferralCTA: PropTypes.bool, @@ -80,17 +77,19 @@ class BaseOptionsSelector extends Component { this.incrementPage = this.incrementPage.bind(this); this.sliceSections = this.sliceSections.bind(this); this.calculateAllVisibleOptionsCount = this.calculateAllVisibleOptionsCount.bind(this); + this.onLayout = this.onLayout.bind(this); + this.setListRef = this.setListRef.bind(this); this.debouncedUpdateSearchValue = _.debounce(this.updateSearchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); this.relatedTarget = null; - const allOptions = this.flattenSections(); - const sections = this.sliceSections(); - const focusedIndex = this.getInitiallyFocusedIndex(allOptions); + this.focusListener = null; + this.blurListener = null; + this.isFocused = false; this.state = { - sections, - allOptions, - focusedIndex, + sections: [], + allOptions: [], + focusedIndex: 0, shouldDisableRowSelection: false, shouldShowReferralModal: false, errorMessage: '', @@ -100,35 +99,49 @@ class BaseOptionsSelector extends Component { } componentDidMount() { - this.subscribeToKeyboardShortcut(); + this.focusListener = this.props.navigation.addListener('focus', () => { + // Screen coming back into focus, for example + // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K. + // Only applies to platforms that support keyboard shortcuts + if ([CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform())) { + this.subscribeToKeyboardShortcut(); + } - if (this.props.isFocused && this.props.autoFocus && this.textInput) { - this.focusTimeout = setTimeout(() => { - this.textInput.focus(); - }, CONST.ANIMATED_TRANSITION); - } + if (this.props.autoFocus && this.textInput) { + this.focusTimeout = setTimeout(() => { + this.textInput.focus(); + }, CONST.ANIMATED_TRANSITION); + } - this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false); - } + this.isFocused = true; + }); - componentDidUpdate(prevProps, prevState) { - if (prevProps.isFocused !== this.props.isFocused) { - if (this.props.isFocused) { - this.subscribeToKeyboardShortcut(); - } else { + this.blurListener = this.props.navigation.addListener('blur', () => { + if ([CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform())) { this.unSubscribeFromKeyboardShortcut(); } - } + this.isFocused = false; + }); + this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false); - // Screen coming back into focus, for example - // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K. - // Only applies to platforms that support keyboard shortcuts - if ([CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform()) && !prevProps.isFocused && this.props.isFocused && this.props.autoFocus && this.textInput) { - setTimeout(() => { - this.textInput.focus(); - }, CONST.ANIMATED_TRANSITION); - } + /** + * Execute the following code after all interactions have been completed. + * Which means once we are sure that all navigation animations are done, + * we will execute the callback passed to `runAfterInteractions`. + */ + this.interactionTask = InteractionManager.runAfterInteractions(() => { + const allOptions = this.flattenSections(); + const sections = this.sliceSections(); + const focusedIndex = this.getInitiallyFocusedIndex(allOptions); + this.setState({ + sections, + allOptions, + focusedIndex, + }); + }); + } + componentDidUpdate(prevProps, prevState) { if (prevState.paginationPage !== this.state.paginationPage) { const newSections = this.sliceSections(); @@ -178,11 +191,24 @@ class BaseOptionsSelector extends Component { } componentWillUnmount() { + if (this.interactionTask) { + this.interactionTask.cancel(); + } + this.focusListener(); + this.blurListener(); if (this.focusTimeout) { clearTimeout(this.focusTimeout); } + } + + onLayout() { + if (this.props.selectedOptions.length === 0) { + this.scrollToIndex(this.state.focusedIndex, false); + } - this.unSubscribeFromKeyboardShortcut(); + if (this.props.onLayout) { + this.props.onLayout(); + } } /** @@ -211,6 +237,10 @@ class BaseOptionsSelector extends Component { return defaultIndex; } + setListRef(ref) { + this.list = ref; + } + /** * Maps sections to render only allowed count of them per section. * @@ -306,7 +336,7 @@ class BaseOptionsSelector extends Component { const focusedItemKey = lodashGet(e, ['target', 'attributes', 'id', 'value']); const focusedOption = focusedItemKey ? _.find(this.state.allOptions, (option) => option.keyForList === focusedItemKey) : this.state.allOptions[this.state.focusedIndex]; - if (!focusedOption || !this.props.isFocused) { + if (!focusedOption || !this.isFocused) { return; } @@ -497,7 +527,7 @@ class BaseOptionsSelector extends Component { ); const optionsList = ( (this.list = el)} + ref={this.setListRef} optionHoveredStyle={optionHoveredStyle} onSelectRow={this.props.onSelectRow ? this.selectRow : undefined} sections={this.state.sections} @@ -514,16 +544,9 @@ class BaseOptionsSelector extends Component { isDisabled={this.props.isDisabled} shouldHaveOptionSeparator={this.props.shouldHaveOptionSeparator} highlightSelectedOptions={this.props.highlightSelectedOptions} - onLayout={() => { - if (this.props.selectedOptions.length === 0) { - this.scrollToIndex(this.state.focusedIndex, false); - } - - if (this.props.onLayout) { - this.props.onLayout(); - } - }} - contentContainerStyles={[safeAreaPaddingBottomStyle, ...this.props.contentContainerStyles]} + onLayout={this.onLayout} + safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} + contentContainerStyles={this.props.contentContainerStyles} sectionHeaderStyle={this.props.sectionHeaderStyle} listContainerStyles={listContainerStyles} listStyles={this.props.listStyles} @@ -679,4 +702,4 @@ class BaseOptionsSelector extends Component { BaseOptionsSelector.defaultProps = defaultProps; BaseOptionsSelector.propTypes = propTypes; -export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(BaseOptionsSelector); +export default compose(withLocalize, withNavigation, withThemeStyles, withTheme)(BaseOptionsSelector); diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index b9ecb647f437..bc45e41ae2f9 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -222,7 +222,7 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate let hasErrors = false; if (hasReceipt) { receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction); - hasErrors = canEditReceipt && TransactionUtils.hasMissingSmartscanFields(transaction); + hasErrors = canEdit && TransactionUtils.hasMissingSmartscanFields(transaction); } const pendingAction = lodashGet(transaction, 'pendingAction'); diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 27447a10a32b..abc7e3954200 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -15,7 +15,6 @@ import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; @@ -30,7 +29,6 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; -import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -128,7 +126,6 @@ const defaultProps = { function ReportPreview(props) { const theme = useTheme(); const styles = useThemeStyles(); - const {getLineHeightStyle} = useStyleUtils(); const {translate} = useLocalize(); const [hasMissingSmartscanFields, sethasMissingSmartscanFields] = useState(false); @@ -286,7 +283,7 @@ function ReportPreview(props) { - {getPreviewMessage()} + {getPreviewMessage()} {!iouSettled && hasErrors && ( { updateTargetAndMousePosition(e); if (React.isValidElement(children)) { - children.props.onMouseEnter(e); + children.props.onMouseEnter?.(e); } }, [children, updateTargetAndMousePosition], diff --git a/src/hooks/useDebouncedState.ts b/src/hooks/useDebouncedState.ts new file mode 100644 index 000000000000..3677c85f3081 --- /dev/null +++ b/src/hooks/useDebouncedState.ts @@ -0,0 +1,35 @@ +import debounce from 'lodash/debounce'; +import {useEffect, useRef, useState} from 'react'; +import CONST from '@src/CONST'; + +/** + * A React hook that provides a state and its debounced version. + * + * @param initialValue - The initial value of the state. + * @param delay - The debounce delay in milliseconds. Defaults to SEARCH_OPTION_LIST_DEBOUNCE_TIME = 300ms. + * @returns A tuple containing: + * - The current state value. + * - The debounced state value. + * - A function to set both the current and debounced state values. + * + * @template T The type of the state value. + * + * @example + * const [value, debouncedValue, setValue] = useDebouncedState("", 300); + */ +function useDebouncedState(initialValue: T, delay = CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME): [T, T, (value: T) => void] { + const [value, setValue] = useState(initialValue); + const [debouncedValue, setDebouncedValue] = useState(initialValue); + const debouncedSetDebouncedValue = useRef(debounce(setDebouncedValue, delay)).current; + + useEffect(() => () => debouncedSetDebouncedValue.cancel(), [debouncedSetDebouncedValue]); + + const handleSetValue = (newValue: T) => { + setValue(newValue); + debouncedSetDebouncedValue(newValue); + }; + + return [value, debouncedValue, handleSetValue]; +} + +export default useDebouncedState; diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts index ba14bc9c9c3d..26b3665ca4ce 100644 --- a/src/libs/GroupChatUtils.ts +++ b/src/libs/GroupChatUtils.ts @@ -1,26 +1,18 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Report} from '@src/types/onyx'; -import * as OptionsListUtils from './OptionsListUtils'; +import type {Report} from '@src/types/onyx'; import * as ReportUtils from './ReportUtils'; -let allPersonalDetails: OnyxEntry = {}; -Onyx.connect({ - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - callback: (val) => (allPersonalDetails = val), -}); - /** * Returns the report name if the report is a group chat */ function getGroupChatName(report: Report): string | undefined { const participants = report.participantAccountIDs ?? []; const isMultipleParticipantReport = participants.length > 1; - const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, allPersonalDetails ?? {}); - // @ts-expect-error Error will gone when OptionsListUtils will be migrated to Typescript - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipantReport); - return ReportUtils.getDisplayNamesStringFromTooltips(displayNamesWithTooltips); + + return participants + .map((participant) => ReportUtils.getDisplayNameForParticipant(participant, isMultipleParticipantReport)) + .sort((first, second) => first?.localeCompare(second ?? '') ?? 0) + .filter(Boolean) + .join(', '); } export { diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index cd95e3c398bf..3c958336feb2 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -90,19 +90,20 @@ function updateIOUOwnerAndTotal(iouReport: OnyxEntry, actorAccountID: nu // Make a copy so we don't mutate the original object const iouReportUpdate: Report = {...iouReport}; - if (iouReportUpdate.total) { - if (actorAccountID === iouReport.ownerAccountID) { - iouReportUpdate.total += isDeleting ? -amount : amount; - } else { - iouReportUpdate.total += isDeleting ? amount : -amount; - } + // Let us ensure a valid value before updating the total amount. + iouReportUpdate.total = iouReportUpdate.total ?? 0; - if (iouReportUpdate.total < 0) { - // The total sign has changed and hence we need to flip the manager and owner of the report. - iouReportUpdate.ownerAccountID = iouReport.managerID; - iouReportUpdate.managerID = iouReport.ownerAccountID; - iouReportUpdate.total = -iouReportUpdate.total; - } + if (actorAccountID === iouReport.ownerAccountID) { + iouReportUpdate.total += isDeleting ? -amount : amount; + } else { + iouReportUpdate.total += isDeleting ? amount : -amount; + } + + if (iouReportUpdate.total < 0) { + // The total sign has changed and hence we need to flip the manager and owner of the report. + iouReportUpdate.ownerAccountID = iouReport.managerID; + iouReportUpdate.managerID = iouReport.ownerAccountID; + iouReportUpdate.total = -iouReportUpdate.total; } return iouReportUpdate; diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 600cfb48a1c1..61e7ce04ab71 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -38,8 +38,10 @@ function buildMessageFragmentForValue( const newValueToDisplay = valueInQuotes ? `"${newValue}"` : newValue; const oldValueToDisplay = valueInQuotes ? `"${oldValue}"` : oldValue; const displayValueName = shouldConvertToLowercase ? valueName.toLowerCase() : valueName; + const isOldValuePartialMerchant = valueName === Localize.translateLocal('common.merchant') && oldValue === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; - if (!oldValue) { + // In case of a partial merchant value, we want to avoid user seeing the "(none)" value in the message. + if (!oldValue || isOldValuePartialMerchant) { const fragment = Localize.translateLocal('iou.setTheRequest', {valueName: displayValueName, newValueToDisplay}); setFragments.push(fragment); } else if (!newValue) { diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 99321d7734c6..91285821fe9f 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -73,7 +73,7 @@ function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number * @param path - Path that you are looking for. * @return - Returns distance to path or -1 if the path is not found in root navigator. */ -function getDistanceFromPathInRootNavigator(path: string): number { +function getDistanceFromPathInRootNavigator(path?: string): number { let currentState = navigationRef.getRootState(); for (let index = 0; index < 5; index++) { @@ -149,7 +149,7 @@ function navigate(route: Route = ROUTES.HOME, type?: string) { * @param shouldEnforceFallback - Enforces navigation to fallback route * @param shouldPopToTop - Should we navigate to LHN on back press */ -function goBack(fallbackRoute: Route, shouldEnforceFallback = false, shouldPopToTop = false) { +function goBack(fallbackRoute?: Route, shouldEnforceFallback = false, shouldPopToTop = false) { if (!canNavigate('goBack')) { return; } diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index ceec66295a8c..0abdfdd02224 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -72,6 +72,7 @@ Onyx.connect({ const lastReportActions = {}; const allSortedReportActions = {}; const allReportActions = {}; +const visibleReportActionItems = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, callback: (actions, key) => { @@ -83,6 +84,18 @@ Onyx.connect({ const sortedReportActions = ReportActionUtils.getSortedReportActions(_.toArray(actions), true); allSortedReportActions[reportID] = sortedReportActions; lastReportActions[reportID] = _.first(sortedReportActions); + + // The report is only visible if it is the last action not deleted that + // does not match a closed or created state. + const reportActionsForDisplay = _.filter( + sortedReportActions, + (reportAction, actionKey) => + ReportActionUtils.shouldReportActionBeVisible(reportAction, actionKey) && + !ReportActionUtils.isWhisperAction(reportAction) && + reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && + reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + visibleReportActionItems[reportID] = reportActionsForDisplay[reportActionsForDisplay.length - 1]; }, }); @@ -519,7 +532,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID !== currentUserAccountID ? lastActorDetails.firstName || PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails) : ''; - let lastMessageText = lastActorDisplayName ? `${lastActorDisplayName}: ${lastMessageTextFromReport}` : lastMessageTextFromReport; + let lastMessageText = lastMessageTextFromReport; if (result.isArchivedRoom) { const archiveReason = @@ -531,6 +544,13 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { }); } + const lastAction = visibleReportActionItems[report.reportID]; + const shouldDisplayLastActorName = lastAction && lastAction.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU; + + if (shouldDisplayLastActorName && lastActorDisplayName && lastMessageTextFromReport) { + lastMessageText = `${lastActorDisplayName}: ${lastMessageTextFromReport}`; + } + if (result.isThread || result.isMoneyRequestReport) { result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } else if (result.isChatRoom || result.isPolicyExpenseChat) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 0d7658adf180..e619cb3c80dd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -996,6 +996,12 @@ function hasSingleParticipant(report: OnyxEntry): boolean { */ function hasOnlyDistanceRequestTransactions(iouReportID: string | undefined): boolean { const transactions = TransactionUtils.getAllReportTransactions(iouReportID); + + // Early return false in case not having any transaction + if (!transactions || transactions.length === 0) { + return false; + } + return transactions.every((transaction) => TransactionUtils.isDistanceRequest(transaction)); } @@ -1488,7 +1494,6 @@ function getDisplayNameForParticipant(accountID?: number, shouldUseShortForm = f } const personalDetails = getPersonalDetailsForAccountID(accountID); - // console.log(personalDetails); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetails.login || ''); // This is to check if account is an invite/optimistically created one @@ -1550,17 +1555,6 @@ function getDisplayNamesWithTooltips( }); } -/** - * Gets a joined string of display names from the list of display name with tooltip objects. - * - */ -function getDisplayNamesStringFromTooltips(displayNamesWithTooltips: DisplayNameWithTooltips | undefined) { - return displayNamesWithTooltips - ?.map(({displayName}) => displayName) - .filter(Boolean) - .join(', '); -} - /** * For a deleted parent report action within a chat report, * let us return the appropriate display message @@ -2922,10 +2916,10 @@ function buildOptimisticReportPreview( accountID: iouReport?.managerID ?? 0, // The preview is initially whispered if created with a receipt, so the actor is the current user as well actorAccountID: hasReceipt ? currentUserAccountID : iouReport?.managerID ?? 0, + childReportID: childReportID ?? iouReport?.reportID, childMoneyRequestCount: 1, childLastMoneyRequestComment: comment, childRecentReceiptTransactionIDs: hasReceipt && isNotEmptyObject(transaction) ? {[transaction?.transactionID ?? '']: created} : undefined, - childReportID, whisperedToAccountIDs: isReceiptBeingScanned ? [currentUserAccountID ?? -1] : [], }; } @@ -3770,6 +3764,7 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o if (isOwnExpenseReport && PolicyUtils.isPaidGroupPolicy(policy)) { return isDraftExpenseReport(report); } + return (isOwnExpenseReport || isIOUReport(report)) && !isReportApproved(report) && !isSettled(report?.reportID); } @@ -3938,6 +3933,13 @@ function getAddWorkspaceRoomOrChatReportErrors(report: OnyxEntry): Recor function canUserPerformWriteAction(report: OnyxEntry) { const reportErrors = getAddWorkspaceRoomOrChatReportErrors(report); + // If the Money Request report is marked for deletion, let us prevent any further write action. + if (isMoneyRequestReport(report)) { + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); + if (parentReportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return false; + } + } return !isArchivedRoom(report) && isEmptyObject(reportErrors) && report && isAllowedToComment(report) && !isAnonymousUser; } @@ -4406,7 +4408,6 @@ export { getIcons, getRoomWelcomeMessage, getDisplayNamesWithTooltips, - getDisplayNamesStringFromTooltips, getReportName, getReport, getReportNotificationPreference, @@ -4534,4 +4535,4 @@ export { shouldDisableThread, }; -export type {ExpenseOriginalMessage, OptionData, OptimisticChatReport}; +export type {ExpenseOriginalMessage, OptionData, OptimisticChatReport, OptimisticCreatedReportAction}; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 798d94bfb0e0..c0691eb86475 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -297,12 +297,12 @@ function finalReconnectAppAfterActivatingReliableUpdates(): Promise { +function getMissingOnyxUpdates(updateIDFrom = 0, updateIDTo: number | string = 0): Promise { console.debug(`[OnyxUpdates] Fetching missing updates updateIDFrom: ${updateIDFrom} and updateIDTo: ${updateIDTo}`); type GetMissingOnyxMessagesParams = { updateIDFrom: number; - updateIDTo: number; + updateIDTo: number | string; }; const parameters: GetMissingOnyxMessagesParams = { diff --git a/src/libs/actions/CanvasSize.js b/src/libs/actions/CanvasSize.ts similarity index 92% rename from src/libs/actions/CanvasSize.js rename to src/libs/actions/CanvasSize.ts index b313763131b9..8e0a155f25eb 100644 --- a/src/libs/actions/CanvasSize.js +++ b/src/libs/actions/CanvasSize.ts @@ -11,12 +11,12 @@ function retrieveMaxCanvasArea() { // More information at: https://github.com/jhildenbiddle/canvas-size/issues/13 canvasSize .maxArea({ - max: Browser.isMobile() ? 8192 : null, + max: Browser.isMobile() ? 8192 : undefined, usePromise: true, useWorker: false, }) .then(() => ({ - onSuccess: (width, height) => { + onSuccess: (width: number, height: number) => { Onyx.merge(ONYXKEYS.MAX_CANVAS_AREA, width * height); }, })); diff --git a/src/libs/actions/Card.js b/src/libs/actions/Card.js deleted file mode 100644 index 68642bd8fdf1..000000000000 --- a/src/libs/actions/Card.js +++ /dev/null @@ -1,176 +0,0 @@ -import Onyx from 'react-native-onyx'; -import * as API from '@libs/API'; -import * as Localize from '@libs/Localize'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -/** - * @param {Number} cardID - */ -function reportVirtualExpensifyCardFraud(cardID) { - API.write( - 'ReportVirtualExpensifyCardFraud', - { - cardID, - }, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, - value: { - isLoading: true, - }, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, - value: { - isLoading: false, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, - value: { - isLoading: false, - }, - }, - ], - }, - ); -} - -/** - * Call the API to deactivate the card and request a new one - * @param {String} cardId - id of the card that is going to be replaced - * @param {String} reason - reason for replacement ('damaged' | 'stolen') - */ -function requestReplacementExpensifyCard(cardId, reason) { - API.write( - 'RequestReplacementExpensifyCard', - { - cardId, - reason, - }, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, - value: { - isLoading: true, - errors: null, - }, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, - value: { - isLoading: false, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, - value: { - isLoading: false, - }, - }, - ], - }, - ); -} - -/** - * Activates the physical Expensify card based on the last four digits of the card number - * - * @param {String} cardLastFourDigits - * @param {Number} cardID - */ -function activatePhysicalExpensifyCard(cardLastFourDigits, cardID) { - API.write( - 'ActivatePhysicalExpensifyCard', - {cardLastFourDigits, cardID}, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CARD_LIST, - value: { - [cardID]: { - errors: null, - isLoading: true, - }, - }, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CARD_LIST, - value: { - [cardID]: { - isLoading: false, - }, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.CARD_LIST, - value: { - [cardID]: { - isLoading: false, - }, - }, - }, - ], - }, - ); -} - -/** - * Clears errors for a specific cardID - * - * @param {Number} cardID - */ -function clearCardListErrors(cardID) { - Onyx.merge(ONYXKEYS.CARD_LIST, {[cardID]: {errors: null, isLoading: false}}); -} - -/** - * Makes an API call to get virtual card details (pan, cvv, expiration date, address) - * This function purposefully uses `makeRequestWithSideEffects` method. For security reason - * card details cannot be persisted in Onyx and have to be asked for each time a user want's to - * reveal them. - * - * @param {String} cardID - virtual card ID - * - * @returns {Promise} - promise with card details object - */ -function revealVirtualCardDetails(cardID) { - return new Promise((resolve, reject) => { - // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects('RevealExpensifyCardDetails', {cardID}) - .then((response) => { - if (response.jsonCode !== CONST.JSON_CODE.SUCCESS) { - reject(Localize.translateLocal('cardPage.cardDetailsLoadingFailure')); - return; - } - resolve(response); - }) - .catch(() => reject(Localize.translateLocal('cardPage.cardDetailsLoadingFailure'))); - }); -} - -export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud, revealVirtualCardDetails}; diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts new file mode 100644 index 000000000000..172b0ac73ca6 --- /dev/null +++ b/src/libs/actions/Card.ts @@ -0,0 +1,193 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxUpdate} from 'react-native-onyx'; +import * as API from '@libs/API'; +import * as Localize from '@libs/Localize'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Response} from '@src/types/onyx'; + +type ReplacementReason = 'damaged' | 'stolen'; + +function reportVirtualExpensifyCardFraud(cardID: number) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, + value: { + isLoading: true, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, + value: { + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD, + value: { + isLoading: false, + }, + }, + ]; + + type ReportVirtualExpensifyCardFraudParams = { + cardID: number; + }; + + const parameters: ReportVirtualExpensifyCardFraudParams = { + cardID, + }; + + API.write('ReportVirtualExpensifyCardFraud', parameters, {optimisticData, successData, failureData}); +} + +/** + * Call the API to deactivate the card and request a new one + * @param cardId - id of the card that is going to be replaced + * @param reason - reason for replacement + */ +function requestReplacementExpensifyCard(cardId: number, reason: ReplacementReason) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + value: { + isLoading: true, + errors: null, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + value: { + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + value: { + isLoading: false, + }, + }, + ]; + + type RequestReplacementExpensifyCardParams = { + cardId: number; + reason: string; + }; + + const parameters: RequestReplacementExpensifyCardParams = { + cardId, + reason, + }; + + API.write('RequestReplacementExpensifyCard', parameters, {optimisticData, successData, failureData}); +} + +/** + * Activates the physical Expensify card based on the last four digits of the card number + */ +function activatePhysicalExpensifyCard(cardLastFourDigits: string, cardID: number) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + errors: null, + isLoading: true, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + isLoading: false, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + isLoading: false, + }, + }, + }, + ]; + + type ActivatePhysicalExpensifyCardParams = { + cardLastFourDigits: string; + cardID: number; + }; + + const parameters: ActivatePhysicalExpensifyCardParams = { + cardLastFourDigits, + cardID, + }; + + API.write('ActivatePhysicalExpensifyCard', parameters, {optimisticData, successData, failureData}); +} + +/** + * Clears errors for a specific cardID + */ +function clearCardListErrors(cardID: number) { + Onyx.merge(ONYXKEYS.CARD_LIST, {[cardID]: {errors: null, isLoading: false}}); +} + +/** + * Makes an API call to get virtual card details (pan, cvv, expiration date, address) + * This function purposefully uses `makeRequestWithSideEffects` method. For security reason + * card details cannot be persisted in Onyx and have to be asked for each time a user want's to + * reveal them. + * + * @param cardID - virtual card ID + * + * @returns promise with card details object + */ +function revealVirtualCardDetails(cardID: number): Promise { + return new Promise((resolve, reject) => { + type RevealExpensifyCardDetailsParams = {cardID: number}; + + const parameters: RevealExpensifyCardDetailsParams = {cardID}; + + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects('RevealExpensifyCardDetails', parameters) + .then((response) => { + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + reject(Localize.translateLocal('cardPage.cardDetailsLoadingFailure')); + return; + } + resolve(response); + }) + .catch(() => reject(Localize.translateLocal('cardPage.cardDetailsLoadingFailure'))); + }); +} + +export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud, revealVirtualCardDetails}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 75ae60970451..8f1e185cc57d 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -2497,38 +2497,37 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView iouReportLastMessageText.length === 0 && !ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && (!transactionThreadID || shouldDeleteTransactionThread); // STEP 4: Update the iouReport and reportPreview with new totals and messages if it wasn't deleted - let updatedIOUReport = null; - let updatedReportPreviewAction = null; - if (!shouldDeleteIOUReport) { - if (ReportUtils.isExpenseReport(iouReport)) { - updatedIOUReport = {...iouReport}; + let updatedIOUReport = {...iouReport}; + const updatedReportPreviewAction = {...reportPreviewAction}; + updatedReportPreviewAction.pendingAction = shouldDeleteIOUReport ? CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + if (ReportUtils.isExpenseReport(iouReport)) { + updatedIOUReport = {...iouReport}; + + // Because of the Expense reports are stored as negative values, we add the total from the amount + updatedIOUReport.total += TransactionUtils.getAmount(transaction, true); + } else { + updatedIOUReport = IOUUtils.updateIOUOwnerAndTotal( + iouReport, + reportAction.actorAccountID, + TransactionUtils.getAmount(transaction, false), + TransactionUtils.getCurrency(transaction), + true, + ); + } - // Because of the Expense reports are stored as negative values, we add the total from the amount - updatedIOUReport.total += TransactionUtils.getAmount(transaction, true); - } else { - updatedIOUReport = IOUUtils.updateIOUOwnerAndTotal( - iouReport, - reportAction.actorAccountID, - TransactionUtils.getAmount(transaction, false), - TransactionUtils.getCurrency(transaction), - true, - ); - } + updatedIOUReport.lastMessageText = iouReportLastMessageText; + updatedIOUReport.lastVisibleActionCreated = lodashGet(lastVisibleAction, 'created'); - updatedIOUReport.lastMessageText = iouReportLastMessageText; - updatedIOUReport.lastVisibleActionCreated = lodashGet(lastVisibleAction, 'created'); + const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport); + const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', { + payer: ReportUtils.getPersonalDetailsForAccountID(updatedIOUReport.managerID).login || '', + amount: CurrencyUtils.convertToDisplayString(updatedIOUReport.total, updatedIOUReport.currency), + }); + updatedReportPreviewAction.message[0].text = messageText; + updatedReportPreviewAction.message[0].html = messageText; - updatedReportPreviewAction = {...reportPreviewAction}; - const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport); - const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', { - payer: ReportUtils.getPersonalDetailsForAccountID(updatedIOUReport.managerID).login || '', - amount: CurrencyUtils.convertToDisplayString(updatedIOUReport.total, updatedIOUReport.currency), - }); - updatedReportPreviewAction.message[0].text = messageText; - updatedReportPreviewAction.message[0].html = messageText; - if (reportPreviewAction.childMoneyRequestCount > 0) { - updatedReportPreviewAction.childMoneyRequestCount = reportPreviewAction.childMoneyRequestCount - 1; - } + if (reportPreviewAction.childMoneyRequestCount > 0) { + updatedReportPreviewAction.childMoneyRequestCount = reportPreviewAction.childMoneyRequestCount - 1; } // STEP 5: Build Onyx data @@ -2562,12 +2561,12 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView ] : []), { - onyxMethod: shouldDeleteIOUReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, - value: shouldDeleteIOUReport ? null : updatedReportAction, + value: updatedReportAction, }, { - onyxMethod: shouldDeleteIOUReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, value: updatedIOUReport, }, @@ -2610,9 +2609,34 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { - [reportAction.reportActionID]: {pendingAction: null}, + [reportAction.reportActionID]: shouldDeleteIOUReport + ? null + : { + pendingAction: null, + }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [reportPreviewAction.reportActionID]: shouldDeleteIOUReport + ? null + : { + pendingAction: null, + errors: null, + }, + }, + }, + ...(shouldDeleteIOUReport + ? [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: null, + }, + ] + : []), ]; const failureData = [ @@ -3239,7 +3263,7 @@ function submitReport(expenseReport) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, value: { - [expenseReport.reportActionID]: { + [optimisticSubmittedReportAction.reportActionID]: { errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), }, }, diff --git a/src/libs/actions/MemoryOnlyKeys/MemoryOnlyKeys.js b/src/libs/actions/MemoryOnlyKeys/MemoryOnlyKeys.ts similarity index 71% rename from src/libs/actions/MemoryOnlyKeys/MemoryOnlyKeys.js rename to src/libs/actions/MemoryOnlyKeys/MemoryOnlyKeys.ts index 028bce225909..3e8c613187b4 100644 --- a/src/libs/actions/MemoryOnlyKeys/MemoryOnlyKeys.js +++ b/src/libs/actions/MemoryOnlyKeys/MemoryOnlyKeys.ts @@ -1,8 +1,9 @@ import Onyx from 'react-native-onyx'; +import type {OnyxKey} from 'react-native-onyx/lib/types'; import Log from '@libs/Log'; import ONYXKEYS from '@src/ONYXKEYS'; -const memoryOnlyKeys = [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.POLICY, ONYXKEYS.PERSONAL_DETAILS_LIST]; +const memoryOnlyKeys: OnyxKey[] = [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.POLICY, ONYXKEYS.PERSONAL_DETAILS_LIST]; const enable = () => { Log.info('[MemoryOnlyKeys] enabled'); diff --git a/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.native.js b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.native.js deleted file mode 100644 index 9d08b9db6aa4..000000000000 --- a/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.native.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * This is a no-op because the global methods will only work for web and desktop - */ -const exposeGlobalMemoryOnlyKeysMethods = () => {}; - -export default exposeGlobalMemoryOnlyKeysMethods; diff --git a/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.native.ts b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.native.ts new file mode 100644 index 000000000000..b89e03bdefdc --- /dev/null +++ b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.native.ts @@ -0,0 +1,8 @@ +import type ExposeGlobalMemoryOnlyKeysMethods from './types'; + +/** + * This is a no-op because the global methods will only work for web and desktop + */ +const exposeGlobalMemoryOnlyKeysMethods: ExposeGlobalMemoryOnlyKeysMethods = () => {}; + +export default exposeGlobalMemoryOnlyKeysMethods; diff --git a/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.js b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.ts similarity index 67% rename from src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.js rename to src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.ts index 1d039c8980a9..4514edacb288 100644 --- a/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.js +++ b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.ts @@ -1,6 +1,7 @@ import * as MemoryOnlyKeys from '@userActions/MemoryOnlyKeys/MemoryOnlyKeys'; +import type ExposeGlobalMemoryOnlyKeysMethods from './types'; -const exposeGlobalMemoryOnlyKeysMethods = () => { +const exposeGlobalMemoryOnlyKeysMethods: ExposeGlobalMemoryOnlyKeysMethods = () => { window.enableMemoryOnlyKeys = () => { MemoryOnlyKeys.enable(); }; diff --git a/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/types.ts b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/types.ts new file mode 100644 index 000000000000..4cb50041b627 --- /dev/null +++ b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/types.ts @@ -0,0 +1,3 @@ +type ExposeGlobalMemoryOnlyKeysMethods = () => void; + +export default ExposeGlobalMemoryOnlyKeysMethods; diff --git a/src/libs/actions/OnyxUpdateManager.js b/src/libs/actions/OnyxUpdateManager.ts similarity index 86% rename from src/libs/actions/OnyxUpdateManager.js rename to src/libs/actions/OnyxUpdateManager.ts index 21cea452295b..ab0dea960b27 100644 --- a/src/libs/actions/OnyxUpdateManager.js +++ b/src/libs/actions/OnyxUpdateManager.ts @@ -1,5 +1,4 @@ import Onyx from 'react-native-onyx'; -import _ from 'underscore'; import Log from '@libs/Log'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; import CONST from '@src/CONST'; @@ -22,27 +21,27 @@ import * as OnyxUpdates from './OnyxUpdates'; // The circular dependency happens because this file calls API.GetMissingOnyxUpdates() which uses the SaveResponseInOnyx.js file // (as a middleware). Therefore, SaveResponseInOnyx.js can't import and use this file directly. -let lastUpdateIDAppliedToClient = 0; +let lastUpdateIDAppliedToClient: number | null = 0; Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (val) => (lastUpdateIDAppliedToClient = val), + callback: (value) => (lastUpdateIDAppliedToClient = value), }); export default () => { console.debug('[OnyxUpdateManager] Listening for updates from the server'); Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_FROM_SERVER, - callback: (val) => { - if (!val) { + callback: (value) => { + if (!value) { return; } // Since we used the same key that used to store another object, let's confirm that the current object is // following the new format before we proceed. If it isn't, then let's clear the object in Onyx. if ( - !_.isObject(val) || - !_.has(val, 'type') || - (!(val.type === CONST.ONYX_UPDATE_TYPES.HTTPS && _.has(val, 'request') && _.has(val, 'response')) && !(val.type === CONST.ONYX_UPDATE_TYPES.PUSHER && _.has(val, 'updates'))) + !(typeof value === 'object' && !!value) || + !('type' in value) || + (!(value.type === CONST.ONYX_UPDATE_TYPES.HTTPS && value.request && value.response) && !(value.type === CONST.ONYX_UPDATE_TYPES.PUSHER && value.updates)) ) { console.debug('[OnyxUpdateManager] Invalid format found for updates, cleaning and unpausing the queue'); Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null); @@ -50,9 +49,9 @@ export default () => { return; } - const updateParams = val; - const lastUpdateIDFromServer = val.lastUpdateID; - const previousUpdateIDFromServer = val.previousUpdateID; + const updateParams = value; + const lastUpdateIDFromServer = value.lastUpdateID; + const previousUpdateIDFromServer = value.previousUpdateID; // In cases where we received a previousUpdateID and it doesn't match our lastUpdateIDAppliedToClient // we need to perform one of the 2 possible cases: @@ -76,7 +75,7 @@ export default () => { canUnpauseQueuePromise = App.finalReconnectAppAfterActivatingReliableUpdates(); } else { // The flow below is setting the promise to a getMissingOnyxUpdates to address flow (2) explained above. - console.debug(`[OnyxUpdateManager] Client is behind the server by ${previousUpdateIDFromServer - lastUpdateIDAppliedToClient} so fetching incremental updates`); + console.debug(`[OnyxUpdateManager] Client is behind the server by ${Number(previousUpdateIDFromServer) - lastUpdateIDAppliedToClient} so fetching incremental updates`); Log.info('Gap detected in update IDs from server so fetching incremental updates', true, { lastUpdateIDFromServer, previousUpdateIDFromServer, diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index a0772db49585..85f569246ec5 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -71,6 +71,7 @@ function applyPusherOnyxUpdates(updates: OnyxUpdateEvent[]) { */ function apply({lastUpdateID, type, request, response, updates}: Merge): Promise; function apply({lastUpdateID, type, request, response, updates}: Merge): Promise; +function apply({lastUpdateID, type, request, response, updates}: OnyxUpdatesFromServer): Promise; function apply({lastUpdateID, type, request, response, updates}: OnyxUpdatesFromServer): Promise | undefined { Log.info(`[OnyxUpdateManager] Applying update type: ${type} with lastUpdateID: ${lastUpdateID}`, false, {command: request?.command}); diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 0e1662da4d55..508cca34fb88 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -43,20 +43,19 @@ Onyx.connect({ }); /** - * Returns the displayName for a user + * Creates a new displayName for a user based on passed personal details or login. */ -function getDisplayName(login: string, personalDetail: Pick | null): string { +function createDisplayName(login: string, personalDetails: Pick | OnyxEntry): string { // If we have a number like +15857527441@expensify.sms then let's remove @expensify.sms and format it // so that the option looks cleaner in our UI. const userLogin = LocalePhoneNumber.formatPhoneNumber(login); - const userDetails = personalDetail ?? allPersonalDetails?.[login]; - if (!userDetails) { + if (!personalDetails) { return userLogin; } - const firstName = userDetails.firstName ?? ''; - const lastName = userDetails.lastName ?? ''; + const firstName = personalDetails.firstName ?? ''; + const lastName = personalDetails.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim(); // It's possible for fullName to be empty string, so we must use "||" to fallback to userLogin. @@ -150,7 +149,7 @@ function updateDisplayName(firstName: string, lastName: string) { [currentUserAccountID]: { firstName, lastName, - displayName: getDisplayName(currentUserEmail ?? '', { + displayName: createDisplayName(currentUserEmail ?? '', { firstName, lastName, }), @@ -566,7 +565,7 @@ export { deleteAvatar, extractFirstAndLastNameFromAvailableDetails, getCountryISO, - getDisplayName, + createDisplayName, getPrivatePersonalDetails, openPersonalDetailsPage, openPublicProfilePage, diff --git a/src/libs/actions/TeachersUnite.js b/src/libs/actions/TeachersUnite.js deleted file mode 100644 index 45bd70528c32..000000000000 --- a/src/libs/actions/TeachersUnite.js +++ /dev/null @@ -1,183 +0,0 @@ -import lodashGet from 'lodash/get'; -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; -import * as API from '@libs/API'; -import Navigation from '@libs/Navigation/Navigation'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -let sessionEmail = ''; -let sessionAccountID = 0; -Onyx.connect({ - key: ONYXKEYS.SESSION, - callback: (val) => { - sessionEmail = lodashGet(val, 'email', ''); - sessionAccountID = lodashGet(val, 'accountID', 0); - }, -}); - -let allPersonalDetails; -Onyx.connect({ - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - callback: (val) => (allPersonalDetails = val), -}); - -/** - * @param {String} partnerUserID - * @param {String} firstName - * @param {String} lastName - * @param {String} policyID - * @param {String} publicRoomReportID - This is the global reportID for the public room, we'll ignore the optimistic one - */ -function referTeachersUniteVolunteer(partnerUserID, firstName, lastName, policyID, publicRoomReportID) { - const optimisticPublicRoom = ReportUtils.buildOptimisticChatReport([], CONST.TEACHERS_UNITE.PUBLIC_ROOM_NAME, CONST.REPORT.CHAT_TYPE.POLICY_ROOM, policyID); - const optimisticData = [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${publicRoomReportID}`, - value: { - ...optimisticPublicRoom, - reportID: publicRoomReportID, - policyName: CONST.TEACHERS_UNITE.POLICY_NAME, - }, - }, - ]; - API.write( - 'ReferTeachersUniteVolunteer', - { - reportID: publicRoomReportID, - firstName, - lastName, - partnerUserID, - }, - {optimisticData}, - ); - Navigation.dismissModal(publicRoomReportID); -} - -/** - * Optimistically creates a policyExpenseChat for the school principal and passes data to AddSchoolPrincipal - * @param {String} firstName - * @param {String} partnerUserID - * @param {String} lastName - * @param {String} policyID - */ -function addSchoolPrincipal(firstName, partnerUserID, lastName, policyID) { - const policyName = CONST.TEACHERS_UNITE.POLICY_NAME; - const loggedInEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(sessionEmail); - const reportCreationData = {}; - - const expenseChatData = ReportUtils.buildOptimisticChatReport([sessionAccountID], '', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, sessionAccountID, true, policyName); - const expenseChatReportID = expenseChatData.reportID; - const expenseReportCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(sessionEmail); - const expenseReportActionData = { - [expenseReportCreatedAction.reportActionID]: expenseReportCreatedAction, - }; - - reportCreationData[loggedInEmail] = { - reportID: expenseChatReportID, - reportActionID: expenseReportCreatedAction.reportActionID, - }; - - API.write( - 'AddSchoolPrincipal', - { - firstName, - lastName, - partnerUserID, - policyID, - reportCreationData: JSON.stringify(reportCreationData), - }, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - id: policyID, - isPolicyExpenseChatEnabled: true, - type: CONST.POLICY.TYPE.CORPORATE, - name: policyName, - role: CONST.POLICY.ROLE.USER, - owner: sessionEmail, - outputCurrency: lodashGet(allPersonalDetails, [sessionAccountID, 'localCurrencyCode'], CONST.CURRENCY.USD), - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, - value: { - [sessionAccountID]: { - role: CONST.POLICY.ROLE.USER, - errors: {}, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - ...expenseChatData, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, - value: expenseReportActionData, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {pendingAction: null}, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: null, - }, - pendingAction: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, - value: { - [_.keys(expenseChatData)[0]]: { - pendingAction: null, - }, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, - value: null, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, - value: null, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, - value: null, - }, - ], - }, - ); - Navigation.dismissModal(expenseChatReportID); -} - -export default {referTeachersUniteVolunteer, addSchoolPrincipal}; diff --git a/src/libs/actions/TeachersUnite.ts b/src/libs/actions/TeachersUnite.ts new file mode 100644 index 000000000000..6aff31ad3836 --- /dev/null +++ b/src/libs/actions/TeachersUnite.ts @@ -0,0 +1,199 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import * as API from '@libs/API'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import type {OptimisticCreatedReportAction} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +type CreationData = { + reportID: string; + reportActionID: string; +}; + +type ReportCreationData = Record; + +type ExpenseReportActionData = Record; + +let sessionEmail = ''; +let sessionAccountID = 0; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + sessionEmail = value?.email ?? ''; + sessionAccountID = value?.accountID ?? 0; + }, +}); + +let allPersonalDetails: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => (allPersonalDetails = value), +}); + +/** + * @param publicRoomReportID - This is the global reportID for the public room, we'll ignore the optimistic one + */ +function referTeachersUniteVolunteer(partnerUserID: string, firstName: string, lastName: string, policyID: string, publicRoomReportID: string) { + const optimisticPublicRoom = ReportUtils.buildOptimisticChatReport([], CONST.TEACHERS_UNITE.PUBLIC_ROOM_NAME, CONST.REPORT.CHAT_TYPE.POLICY_ROOM, policyID); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${publicRoomReportID}`, + value: { + ...optimisticPublicRoom, + reportID: publicRoomReportID, + policyName: CONST.TEACHERS_UNITE.POLICY_NAME, + }, + }, + ]; + + type ReferTeachersUniteVolunteerParams = { + reportID: string; + firstName: string; + lastName: string; + partnerUserID: string; + }; + + const parameters: ReferTeachersUniteVolunteerParams = { + reportID: publicRoomReportID, + firstName, + lastName, + partnerUserID, + }; + + API.write('ReferTeachersUniteVolunteer', parameters, {optimisticData}); + Navigation.dismissModal(publicRoomReportID); +} + +/** + * Optimistically creates a policyExpenseChat for the school principal and passes data to AddSchoolPrincipal + */ +function addSchoolPrincipal(firstName: string, partnerUserID: string, lastName: string, policyID: string) { + const policyName = CONST.TEACHERS_UNITE.POLICY_NAME; + const loggedInEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(sessionEmail); + const reportCreationData: ReportCreationData = {}; + + const expenseChatData = ReportUtils.buildOptimisticChatReport([sessionAccountID], '', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, sessionAccountID, true, policyName); + const expenseChatReportID = expenseChatData.reportID; + const expenseReportCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(sessionEmail); + const expenseReportActionData: ExpenseReportActionData = { + [expenseReportCreatedAction.reportActionID]: expenseReportCreatedAction, + }; + + reportCreationData[loggedInEmail] = { + reportID: expenseChatReportID, + reportActionID: expenseReportCreatedAction.reportActionID, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + id: policyID, + isPolicyExpenseChatEnabled: true, + type: CONST.POLICY.TYPE.CORPORATE, + name: policyName, + role: CONST.POLICY.ROLE.USER, + owner: sessionEmail, + outputCurrency: allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, + value: { + [sessionAccountID]: { + role: CONST.POLICY.ROLE.USER, + errors: {}, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...expenseChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: expenseReportActionData, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {pendingAction: null}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: { + [Object.keys(expenseChatData)[0]]: { + pendingAction: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: null, + }, + ]; + + type AddSchoolPrincipalParams = { + firstName: string; + lastName: string; + partnerUserID: string; + policyID: string; + reportCreationData: string; + }; + + const parameters: AddSchoolPrincipalParams = { + firstName, + lastName, + partnerUserID, + policyID, + reportCreationData: JSON.stringify(reportCreationData), + }; + + API.write('AddSchoolPrincipal', parameters, {optimisticData, successData, failureData}); + Navigation.dismissModal(expenseChatReportID); +} + +export default {referTeachersUniteVolunteer, addSchoolPrincipal}; diff --git a/src/libs/actions/TransactionEdit.js b/src/libs/actions/TransactionEdit.ts similarity index 77% rename from src/libs/actions/TransactionEdit.js rename to src/libs/actions/TransactionEdit.ts index 2cb79ac387bd..b1710aa72cbb 100644 --- a/src/libs/actions/TransactionEdit.js +++ b/src/libs/actions/TransactionEdit.ts @@ -1,28 +1,32 @@ import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; /** * Makes a backup copy of a transaction object that can be restored when the user cancels editing a transaction. - * - * @param {Object} transaction */ -function createBackupTransaction(transaction) { +function createBackupTransaction(transaction: OnyxEntry) { + if (!transaction) { + return; + } + const newTransaction = { ...transaction, }; + // Use set so that it will always fully overwrite any backup transaction that could have existed before Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); } /** * Removes a transaction from Onyx that was only used temporary in the edit flow - * @param {String} transactionID */ -function removeBackupTransaction(transactionID) { +function removeBackupTransaction(transactionID: string) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); } -function restoreOriginalTransactionFromBackup(transactionID) { +function restoreOriginalTransactionFromBackup(transactionID: string) { const connectionID = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, callback: (backupTransaction) => { diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 8da451f9d64d..8e3bd5f2c017 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -717,7 +717,7 @@ function setContactMethodAsDefault(newDefaultContactMethod: string) { value: { [currentUserAccountID]: { login: newDefaultContactMethod, - displayName: PersonalDetails.getDisplayName(newDefaultContactMethod, myPersonalDetails), + displayName: PersonalDetails.createDisplayName(newDefaultContactMethod, myPersonalDetails), }, }, }, diff --git a/src/pages/EditRequestMerchantPage.js b/src/pages/EditRequestMerchantPage.js index c8766d9acc67..e5966bad2d2b 100644 --- a/src/pages/EditRequestMerchantPage.js +++ b/src/pages/EditRequestMerchantPage.js @@ -27,7 +27,7 @@ function EditRequestMerchantPage({defaultMerchant, onSubmit, isPolicyExpenseChat const styles = useThemeStyles(); const {translate} = useLocalize(); const merchantInputRef = useRef(null); - const isEmptyMerchant = defaultMerchant === '' || defaultMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || defaultMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + const isEmptyMerchant = defaultMerchant === '' || defaultMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const validate = useCallback( (value) => { diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index b322f4eb106c..fe43d96001a0 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -156,13 +156,8 @@ function EditRequestPage({report, route, policyCategories, policyTags, parentRep return; } - // This is possible only in case of IOU requests. - if (newTrimmedMerchant === '') { - IOU.updateMoneyRequestMerchant(transaction.transactionID, report.reportID, CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT); - return; - } - - IOU.updateMoneyRequestMerchant(transaction.transactionID, report.reportID, newMerchant); + // An empty newTrimmedMerchant is only possible for the P2P IOU case + IOU.updateMoneyRequestMerchant(transaction.transactionID, report.reportID, newTrimmedMerchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT); Navigation.dismissModal(); }, [transactionMerchant, transaction, report], diff --git a/src/pages/SearchPage.js b/src/pages/SearchPage.js index 061f43e73de8..a6e453d9f211 100755 --- a/src/pages/SearchPage.js +++ b/src/pages/SearchPage.js @@ -1,7 +1,8 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OptionsSelector from '@components/OptionsSelector'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -42,6 +43,18 @@ const defaultProps = { isSearchingForReports: false, }; +function isSectionsEmpty(sections) { + if (!sections.length) { + return true; + } + + if (!sections[0].data.length) { + return true; + } + + return _.isEmpty(sections[0].data[0]); +} + function SearchPage({betas, personalDetails, reports, isSearchingForReports}) { const [searchValue, setSearchValue] = useState(''); const [searchOptions, setSearchOptions] = useState({ @@ -54,21 +67,45 @@ function SearchPage({betas, personalDetails, reports, isSearchingForReports}) { const {translate} = useLocalize(); const themeStyles = useThemeStyles(); const isMounted = useRef(false); + const interactionTask = useRef(null); const updateOptions = useCallback(() => { - const { - recentReports: localRecentReports, - personalDetails: localPersonalDetails, - userToInvite: localUserToInvite, - } = OptionsListUtils.getSearchOptions(reports, personalDetails, searchValue.trim(), betas); - - setSearchOptions({ - recentReports: localRecentReports, - personalDetails: localPersonalDetails, - userToInvite: localUserToInvite, + if (interactionTask.current) { + interactionTask.current.cancel(); + } + + /** + * Execute the callback after all interactions are done, which means + * after all animations have finished. + */ + interactionTask.current = InteractionManager.runAfterInteractions(() => { + const { + recentReports: localRecentReports, + personalDetails: localPersonalDetails, + userToInvite: localUserToInvite, + } = OptionsListUtils.getSearchOptions(reports, personalDetails, searchValue.trim(), betas); + + setSearchOptions({ + recentReports: localRecentReports, + personalDetails: localPersonalDetails, + userToInvite: localUserToInvite, + }); }); }, [reports, personalDetails, searchValue, betas]); + /** + * Cancel the interaction task when the component unmounts + */ + useEffect( + () => () => { + if (!interactionTask.current) { + return; + } + interactionTask.current.cancel(); + }, + [], + ); + useEffect(() => { Timing.start(CONST.TIMING.SEARCH_RENDER); Performance.markStart(CONST.TIMING.SEARCH_RENDER); @@ -159,7 +196,7 @@ function SearchPage({betas, personalDetails, reports, isSearchingForReports}) { Boolean(searchOptions.userToInvite), searchValue, ); - + const sections = getSections(); return ( + { + Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND)); + }} + style={[ + themeStyles.p5, + themeStyles.w100, + themeStyles.br2, + themeStyles.highlightBG, + themeStyles.flexRow, + themeStyles.justifyContentBetween, + themeStyles.alignItemsCenter, + {gap: 10}, + ]} + accessibilityLabel="referral" + role={CONST.ACCESSIBILITY_ROLE.BUTTON} + > + + {translate(`referralProgram.${CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND}.buttonText1`)} + + {translate(`referralProgram.${CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND}.buttonText2`)} + + + + + + ); +} + +SearchPageFooter.displayName = 'SearchPageFooter'; + +export default SearchPageFooter; diff --git a/src/pages/SearchPage/index.js b/src/pages/SearchPage/index.js new file mode 100644 index 000000000000..211f3622e06c --- /dev/null +++ b/src/pages/SearchPage/index.js @@ -0,0 +1,183 @@ +import PropTypes from 'prop-types'; +import React, {useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import Performance from '@libs/Performance'; +import * as ReportUtils from '@libs/ReportUtils'; +import reportPropTypes from '@pages/reportPropTypes'; +import * as Report from '@userActions/Report'; +import Timing from '@userActions/Timing'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SearchPageFooter from './SearchPageFooter'; + +const propTypes = { + /* Onyx Props */ + + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** All reports shared with the user */ + reports: PropTypes.objectOf(reportPropTypes), +}; + +const defaultProps = { + betas: [], + reports: {}, +}; + +const setPerformanceTimersEnd = () => { + Timing.end(CONST.TIMING.SEARCH_RENDER); + Performance.markEnd(CONST.TIMING.SEARCH_RENDER); +}; + +const SearchPageFooterInstance = ; + +function SearchPage({betas, reports}) { + const [isScreenTransitionEnd, setIsScreenTransitionEnd] = useState(false); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const themeStyles = useThemeStyles(); + const personalDetails = usePersonalDetails(); + + const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; + + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + + useEffect(() => { + Timing.start(CONST.TIMING.SEARCH_RENDER); + Performance.markStart(CONST.TIMING.SEARCH_RENDER); + }, []); + + const onChangeText = (text = '') => { + Report.searchInServer(text); + setSearchValue(text); + }; + + const { + recentReports, + personalDetails: localPersonalDetails, + userToInvite, + headerMessage, + } = useMemo(() => { + if (!isScreenTransitionEnd) { + return { + recentReports: {}, + personalDetails: {}, + userToInvite: {}, + headerMessage: '', + }; + } + const options = OptionsListUtils.getSearchOptions(reports, personalDetails, debouncedSearchValue.trim(), betas); + const header = OptionsListUtils.getHeaderMessage(options.recentReports.length + options.personalDetails.length !== 0, Boolean(options.userToInvite), debouncedSearchValue); + return {...options, headerMessage: header}; + }, [debouncedSearchValue, reports, personalDetails, betas, isScreenTransitionEnd]); + + const sections = useMemo(() => { + const newSections = []; + let indexOffset = 0; + + if (recentReports.length > 0) { + newSections.push({ + data: recentReports, + shouldShow: true, + indexOffset, + }); + indexOffset += recentReports.length; + } + + if (localPersonalDetails.length > 0) { + newSections.push({ + data: localPersonalDetails, + shouldShow: true, + indexOffset, + }); + indexOffset += recentReports.length; + } + + if (userToInvite) { + newSections.push({ + data: [userToInvite], + shouldShow: true, + indexOffset, + }); + } + + return newSections; + }, [localPersonalDetails, recentReports, userToInvite]); + + const selectReport = (option) => { + if (!option) { + return; + } + + if (option.reportID) { + setSearchValue(''); + Navigation.dismissModal(option.reportID); + } else { + Report.navigateToAndOpenReport([option.login]); + } + }; + + const handleScreenTransitionEnd = () => { + setIsScreenTransitionEnd(true); + }; + + const isOptionsDataReady = useMemo(() => ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails), [personalDetails]); + + return ( + + {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( + <> + + + + + + )} + + ); +} + +SearchPage.propTypes = propTypes; +SearchPage.defaultProps = defaultProps; +SearchPage.displayName = 'SearchPage'; + +export default withOnyx({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + isSearchingForReports: { + key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, + initWithStoredValues: false, + }, +})(SearchPage); diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 0f332b546f4b..9d8564a835a0 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -11,6 +11,7 @@ import DragAndDropProvider from '@components/DragAndDrop/Provider'; import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {usePersonalDetails} from '@components/OnyxProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; @@ -34,6 +35,7 @@ import reportMetadataPropTypes from '@pages/reportMetadataPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; import * as ComposerActions from '@userActions/Composer'; import * as Report from '@userActions/Report'; +import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -287,14 +289,52 @@ function ReportScreen({ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(accountManagerReportID)); }, [accountManagerReportID]); + const allPersonalDetails = usePersonalDetails(); + + /** + * @param {String} text + */ + const handleCreateTask = useCallback( + (text) => { + /** + * Matching task rule by group + * Group 1: Start task rule with [] + * Group 2: Optional email group between \s+....\s* start rule with @+valid email + * Group 3: Title is remaining characters + */ + const taskRegex = /^\[\]\s+(?:@([^\s@]+@[\w.-]+\.[a-zA-Z]{2,}))?\s*([\s\S]*)/; + + const match = text.match(taskRegex); + if (!match) { + return false; + } + const title = match[2] ? match[2].trim().replace(/\n/g, ' ') : undefined; + if (!title) { + return false; + } + const email = match[1] ? match[1].trim() : undefined; + let assignee = {}; + if (email) { + assignee = _.find(_.values(allPersonalDetails), (p) => p.login === email) || {}; + } + Task.createTaskAndNavigate(getReportID(route), title, '', assignee.login, assignee.accountID, assignee.assigneeChatReport, report.policyID); + return true; + }, + [allPersonalDetails, report.policyID, route], + ); + /** * @param {String} text */ const onSubmitComment = useCallback( (text) => { + const isTaskCreated = handleCreateTask(text); + if (isTaskCreated) { + return; + } Report.addComment(getReportID(route), text); }, - [route], + [route, handleCreateTask], ); // Clear notifications for the current report when it's opened and re-focused diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 6feee115dc73..b1130af5d2ff 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -675,7 +675,7 @@ function ReportActionItem(props) { > {(hovered) => ( diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index dba8ef2e11d0..fd2e5e7d8f57 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -1,7 +1,7 @@ import {useRoute} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter, InteractionManager} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import _ from 'underscore'; @@ -513,4 +513,4 @@ ReportActionsList.propTypes = propTypes; ReportActionsList.defaultProps = defaultProps; ReportActionsList.displayName = 'ReportActionsList'; -export default compose(withWindowDimensions, withPersonalDetails(), withCurrentUserPersonalDetails)(ReportActionsList); +export default compose(withWindowDimensions, withPersonalDetails(), withCurrentUserPersonalDetails)(memo(ReportActionsList)); diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 2758437a3962..f3c51cb72bbb 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -1,7 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import networkPropTypes from '@components/networkPropTypes'; @@ -174,25 +174,25 @@ function ReportActionsView(props) { } }, [props.report, didSubscribeToReportTypingEvents, reportID]); + const oldestReportAction = useMemo(() => _.last(props.reportActions), [props.reportActions]); + /** * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently * displaying. */ - const loadOlderChats = () => { + const loadOlderChats = useCallback(() => { // Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline. if (props.network.isOffline || props.isLoadingOlderReportActions) { return; } - const oldestReportAction = _.last(props.reportActions); - // Don't load more chats if we're already at the beginning of the chat history if (!oldestReportAction || oldestReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { return; } // Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments Report.getOlderActions(reportID, oldestReportAction.reportActionID); - }; + }, [props.network.isOffline, props.isLoadingOlderReportActions, oldestReportAction, reportID]); /** * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently @@ -229,7 +229,7 @@ function ReportActionsView(props) { /** * Runs when the FlatList finishes laying out */ - const recordTimeToMeasureItemLayout = () => { + const recordTimeToMeasureItemLayout = useCallback(() => { if (didLayout.current) { return; } @@ -244,7 +244,7 @@ function ReportActionsView(props) { } else { Performance.markEnd(CONST.TIMING.SWITCH_REPORT); } - }; + }, [hasCachedActions]); // Comments have not loaded at all yet do nothing if (!_.size(props.reportActions)) { diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index ffcba2048d18..1b7b21d2f8a8 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -1,6 +1,6 @@ /* eslint-disable rulesdir/onyx-props-must-have-default */ import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {InteractionManager, StyleSheet, View} from 'react-native'; import _ from 'underscore'; import LogoComponent from '@assets/images/expensify-wordmark.svg'; @@ -149,6 +149,8 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority ); const viewMode = priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT; + // eslint-disable-next-line react-hooks/exhaustive-deps + const contentContainerStyles = useMemo(() => [styles.sidebarListContainer, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}], [insets]); return ( @@ -187,7 +189,7 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority )} - {!_.isEmpty(physicalCard) && ( + {physicalCard.state === CONST.EXPENSIFY_CARD.STATE.OPEN && ( <> - + void; + disableMemoryOnlyKeys: () => void; +} diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 2cd686c115b4..a9af3339eeb4 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -36,7 +36,7 @@ type Policy = { owner: string; /** The accountID of the policy owner */ - ownerAccountID: number; + ownerAccountID?: number; /** The output currency for the policy */ outputCurrency: string; @@ -51,7 +51,7 @@ type Policy = { pendingAction?: OnyxCommon.PendingAction; /** A list of errors keyed by microtime */ - errors: OnyxCommon.Errors; + errors?: OnyxCommon.Errors; /** Whether this policy was loaded from a policy summary, or loaded completely with all of its values */ isFromFullPolicy?: boolean; @@ -66,13 +66,13 @@ type Policy = { isPolicyExpenseChatEnabled: boolean; /** Whether the auto reporting is enabled */ - autoReporting: boolean; + autoReporting?: boolean; /** The scheduled submit frequency set up on the this policy */ - autoReportingFrequency: ValueOf; + autoReportingFrequency?: ValueOf; /** Whether the scheduled submit is enabled */ - isHarvestingEnabled: boolean; + isHarvestingEnabled?: boolean; /** The accountID of manager who the employee submits their expenses to on paid policies */ submitsTo?: number; diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts index 4a895e7b16dd..d81335b284ac 100644 --- a/src/types/onyx/ReportAction.ts +++ b/src/types/onyx/ReportAction.ts @@ -204,4 +204,4 @@ type ReportAction = ReportActionBase & OriginalMessage; type ReportActions = Record; export default ReportAction; -export type {ReportActions, ReportActionBase, Message, LinkMetadata}; +export type {ReportActions, ReportActionBase, Message, LinkMetadata, OriginalMessage}; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index b06ec9766842..5e606738c56d 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -11,6 +11,7 @@ type Response = { jsonCode?: number | string; onyxData?: OnyxUpdate[]; requestID?: string; + reportID?: string; shouldPauseQueue?: boolean; authToken?: string; encryptedAuthToken?: string; diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 0a1ac84d52f9..3ba19199c30a 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -1991,7 +1991,8 @@ describe('actions/IOU', () => { }); createIOUAction = _.find(reportActionsForReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); - expect(createIOUAction).toBeFalsy(); + // Then the IOU Action should be truthy for offline support. + expect(createIOUAction).toBeTruthy(); // Then we check if the transaction is removed from the transactions collection const t = await new Promise((resolve) => { @@ -2009,6 +2010,7 @@ describe('actions/IOU', () => { // Given fetch operations are resumed fetch.resume(); + await waitForBatchedUpdates(); // Then we recheck the IOU report action from the report actions collection reportActionsForReport = await new Promise((resolve) => { @@ -2059,11 +2061,12 @@ describe('actions/IOU', () => { }); }); - // Then the report should be falsy (indicating deletion) - expect(report).toBeFalsy(); + // Then the report should be truthy for offline support + expect(report).toBeTruthy(); // Given the resumed fetch state fetch.resume(); + await waitForBatchedUpdates(); report = await new Promise((resolve) => { const connectionID = Onyx.connect({ @@ -2076,7 +2079,7 @@ describe('actions/IOU', () => { }); }); - // Then the report should still be falsy (confirming deletion persisted) + // Then the report should be falsy so that there is no trace of the money request. expect(report).toBeFalsy(); }); diff --git a/tests/perf-test/OptionsSelector.perf-test.js b/tests/perf-test/OptionsSelector.perf-test.js index da706d7bb629..557a0baf1ba4 100644 --- a/tests/perf-test/OptionsSelector.perf-test.js +++ b/tests/perf-test/OptionsSelector.perf-test.js @@ -20,20 +20,22 @@ jest.mock('../../src/components/withLocalize', () => (Component) => { return WrappedComponent; }); -jest.mock('../../src/components/withNavigationFocus', () => (Component) => { - function WithNavigationFocus(props) { +jest.mock('../../src/components/withNavigation', () => (Component) => { + function withNavigation(props) { return ( jest.fn(), + }} /> ); } - WithNavigationFocus.displayName = 'WithNavigationFocus'; - - return WithNavigationFocus; + withNavigation.displayName = 'withNavigation'; + return withNavigation; }); const generateSections = (sectionConfigs) => @@ -118,10 +120,10 @@ test('[OptionsSelector] should scroll and press few items', () => { const eventData = generateEventData(100, variables.optionRowHeight); const eventData2 = generateEventData(200, variables.optionRowHeight); - const scenario = (screen) => { + const scenario = async (screen) => { fireEvent.press(screen.getByText('Item 10')); fireEvent.scroll(screen.getByTestId('options-list'), eventData); - fireEvent.press(screen.getByText('Item 100')); + fireEvent.press(await screen.findByText('Item 100')); fireEvent.scroll(screen.getByTestId('options-list'), eventData2); fireEvent.press(screen.getByText('Item 200')); };