diff --git a/.eslintrc.js b/.eslintrc.js index f852c970f85c..281f8269804e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,12 +1,13 @@ const restrictedImportPaths = [ { name: 'react-native', - importNames: ['useWindowDimensions', 'StatusBar', 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable'], + importNames: ['useWindowDimensions', 'StatusBar', 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable', 'Text'], message: [ '', "For 'useWindowDimensions', please use 'src/hooks/useWindowDimensions' instead.", "For 'TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'Pressable', please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from 'src/components/Pressable' instead.", "For 'StatusBar', please use 'src/libs/StatusBar' instead.", + "For 'Text', please use '@components/Text' instead.", ].join('\n'), }, { diff --git a/android/app/build.gradle b/android/app/build.gradle index 3d10b31e72f5..25ff4448ecb8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001042507 - versionName "1.4.25-7" + versionCode 1001042700 + versionName "1.4.27-0" } flavorDimensions "default" diff --git a/assets/images/chatbubble-add.svg b/assets/images/chatbubble-add.svg new file mode 100644 index 000000000000..047a43073b3c --- /dev/null +++ b/assets/images/chatbubble-add.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/assets/images/chatbubble-unread.svg b/assets/images/chatbubble-unread.svg new file mode 100644 index 000000000000..9da789510276 --- /dev/null +++ b/assets/images/chatbubble-unread.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 186d7def3423..9eb16099f535 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -5,7 +5,7 @@ Welcome! Thanks for checking out the New Expensify app and taking the time to co If you would like to become an Expensify contributor, the first step is to read this document in its **entirety**. The second step is to review the README guidelines [here](https://github.com/Expensify/App/blob/main/README.md) to understand our coding philosophy and for a general overview of the code repository (i.e. how to run the app locally, testing, storage, our app philosophy, etc). Please read both documents before asking questions, as it may be covered within the documentation. #### Test Accounts -You can create as many accounts as needed in order to test your changes directly from [the app](https://new.expensify.com/). An initial account can be created when logging in for the first time, and additional accounts can be created by opening the "New Chat" or "Group Chat" pages via the Global Create menu, inputting a valid email or phone number, and tapping the user's avatar. Do use Expensify employee or customer accounts for testing. +You can create as many accounts as needed in order to test your changes directly from [the app](https://new.expensify.com/). An initial account can be created when logging in for the first time, and additional accounts can be created by opening the "New Chat" or "Group Chat" pages via the Global Create menu, inputting a valid email or phone number, and tapping the user's avatar. Do not use Expensify employee or customer accounts for testing. **Notes**: diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md b/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md deleted file mode 100644 index e6d8f2fedb73..000000000000 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Free Trial -description: Learn more about your free trial with Expensify. ---- - -# Overview -New customers can take advantage of a seven-day Free Trial on a group Workspace. This trial period allows you to fully explore Expensify's features and capabilities before deciding on a subscription. -During the trial, your organization will have complete access to all the features and functionality offered by the Collect or Control workspace plan. This post provides a step-by-step guide on how to begin, oversee, and successfully conclude your organization's Expensify Free Trial. - -# How to start a Free Trial -1. Sign up for a new Expensify account at expensify.com. -2. After you've signed up for a new Expensify account, you will see a task on your Home page asking if you are using Expensify for your business or as an individual. - a. **Note**: If you select “Individual”, Expensify is free for individuals for up to 25 SmartScans per month. Selecting Individual will **not** start a Free Trial. More details on individual subscriptions can be found [here](https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription). -3. Select the Business option. -4. Select which Expensify features you'd like to set up for your organization. -5. Congratulations, your seven-day Free Trial has started! - -Once you've made these selections, we'll automatically enroll you in a Free Trial and create a Group Workspace, which will trigger new tasks on your Home page to walk you through how to configure Expensify for your organization. If you have any questions about a specific task or need assistance setting up your company, you can speak with your designated Setup Specialist by clicking “Support” on the left-hand navigation menu and selecting their name. This will allow you to message your Setup Specialist, and request a call if you need. - -# How to unlock additional Free Trial weeks -When you begin a Free Trial, you'll have an initial seven-day period before you need to provide your billing information to continue using Expensify. Luckily, Expensify offers the option to extend your Free Trial by an additional five weeks! - -To access these extra free weeks, all you need to do is complete the tasks on your Home page marked with the "Free Week!" banner. Each task completed in this category will automatically add seven more days to your trial. You can easily keep track of the remaining days of your Free Trial by checking the top right-hand corner of your Expensify Home page. - -# How to make the most of your Free Trial -- Complete all of the "Free Week!" tasks right away. These tasks are crucial for establishing your organization's Workspace, and finishing them will give you a clear idea of how much time you have left in your Free Trial. - -- Every Free Trial has dedicated access to a Setup Specialist who can help you set up your account to your preferences. We highly recommend booking a call with your dedicated Setup Specialist as soon as you start your Free Trial. If you ever need assistance with a setup task, your tasks also include demo videos. - -- Invite a few employees to join Expensify as early as possible during the Free Trial. Bringing employees on board and having them submit expenses will allow you to fully experience how all of the features and functionalities of Expensify work together to save time. We provide excellent resources to help employees get started with Expensify. - -- Establish a connection between Expensify and your accounting system from the outset. By doing this early, you can start testing Expensify comprehensively from end to end. - -{% include faq-begin.md %} -## What happens when my Free Trial ends? -If you’ve already added a billing card to Expensify, you will automatically start your organization’s Expensify subscription after your Free Trial ends. At the beginning of the following month, we'll bill the card you have on file for your subscription, adjusting the charge to exclude the Free Trial period. -If your Free Trial concludes without a billing card on file, you will see a notification on your Home page saying, 'Your Free Trial has expired.' -If you still have outstanding 'Free Week!' tasks, completing them will extend your Free Trial by additional days. -If you continue without adding a billing card, you will be granted a five-day grace period after the following billing cycle before all Group Workspace functionality is disabled. To continue using Expensify's Group Workspace features, you will need to input your billing card information and initiate a subscription. - -## How can I downgrade my account after my Free Trial ends? -If you’d like to downgrade to an individual account after your Free Trial has ended, you will need to delete any Group Workspace that you have created. This action will remove the Workspaces, subscription, and any amount owed. You can do this in one of two ways from the Expensify web app: -- Select the “Downgrade” option on the billing card task on your Home page. -- Go to **Settings > Workspaces > [Workspace name]**, then click the gear button next to the Workspace and select Delete. - -{% include faq-end.md %} diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index c76c947aafd9..ca8218d53d87 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.25 + 1.4.27 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.25.7 + 1.4.27.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 1dde1a528b3c..4a261dccc11e 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.25 + 1.4.27 CFBundleSignature ???? CFBundleVersion - 1.4.25.7 + 1.4.27.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index b840fa5cd80a..0ba053ad0026 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -3,9 +3,9 @@ CFBundleShortVersionString - 1.4.25 + 1.4.27 CFBundleVersion - 1.4.25.7 + 1.4.27.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 148bf7157119..1dcdff30b536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.25-7", + "version": "1.4.27-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.25-7", + "version": "1.4.27-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -94,7 +94,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.126", + "react-native-onyx": "1.0.118", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -47034,17 +47034,17 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.126", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.126.tgz", - "integrity": "sha512-tUJI1mQaWXLfyBFYQQWM6mm9GiCqIXGvjbqJkH1fLY3OqbGW6DyH4CxC+qJrqfi4bKZgZHp5xlBHhkPV4pKK2A==", + "version": "1.0.118", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.118.tgz", + "integrity": "sha512-w54jO+Bpu1ElHsrxZXIIpcBqNkrUvuVCQmwWdfOW5LvO4UwsPSwmMxzExbUZ4ip+7CROmm10IgXFaAoyfeYSVQ==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "underscore": "^1.13.6" }, "engines": { - "node": "20.9.0", - "npm": "10.1.0" + "node": ">=16.15.1 <=20.9.0", + "npm": ">=8.11.0 <=10.1.0" }, "peerDependencies": { "idb-keyval": "^6.2.1", @@ -89702,9 +89702,9 @@ } }, "react-native-onyx": { - "version": "1.0.126", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.126.tgz", - "integrity": "sha512-tUJI1mQaWXLfyBFYQQWM6mm9GiCqIXGvjbqJkH1fLY3OqbGW6DyH4CxC+qJrqfi4bKZgZHp5xlBHhkPV4pKK2A==", + "version": "1.0.118", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.118.tgz", + "integrity": "sha512-w54jO+Bpu1ElHsrxZXIIpcBqNkrUvuVCQmwWdfOW5LvO4UwsPSwmMxzExbUZ4ip+7CROmm10IgXFaAoyfeYSVQ==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index a842ffd2fdb4..5664d2326a57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.25-7", + "version": "1.4.27-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.", @@ -142,7 +142,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.126", + "react-native-onyx": "1.0.118", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", diff --git a/patches/react-native-blob-util+0.17.3.patch b/patches/react-native-blob-util+0.17.3.patch new file mode 100644 index 000000000000..2ade175a7b30 --- /dev/null +++ b/patches/react-native-blob-util+0.17.3.patch @@ -0,0 +1,17 @@ +diff --git a/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilReq.java b/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilReq.java +index 4b41402..4f07fc6 100644 +--- a/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilReq.java ++++ b/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilReq.java +@@ -279,7 +279,11 @@ public class ReactNativeBlobUtilReq extends BroadcastReceiver implements Runnabl + DownloadManager dm = (DownloadManager) appCtx.getSystemService(Context.DOWNLOAD_SERVICE); + downloadManagerId = dm.enqueue(req); + androidDownloadManagerTaskTable.put(taskId, Long.valueOf(downloadManagerId)); +- appCtx.registerReceiver(this, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); ++ if(Build.VERSION.SDK_INT >= 34 ){ ++ appCtx.registerReceiver(this, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), Context.RECEIVER_EXPORTED); ++ }else{ ++ appCtx.registerReceiver(this, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); ++ } + future = scheduledExecutorService.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { diff --git a/src/CONST.ts b/src/CONST.ts index d6f3d3cdcef6..e5b44042a550 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -603,11 +603,9 @@ const CONST = { }, THREAD_DISABLED: ['CREATED'], }, - CANCEL_PAYMENT_REASONS: { - ADMIN: 'CANCEL_REASON_ADMIN', - }, ACTIONABLE_MENTION_WHISPER_RESOLUTION: { INVITE: 'invited', + NOTHING: 'nothing', }, ARCHIVE_REASON: { DEFAULT: 'default', @@ -3121,6 +3119,8 @@ const CONST = { EMAIL: 'EMAIL', REPORT: 'REPORT', }, + + MINI_CONTEXT_MENU_MAX_ITEMS: 4, } as const; export default CONST; diff --git a/src/components/AddressSearch/CurrentLocationButton.js b/src/components/AddressSearch/CurrentLocationButton.tsx similarity index 72% rename from src/components/AddressSearch/CurrentLocationButton.js rename to src/components/AddressSearch/CurrentLocationButton.tsx index 06541565f567..11bd0a64eba5 100644 --- a/src/components/AddressSearch/CurrentLocationButton.js +++ b/src/components/AddressSearch/CurrentLocationButton.tsx @@ -1,29 +1,16 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import {Text} from 'react-native'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; import colors from '@styles/theme/colors'; +import type {CurrentLocationButtonProps} from './types'; -const propTypes = { - /** Callback that runs when location button is clicked */ - onPress: PropTypes.func, - - /** Boolean to indicate if the button is clickable */ - isDisabled: PropTypes.bool, -}; - -const defaultProps = { - isDisabled: false, - onPress: () => {}, -}; - -function CurrentLocationButton({onPress, isDisabled}) { +function CurrentLocationButton({onPress, isDisabled = false}: CurrentLocationButtonProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -32,7 +19,7 @@ function CurrentLocationButton({onPress, isDisabled}) { onPress?.()} accessibilityLabel={translate('location.useCurrent')} disabled={isDisabled} onMouseDown={(e) => e.preventDefault()} @@ -48,7 +35,5 @@ function CurrentLocationButton({onPress, isDisabled}) { } CurrentLocationButton.displayName = 'CurrentLocationButton'; -CurrentLocationButton.propTypes = propTypes; -CurrentLocationButton.defaultProps = defaultProps; export default CurrentLocationButton; diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.tsx similarity index 63% rename from src/components/AddressSearch/index.js rename to src/components/AddressSearch/index.tsx index 357f5af8cb58..89e87eeebe54 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.tsx @@ -1,184 +1,79 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {ActivityIndicator, Keyboard, LogBox, ScrollView, Text, View} from 'react-native'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {ForwardedRef} from 'react'; +import {ActivityIndicator, Keyboard, LogBox, ScrollView, View} from 'react-native'; +import type {LayoutChangeEvent} from 'react-native'; import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete'; -import _ from 'underscore'; +import type {GooglePlaceData, GooglePlaceDetail} from 'react-native-google-places-autocomplete'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import LocationErrorMessage from '@components/LocationErrorMessage'; -import networkPropTypes from '@components/networkPropTypes'; -import {withNetwork} from '@components/OnyxProvider'; +import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ApiUtils from '@libs/ApiUtils'; -import compose from '@libs/compose'; import getCurrentPosition from '@libs/getCurrentPosition'; +import type {GeolocationErrorCodeType} from '@libs/getCurrentPosition/getCurrentPosition.types'; import * as GooglePlacesUtils from '@libs/GooglePlacesUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import CurrentLocationButton from './CurrentLocationButton'; import isCurrentTargetInsideContainer from './isCurrentTargetInsideContainer'; +import type {AddressSearchProps, RenamedInputKeysProps} from './types'; // The error that's being thrown below will be ignored until we fork the // react-native-google-places-autocomplete repo and replace the // VirtualizedList component with a VirtualizedList-backed instead LogBox.ignoreLogs(['VirtualizedLists should never be nested']); -const propTypes = { - /** The ID used to uniquely identify the input in a Form */ - inputID: PropTypes.string, - - /** Saves a draft of the input value when used in a form */ - shouldSaveDraft: PropTypes.bool, - - /** Callback that is called when the text input is blurred */ - onBlur: PropTypes.func, - - /** Error text to display */ - errorText: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]), - - /** Hint text to display */ - hint: PropTypes.string, - - /** The label to display for the field */ - label: PropTypes.string.isRequired, - - /** The value to set the field to initially */ - value: PropTypes.string, - - /** The value to set the field to initially */ - defaultValue: PropTypes.string, - - /** A callback function when the value of this field has changed */ - onInputChange: PropTypes.func.isRequired, - - /** A callback function when an address has been auto-selected */ - onPress: PropTypes.func, - - /** Customize the TextInput container */ - // eslint-disable-next-line react/forbid-prop-types - containerStyles: PropTypes.arrayOf(PropTypes.object), - - /** Should address search be limited to results in the USA */ - isLimitedToUSA: PropTypes.bool, - - /** Shows a current location button in suggestion list */ - canUseCurrentLocation: PropTypes.bool, - - /** A list of predefined places that can be shown when the user isn't searching for something */ - predefinedPlaces: PropTypes.arrayOf( - PropTypes.shape({ - /** A description of the location (usually the address) */ - description: PropTypes.string, - - /** The name of the location */ - name: PropTypes.string, - - /** Data required by the google auto complete plugin to know where to put the markers on the map */ - geometry: PropTypes.shape({ - /** Data about the location */ - location: PropTypes.shape({ - /** Lattitude of the location */ - lat: PropTypes.number, - - /** Longitude of the location */ - lng: PropTypes.number, - }), - }), - }), - ), - - /** A map of inputID key names */ - renamedInputKeys: PropTypes.shape({ - street: PropTypes.string, - street2: PropTypes.string, - city: PropTypes.string, - state: PropTypes.string, - lat: PropTypes.string, - lng: PropTypes.string, - zipCode: PropTypes.string, - }), - - /** Maximum number of characters allowed in search input */ - maxInputLength: PropTypes.number, - - /** The result types to return from the Google Places Autocomplete request */ - resultTypes: PropTypes.string, - - /** Information about the network */ - network: networkPropTypes.isRequired, - - /** Location bias for querying search results. */ - locationBias: PropTypes.string, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - inputID: undefined, - shouldSaveDraft: false, - onBlur: () => {}, - onPress: () => {}, - errorText: '', - hint: '', - value: undefined, - defaultValue: undefined, - containerStyles: [], - isLimitedToUSA: false, - canUseCurrentLocation: false, - renamedInputKeys: { - street: 'addressStreet', - street2: 'addressStreet2', - city: 'addressCity', - state: 'addressState', - zipCode: 'addressZipCode', - lat: 'addressLat', - lng: 'addressLng', - }, - maxInputLength: undefined, - predefinedPlaces: [], - resultTypes: 'address', - locationBias: undefined, -}; - -function AddressSearch({ - canUseCurrentLocation, - containerStyles, - defaultValue, - errorText, - hint, - innerRef, - inputID, - isLimitedToUSA, - label, - maxInputLength, - network, - onBlur, - onInputChange, - onPress, - predefinedPlaces, - preferredLocale, - renamedInputKeys, - resultTypes, - shouldSaveDraft, - translate, - value, - locationBias, -}) { +function AddressSearch( + { + canUseCurrentLocation = false, + containerStyles, + defaultValue, + errorText = '', + hint = '', + inputID, + isLimitedToUSA = false, + label, + maxInputLength, + onBlur, + onInputChange, + onPress, + predefinedPlaces = [], + preferredLocale, + renamedInputKeys = { + street: 'addressStreet', + street2: 'addressStreet2', + city: 'addressCity', + state: 'addressState', + zipCode: 'addressZipCode', + lat: 'addressLat', + lng: 'addressLng', + }, + resultTypes = 'address', + shouldSaveDraft = false, + value, + locationBias, + }: AddressSearchProps, + ref: ForwardedRef, +) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); const [displayListViewBorder, setDisplayListViewBorder] = useState(false); const [isTyping, setIsTyping] = useState(false); const [isFocused, setIsFocused] = useState(false); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [searchValue, setSearchValue] = useState(value || defaultValue || ''); - const [locationErrorCode, setLocationErrorCode] = useState(null); + const [locationErrorCode, setLocationErrorCode] = useState(null); const [isFetchingCurrentLocation, setIsFetchingCurrentLocation] = useState(false); const shouldTriggerGeolocationCallbacks = useRef(true); - const containerRef = useRef(); + const containerRef = useRef(null); const query = useMemo( () => ({ language: preferredLocale, @@ -189,18 +84,18 @@ function AddressSearch({ [preferredLocale, resultTypes, isLimitedToUSA, locationBias], ); const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; - const saveLocationDetails = (autocompleteData, details) => { - const addressComponents = details.address_components; + const saveLocationDetails = (autocompleteData: GooglePlaceData, details: GooglePlaceDetail | null) => { + const addressComponents = details?.address_components; if (!addressComponents) { // When there are details, but no address_components, this indicates that some predefined options have been passed // to this component which don't match the usual properties coming from auto-complete. In that case, only a limited // amount of data massaging needs to happen for what the parent expects to get from this function. - if (_.size(details)) { - onPress({ - address: autocompleteData.description || lodashGet(details, 'description', ''), - lat: lodashGet(details, 'geometry.location.lat', 0), - lng: lodashGet(details, 'geometry.location.lng', 0), - name: lodashGet(details, 'name'), + if (details) { + onPress?.({ + address: autocompleteData.description ?? '', + lat: details.geometry.location.lat ?? 0, + lng: details.geometry.location.lng ?? 0, + name: details.name, }); } return; @@ -219,14 +114,19 @@ function AddressSearch({ administrative_area_level_2: stateFallback, country: countryPrimary, } = GooglePlacesUtils.getAddressComponents(addressComponents, { + // eslint-disable-next-line @typescript-eslint/naming-convention street_number: 'long_name', route: 'long_name', subpremise: 'long_name', locality: 'long_name', sublocality: 'long_name', + // eslint-disable-next-line @typescript-eslint/naming-convention postal_town: 'long_name', + // eslint-disable-next-line @typescript-eslint/naming-convention postal_code: 'long_name', + // eslint-disable-next-line @typescript-eslint/naming-convention administrative_area_level_1: 'short_name', + // eslint-disable-next-line @typescript-eslint/naming-convention administrative_area_level_2: 'long_name', country: 'short_name', }); @@ -234,6 +134,7 @@ function AddressSearch({ // The state's iso code (short_name) is needed for the StatePicker component but we also // need the state's full name (long_name) when we render the state in a TextInput. const {administrative_area_level_1: longStateName} = GooglePlacesUtils.getAddressComponents(addressComponents, { + // eslint-disable-next-line @typescript-eslint/naming-convention administrative_area_level_1: 'long_name', }); @@ -243,15 +144,16 @@ function AddressSearch({ country: countryFallbackLongName = '', state: stateAutoCompleteFallback = '', city: cityAutocompleteFallback = '', - } = GooglePlacesUtils.getPlaceAutocompleteTerms(autocompleteData.terms); + } = GooglePlacesUtils.getPlaceAutocompleteTerms(autocompleteData?.terms ?? []); - const countryFallback = _.findKey(CONST.ALL_COUNTRIES, (country) => country === countryFallbackLongName); + const countryFallback = Object.keys(CONST.ALL_COUNTRIES).find((country) => country === countryFallbackLongName); - const country = countryPrimary || countryFallback; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const country = countryPrimary || countryFallback || ''; const values = { street: `${streetNumber} ${streetName}`.trim(), - name: lodashGet(details, 'name', ''), + name: details.name ?? '', // Autocomplete returns any additional valid address fragments (e.g. Apt #) as subpremise. street2: subpremise, // Make sure country is updated first, since city and state will be reset if the country changes @@ -264,9 +166,9 @@ function AddressSearch({ city: locality || postalTown || sublocality || cityAutocompleteFallback, zipCode, - lat: lodashGet(details, 'geometry.location.lat', 0), - lng: lodashGet(details, 'geometry.location.lng', 0), - address: autocompleteData.description || lodashGet(details, 'formatted_address', ''), + lat: details.geometry.location.lat ?? 0, + lng: details.geometry.location.lng ?? 0, + address: autocompleteData.description || details.formatted_address || '', }; // If the address is not in the US, use the full length state name since we're displaying the address's @@ -282,7 +184,7 @@ function AddressSearch({ } // Set the state to be the same as the city in case the state is empty. - if (_.isEmpty(values.state)) { + if (!values.state) { values.state = values.city; } @@ -290,8 +192,8 @@ function AddressSearch({ // We are setting up a fallback to ensure "values.street" is populated with a relevant value if (!values.street && details.adr_address) { const streetAddressRegex = /([^<]*)<\/span>/; - const adr_address = details.adr_address.match(streetAddressRegex); - const streetAddressFallback = lodashGet(adr_address, [1], null); + const adrAddress = details.adr_address.match(streetAddressRegex); + const streetAddressFallback = adrAddress ? adrAddress?.[1] : null; if (streetAddressFallback) { values.street = streetAddressFallback; } @@ -299,28 +201,28 @@ function AddressSearch({ // Not all pages define the Address Line 2 field, so in that case we append any additional address details // (e.g. Apt #) to Address Line 1 - if (subpremise && typeof renamedInputKeys.street2 === 'undefined') { + if (subpremise && typeof renamedInputKeys?.street2 === 'undefined') { values.street += `, ${subpremise}`; } - const isValidCountryCode = lodashGet(CONST.ALL_COUNTRIES, country); + const isValidCountryCode = !!Object.keys(CONST.ALL_COUNTRIES).find((foundCountry) => foundCountry === country); if (isValidCountryCode) { values.country = country; } if (inputID) { - _.each(values, (inputValue, key) => { - const inputKey = lodashGet(renamedInputKeys, key, key); + Object.entries(values).forEach(([key, inputValue]) => { + const inputKey = renamedInputKeys?.[key as keyof RenamedInputKeysProps] ?? key; if (!inputKey) { return; } - onInputChange(inputValue, inputKey); + onInputChange?.(inputValue, inputKey); }); } else { - onInputChange(values); + onInputChange?.(values); } - onPress(values); + onPress?.(values); }; /** Gets the user's current location and registers success/error callbacks */ @@ -351,7 +253,7 @@ function AddressSearch({ address: CONST.YOUR_LOCATION_TEXT, name: CONST.YOUR_LOCATION_TEXT, }; - onPress(location); + onPress?.(location); }, (errorData) => { if (!shouldTriggerGeolocationCallbacks.current) { @@ -368,19 +270,22 @@ function AddressSearch({ ); }; - const renderHeaderComponent = () => - predefinedPlaces.length > 0 && ( - <> - {/* This will show current location button in list if there are some recent destinations */} - {shouldShowCurrentLocationButton && ( - - )} - {!value && {translate('common.recentDestinations')}} - - ); + const renderHeaderComponent = () => ( + <> + {predefinedPlaces.length > 0 && ( + <> + {/* This will show current location button in list if there are some recent destinations */} + {shouldShowCurrentLocationButton && ( + + )} + {!value && {translate('common.recentDestinations')}} + + )} + + ); // eslint-disable-next-line arrow-body-style useEffect(() => { @@ -392,10 +297,8 @@ function AddressSearch({ const listEmptyComponent = useCallback( () => - network.isOffline || !isTyping ? null : ( - {translate('common.noResultsFound')} - ), - [network.isOffline, isTyping, styles, translate], + !!isOffline || !isTyping ? null : {translate('common.noResultsFound')}, + [isOffline, isTyping, styles, translate], ); const listLoader = useCallback( @@ -464,27 +367,15 @@ function AddressSearch({ query={query} requestUrl={{ useOnPlatform: 'all', - url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), + url: isOffline ? '' : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), }} textInputProps={{ InputComp: TextInput, - ref: (node) => { - if (!innerRef) { - return; - } - - if (_.isFunction(innerRef)) { - innerRef(node); - return; - } - - // eslint-disable-next-line no-param-reassign - innerRef.current = node; - }, + ref, label, containerStyles, errorText, - hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint, + hint: displayListViewBorder || (predefinedPlaces?.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint, value, defaultValue, inputID, @@ -498,20 +389,19 @@ function AddressSearch({ setIsFocused(false); setIsTyping(false); } - onBlur(); + onBlur?.(); }, autoComplete: 'off', - onInputChange: (text) => { + onInputChange: (text: string) => { setSearchValue(text); setIsTyping(true); if (inputID) { - onInputChange(text); + onInputChange?.(text); } else { onInputChange({street: text}); } - // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering - if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) { + if (!text && !predefinedPlaces.length) { setDisplayListViewBorder(false); } }, @@ -530,22 +420,21 @@ function AddressSearch({ isRowScrollable={false} listHoverColor={theme.border} listUnderlayColor={theme.buttonPressedBG} - onLayout={(event) => { + onLayout={(event: LayoutChangeEvent) => { // We use the height of the element to determine if we should hide the border of the listView dropdown // to prevent a lingering border when there are no address suggestions. setDisplayListViewBorder(event.nativeEvent.layout.height > variables.googleEmptyListViewHeight); }} inbetweenCompo={ // We want to show the current location button even if there are no recent destinations - predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( + predefinedPlaces?.length === 0 && + shouldShowCurrentLocationButton && ( - ) : ( - <> ) } placeholder="" @@ -561,18 +450,6 @@ function AddressSearch({ ); } -AddressSearch.propTypes = propTypes; -AddressSearch.defaultProps = defaultProps; -AddressSearch.displayName = 'AddressSearch'; - -const AddressSearchWithRef = React.forwardRef((props, ref) => ( - -)); - -AddressSearchWithRef.displayName = 'AddressSearchWithRef'; +AddressSearch.displayName = 'AddressSearchWithRef'; -export default compose(withNetwork(), withLocalize)(AddressSearchWithRef); +export default forwardRef(AddressSearch); diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.js deleted file mode 100644 index 18bfc10a8dcb..000000000000 --- a/src/components/AddressSearch/isCurrentTargetInsideContainer.js +++ /dev/null @@ -1,8 +0,0 @@ -function isCurrentTargetInsideContainer(event, containerRef) { - // The related target check is required here - // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false - // it will make the auto complete component re-render before onPress is called making selecting an option not working. - return containerRef.current && event.target && containerRef.current.contains(event.relatedTarget); -} - -export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js deleted file mode 100644 index dbf0004b08d9..000000000000 --- a/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js +++ /dev/null @@ -1,6 +0,0 @@ -function isCurrentTargetInsideContainer() { - // The related target check is not required here because in native there is no race condition rendering like on the web - return false; -} - -export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.native.ts b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.ts new file mode 100644 index 000000000000..b53b9e3ddec0 --- /dev/null +++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.ts @@ -0,0 +1,6 @@ +import type {IsCurrentTargetInsideContainerType} from './types'; + +// The related target check is not required here because in native there is no race condition rendering like on the web +const isCurrentTargetInsideContainer: IsCurrentTargetInsideContainerType = () => false; + +export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.ts b/src/components/AddressSearch/isCurrentTargetInsideContainer.ts new file mode 100644 index 000000000000..a50eb747b400 --- /dev/null +++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.ts @@ -0,0 +1,14 @@ +import type {IsCurrentTargetInsideContainerType} from './types'; + +const isCurrentTargetInsideContainer: IsCurrentTargetInsideContainerType = (event, containerRef) => { + // The related target check is required here + // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false + // it will make the auto complete component re-render before onPress is called making selecting an option not working. + if (!containerRef.current || !event.target || !('relatedTarget' in event) || !('contains' in containerRef.current)) { + return false; + } + + return !!containerRef.current.contains(event.relatedTarget as Node); +}; + +export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts new file mode 100644 index 000000000000..8016f1b2ea39 --- /dev/null +++ b/src/components/AddressSearch/types.ts @@ -0,0 +1,96 @@ +import type {RefObject} from 'react'; +import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, View, ViewStyle} from 'react-native'; +import type {Place} from 'react-native-google-places-autocomplete'; +import type Locale from '@src/types/onyx/Locale'; + +type CurrentLocationButtonProps = { + /** Callback that is called when the button is clicked */ + onPress?: () => void; + + /** Boolean to indicate if the button is clickable */ + isDisabled?: boolean; +}; + +type RenamedInputKeysProps = { + street: string; + street2: string; + city: string; + state: string; + lat: string; + lng: string; + zipCode: string; +}; + +type OnPressProps = { + address: string; + lat: number; + lng: number; + name: string; +}; + +type StreetValue = { + street: string; +}; + +type AddressSearchProps = { + /** The ID used to uniquely identify the input in a Form */ + inputID?: string; + + /** Saves a draft of the input value when used in a form */ + shouldSaveDraft?: boolean; + + /** Callback that is called when the text input is blurred */ + onBlur?: () => void; + + /** Error text to display */ + errorText?: string; + + /** Hint text to display */ + hint?: string; + + /** The label to display for the field */ + label: string; + + /** The value to set the field to initially */ + value?: string; + + /** The value to set the field to initially */ + defaultValue?: string; + + /** A callback function when the value of this field has changed */ + onInputChange: (value: string | number | RenamedInputKeysProps | StreetValue, key?: string) => void; + + /** A callback function when an address has been auto-selected */ + onPress?: (props: OnPressProps) => void; + + /** Customize the TextInput container */ + containerStyles?: StyleProp; + + /** Should address search be limited to results in the USA */ + isLimitedToUSA?: boolean; + + /** Shows a current location button in suggestion list */ + canUseCurrentLocation?: boolean; + + /** A list of predefined places that can be shown when the user isn't searching for something */ + predefinedPlaces?: Place[]; + + /** A map of inputID key names */ + renamedInputKeys: RenamedInputKeysProps; + + /** Maximum number of characters allowed in search input */ + maxInputLength?: number; + + /** The result types to return from the Google Places Autocomplete request */ + resultTypes?: string; + + /** Location bias for querying search results. */ + locationBias?: string; + + /** The user's preferred locale e.g. 'en', 'es-ES' */ + preferredLocale?: Locale; +}; + +type IsCurrentTargetInsideContainerType = (event: FocusEvent | NativeSyntheticEvent, containerRef: RefObject) => boolean; + +export type {CurrentLocationButtonProps, AddressSearchProps, RenamedInputKeysProps, IsCurrentTargetInsideContainerType}; diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx index bb3792f59d9f..99a0ee3bf683 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx @@ -1,5 +1,6 @@ import Str from 'expensify-common/lib/str'; import React, {useEffect, useRef} from 'react'; +// eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; import {StyleSheet} from 'react-native'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; diff --git a/src/components/AnonymousReportFooter.tsx b/src/components/AnonymousReportFooter.tsx index ad79e316baf3..04e8a5f8d55b 100644 --- a/src/components/AnonymousReportFooter.tsx +++ b/src/components/AnonymousReportFooter.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {Text, View} from 'react-native'; +import {View} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx/lib/types'; import useLocalize from '@hooks/useLocalize'; @@ -9,6 +9,7 @@ import type {PersonalDetails, Report} from '@src/types/onyx'; import AvatarWithDisplayName from './AvatarWithDisplayName'; import Button from './Button'; import ExpensifyWordmark from './ExpensifyWordmark'; +import Text from './Text'; type AnonymousReportFooterProps = { /** The report currently being looked at */ diff --git a/src/components/AutoEmailLink.js b/src/components/AutoEmailLink.tsx similarity index 68% rename from src/components/AutoEmailLink.js rename to src/components/AutoEmailLink.tsx index af581525ab69..e1a9bdd2794b 100644 --- a/src/components/AutoEmailLink.js +++ b/src/components/AutoEmailLink.tsx @@ -1,18 +1,13 @@ import {CONST} from 'expensify-common/lib/CONST'; -import PropTypes from 'prop-types'; import React from 'react'; -import _ from 'underscore'; +import type {StyleProp, TextStyle} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import Text from './Text'; import TextLink from './TextLink'; -const propTypes = { - text: PropTypes.string.isRequired, - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), -}; - -const defaultProps = { - style: [], +type AutoEmailLinkProps = { + text: string; + style?: StyleProp; }; /* @@ -21,14 +16,15 @@ const defaultProps = { * - Else just render it inside `Text` component */ -function AutoEmailLink(props) { +function AutoEmailLink({text, style}: AutoEmailLinkProps) { const styles = useThemeStyles(); return ( - - {_.map(props.text.split(CONST.REG_EXP.EXTRACT_EMAIL), (str, index) => { + + {text.split(CONST.REG_EXP.EXTRACT_EMAIL).map((str, index) => { if (CONST.REG_EXP.EMAIL.test(str)) { return ( {str} @@ -52,6 +49,5 @@ function AutoEmailLink(props) { } AutoEmailLink.displayName = 'AutoEmailLink'; -AutoEmailLink.propTypes = propTypes; -AutoEmailLink.defaultProps = defaultProps; + export default AutoEmailLink; diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 19b7bb6bb30a..3c2caf020ef7 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -3,6 +3,7 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {flushSync} from 'react-dom'; +// eslint-disable-next-line no-restricted-imports import type {DimensionValue, NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputProps, TextInputSelectionChangeEventData} from 'react-native'; import {StyleSheet, View} from 'react-native'; import type {AnimatedProps} from 'react-native-reanimated'; diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index a345ec72ad11..f25fc978e3ee 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -15,7 +15,7 @@ type ConfirmModalProps = { onConfirm: () => void; /** A callback to call when the form has been closed */ - onCancel?: (ref?: React.RefObject) => void; + onCancel?: () => void; /** Modal visibility */ isVisible: boolean; diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index 781d2f718bcf..4e9bd22e004c 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -1,5 +1,6 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useImperativeHandle} from 'react'; +import type {GestureResponderEvent} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; @@ -27,7 +28,7 @@ type ContextMenuItemProps = { isMini?: boolean; /** Callback to fire when the item is pressed */ - onPress: () => void; + onPress: (event?: GestureResponderEvent | MouseEvent | KeyboardEvent) => void; /** A description text to show under the title */ description?: string; @@ -52,11 +53,11 @@ function ContextMenuItem( const {windowWidth} = useWindowDimensions(); const [isThrottledButtonActive, setThrottledButtonInactive] = useThrottledButtonState(); - const triggerPressAndUpdateSuccess = () => { + const triggerPressAndUpdateSuccess = (event?: GestureResponderEvent | MouseEvent | KeyboardEvent) => { if (!isThrottledButtonActive) { return; } - onPress(); + onPress(event); // 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. @@ -104,3 +105,4 @@ function ContextMenuItem( ContextMenuItem.displayName = 'ContextMenuItem'; export default forwardRef(ContextMenuItem); +export type {ContextMenuItemHandle}; diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx index 9b3eaeb4e447..430c00cf8804 100644 --- a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx +++ b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx @@ -1,5 +1,6 @@ import type {RefObject} from 'react'; import React, {useCallback} from 'react'; +// eslint-disable-next-line no-restricted-imports import type {Text as RNText, StyleProp, TextStyle} from 'react-native'; import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx index 0d554baabeda..ce0ae7ddcf4f 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -1,4 +1,5 @@ import React, {Fragment, useCallback, useRef} from 'react'; +// eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; import {View} from 'react-native'; import Text from '@components/Text'; diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index 532eb61a99a9..eaf89b7f64ea 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -86,7 +86,13 @@ const EmojiPicker = forwardRef((props, ref) => { if (isNavigating) { onModalHide.current = () => {}; } - emojiPopoverAnchorRef.current = null; + const currOnModalHide = onModalHide.current; + onModalHide.current = () => { + if (currOnModalHide) { + currOnModalHide(); + } + emojiPopoverAnchorRef.current = null; + }; setIsEmojiPickerVisible(false); }; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js index 806ab6587917..059f3fc5f848 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js @@ -1,10 +1,11 @@ import {FlashList} from '@shopify/flash-list'; import PropTypes from 'prop-types'; import React, {useMemo} from 'react'; -import {StyleSheet, Text, View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import CategoryShortcutBar from '@components/EmojiPicker/CategoryShortcutBar'; import EmojiSkinToneList from '@components/EmojiPicker/EmojiSkinToneList'; import refPropTypes from '@components/refPropTypes'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 50b24e368fc6..4d1630dbbe06 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -73,6 +73,9 @@ const propTypes = { /** Should validate function be called when the value of the input is changed */ shouldValidateOnChange: PropTypes.bool, + + /** Should fix the errors alert be displayed when there is an error in the form */ + shouldHideFixErrorsAlert: PropTypes.bool, }; // In order to prevent Checkbox focus loss when the user are focusing a TextInput and proceeds to toggle a CheckBox in web and mobile web. @@ -94,6 +97,7 @@ const defaultProps = { validate: () => {}, shouldValidateOnBlur: true, shouldValidateOnChange: true, + shouldHideFixErrorsAlert: false, }; function getInitialValueByType(valueType) { diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js index da34262a8af8..f1c5d6de9071 100644 --- a/src/components/Form/FormWrapper.js +++ b/src/components/Form/FormWrapper.js @@ -66,6 +66,8 @@ const propTypes = { errors: errorsPropType.isRequired, inputRefs: PropTypes.objectOf(refPropTypes).isRequired, + + shouldHideFixErrorsAlert: PropTypes.bool, }; const defaultProps = { @@ -79,6 +81,7 @@ const defaultProps = { footerContent: null, style: [], submitButtonStyles: [], + shouldHideFixErrorsAlert: false, }; function FormWrapper(props) { @@ -97,6 +100,7 @@ function FormWrapper(props) { enabledWhenOffline, isSubmitActionDangerous, formID, + shouldHideFixErrorsAlert, } = props; const formRef = useRef(null); const formContentRef = useRef(null); @@ -117,7 +121,7 @@ function FormWrapper(props) { {isSubmitButtonVisible && ( 0 || Boolean(errorMessage) || !_.isEmpty(formState.errorFields)} + isAlertVisible={((_.size(errors) > 0 || !_.isEmpty(formState.errorFields)) && !shouldHideFixErrorsAlert) || Boolean(errorMessage)} isLoading={formState.isLoading} message={_.isEmpty(formState.errorFields) ? errorMessage : null} onSubmit={onSubmit} @@ -153,6 +157,7 @@ function FormWrapper(props) { enabledWhenOffline={enabledWhenOffline} isSubmitActionDangerous={isSubmitActionDangerous} disablePressOnEnter + shouldHideFixErrorsAlert={shouldHideFixErrorsAlert} /> )} @@ -176,6 +181,7 @@ function FormWrapper(props) { styles.mt5, submitButtonStyles, submitButtonText, + shouldHideFixErrorsAlert, ], ); diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 9ffb0b5ef2f3..55cc9e708771 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -7,7 +7,7 @@ import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; -type ThreeDotsMenuItems = { +type ThreeDotsMenuItem = { /** An icon element displayed on the left side */ icon?: IconAsset; @@ -62,7 +62,7 @@ type HeaderWithBackButtonProps = Partial & { shouldDisableThreeDotsButton?: boolean; /** List of menu items for more(three dots) menu */ - threeDotsMenuItems?: ThreeDotsMenuItems[]; + threeDotsMenuItems?: ThreeDotsMenuItem[]; /** The anchor position of the menu */ threeDotsAnchorPosition?: AnchorPosition; @@ -110,4 +110,5 @@ type HeaderWithBackButtonProps = Partial & { shouldEnableDetailPageNavigation?: boolean; }; +export type {ThreeDotsMenuItem}; export default HeaderWithBackButtonProps; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 2ddee8b2939b..797e6f34fc75 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -27,6 +27,8 @@ import Camera from '@assets/images/camera.svg'; import Car from '@assets/images/car.svg'; import Cash from '@assets/images/cash.svg'; import Chair from '@assets/images/chair.svg'; +import ChatBubbleAdd from '@assets/images/chatbubble-add.svg'; +import ChatBubbleUnread from '@assets/images/chatbubble-unread.svg'; import ChatBubble from '@assets/images/chatbubble.svg'; import ChatBubbles from '@assets/images/chatbubbles.svg'; import Checkmark from '@assets/images/checkmark.svg'; @@ -264,4 +266,6 @@ export { Podcast, Linkedin, Instagram, + ChatBubbleAdd, + ChatBubbleUnread, }; diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js deleted file mode 100644 index 5ad25d23f484..000000000000 --- a/src/components/KYCWall/BaseKYCWall.js +++ /dev/null @@ -1,247 +0,0 @@ -import lodashGet from 'lodash/get'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {Dimensions} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; -import * as BankAccounts from '@libs/actions/BankAccounts'; -import getClickedTargetLocation from '@libs/getClickedTargetLocation'; -import Log from '@libs/Log'; -import Navigation from '@libs/Navigation/Navigation'; -import * as PaymentUtils from '@libs/PaymentUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as PaymentMethods from '@userActions/PaymentMethods'; -import * as Policy from '@userActions/Policy'; -import * as Wallet from '@userActions/Wallet'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import {defaultProps, propTypes} from './kycWallPropTypes'; - -// This sets the Horizontal anchor position offset for POPOVER MENU. -const POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET = 20; - -// This component allows us to block various actions by forcing the user to first add a default payment method and successfully make it through our Know Your Customer flow -// before continuing to take whatever action they originally intended to take. It requires a button as a child and a native event so we can get the coordinates and use it -// to render the AddPaymentMethodMenu in the correct location. -function KYCWall({ - addBankAccountRoute, - addDebitCardRoute, - anchorAlignment, - bankAccountList, - chatReportID, - children, - enablePaymentsRoute, - fundList, - iouReport, - onSelectPaymentMethod, - onSuccessfulKYC, - reimbursementAccount, - shouldIncludeDebitCard, - shouldListenForResize, - source, - userWallet, - walletTerms, - shouldShowPersonalBankAccountOption, -}) { - const anchorRef = useRef(null); - const transferBalanceButtonRef = useRef(null); - - const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false); - const [anchorPosition, setAnchorPosition] = useState({ - anchorPositionVertical: 0, - anchorPositionHorizontal: 0, - }); - - /** - * @param {DOMRect} domRect - * @returns {Object} - */ - const getAnchorPosition = useCallback( - (domRect) => { - if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { - return { - anchorPositionVertical: domRect.top + domRect.height + CONST.MODAL.POPOVER_MENU_PADDING, - anchorPositionHorizontal: domRect.left + POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET, - }; - } - - return { - anchorPositionVertical: domRect.top - CONST.MODAL.POPOVER_MENU_PADDING, - anchorPositionHorizontal: domRect.left, - }; - }, - [anchorAlignment.vertical], - ); - - /** - * Set position of the transfer payment menu - * - * @param {Object} position - */ - const setPositionAddPaymentMenu = ({anchorPositionVertical, anchorPositionHorizontal}) => { - setAnchorPosition({ - anchorPositionVertical, - anchorPositionHorizontal, - }); - }; - - const setMenuPosition = useCallback(() => { - if (!transferBalanceButtonRef.current) { - return; - } - const buttonPosition = getClickedTargetLocation(transferBalanceButtonRef.current); - const position = getAnchorPosition(buttonPosition); - - setPositionAddPaymentMenu(position); - }, [getAnchorPosition]); - - useEffect(() => { - let dimensionsSubscription = null; - - PaymentMethods.kycWallRef.current = this; - - if (shouldListenForResize) { - dimensionsSubscription = Dimensions.addEventListener('change', setMenuPosition); - } - - return () => { - if (shouldListenForResize && dimensionsSubscription) { - dimensionsSubscription.remove(); - } - - PaymentMethods.kycWallRef.current = null; - }; - }, [chatReportID, setMenuPosition, shouldListenForResize]); - - /** - * @param {String} paymentMethod - */ - const selectPaymentMethod = (paymentMethod) => { - onSelectPaymentMethod(paymentMethod); - if (paymentMethod === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) { - BankAccounts.openPersonalBankAccountSetupView(); - } else if (paymentMethod === CONST.PAYMENT_METHODS.DEBIT_CARD) { - Navigation.navigate(addDebitCardRoute); - } else if (paymentMethod === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT) { - if (ReportUtils.isIOUReport(iouReport)) { - const policyID = Policy.createWorkspaceFromIOUPayment(iouReport); - - // Navigate to the bank account set up flow for this specific policy - Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('', policyID)); - return; - } - Navigation.navigate(addBankAccountRoute); - } - }; - - /** - * Take the position of the button that calls this method and show the Add Payment method menu when the user has no valid payment method. - * If they do have a valid payment method they are navigated to the "enable payments" route to complete KYC checks. - * If they are already KYC'd we will continue whatever action is gated behind the KYC wall. - * - * @param {Event} event - * @param {String} iouPaymentType - */ - const continueAction = (event, iouPaymentType) => { - const currentSource = lodashGet(walletTerms, 'source', source); - - /** - * Set the source, so we can tailor the process according to how we got here. - * We do not want to set this on mount, as the source can change upon completing the flow, e.g. when upgrading the wallet to Gold. - */ - Wallet.setKYCWallSource(source, chatReportID); - - if (shouldShowAddPaymentMenu) { - setShouldShowAddPaymentMenu(false); - - return; - } - - // Use event target as fallback if anchorRef is null for safety - const targetElement = anchorRef.current || event.nativeEvent.target; - - transferBalanceButtonRef.current = targetElement; - const isExpenseReport = ReportUtils.isExpenseReport(iouReport); - const paymentCardList = fundList || {}; - - // Check to see if user has a valid payment method on file and display the add payment popover if they don't - if ( - (isExpenseReport && lodashGet(reimbursementAccount, 'achData.state', '') !== CONST.BANK_ACCOUNT.STATE.OPEN) || - (!isExpenseReport && !PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, bankAccountList, shouldIncludeDebitCard)) - ) { - Log.info('[KYC Wallet] User does not have valid payment method'); - if (!shouldIncludeDebitCard) { - selectPaymentMethod(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); - return; - } - - const clickedElementLocation = getClickedTargetLocation(targetElement); - const position = getAnchorPosition(clickedElementLocation); - - setPositionAddPaymentMenu(position); - setShouldShowAddPaymentMenu(true); - - return; - } - - if (!isExpenseReport) { - // Ask the user to upgrade to a gold wallet as this means they have not yet gone through our Know Your Customer (KYC) checks - const hasActivatedWallet = userWallet.tierName && _.contains([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM], userWallet.tierName); - if (!hasActivatedWallet) { - Log.info('[KYC Wallet] User does not have active wallet'); - Navigation.navigate(enablePaymentsRoute); - return; - } - } - Log.info('[KYC Wallet] User has valid payment method and passed KYC checks or did not need them'); - onSuccessfulKYC(iouPaymentType, currentSource); - }; - - return ( - <> - setShouldShowAddPaymentMenu(false)} - anchorRef={anchorRef} - anchorPosition={{ - vertical: anchorPosition.anchorPositionVertical, - horizontal: anchorPosition.anchorPositionHorizontal, - }} - anchorAlignment={anchorAlignment} - onItemSelected={(item) => { - setShouldShowAddPaymentMenu(false); - selectPaymentMethod(item); - }} - shouldShowPersonalBankAccountOption={shouldShowPersonalBankAccountOption} - /> - {children(continueAction, anchorRef)} - - ); -} - -KYCWall.propTypes = propTypes; -KYCWall.defaultProps = defaultProps; -KYCWall.displayName = 'BaseKYCWall'; - -export default withOnyx({ - userWallet: { - key: ONYXKEYS.USER_WALLET, - }, - walletTerms: { - key: ONYXKEYS.WALLET_TERMS, - }, - fundList: { - key: ONYXKEYS.FUND_LIST, - }, - bankAccountList: { - key: ONYXKEYS.BANK_ACCOUNT_LIST, - }, - reimbursementAccount: { - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - }, - chatReport: { - key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, - }, -})(KYCWall); diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx new file mode 100644 index 000000000000..04c8397bc33b --- /dev/null +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -0,0 +1,286 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import type {SyntheticEvent} from 'react'; +import {Dimensions} from 'react-native'; +import type {EmitterSubscription, NativeTouchEvent} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; +import * as BankAccounts from '@libs/actions/BankAccounts'; +import getClickedTargetLocation from '@libs/getClickedTargetLocation'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PaymentUtils from '@libs/PaymentUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as PaymentMethods from '@userActions/PaymentMethods'; +import * as Policy from '@userActions/Policy'; +import * as Wallet from '@userActions/Wallet'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {BankAccountList, FundList, ReimbursementAccount, UserWallet, WalletTerms} from '@src/types/onyx'; +import type {AnchorPosition, DomRect, KYCWallProps, PaymentMethod, TransferMethod} from './types'; + +// This sets the Horizontal anchor position offset for POPOVER MENU. +const POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET = 20; + +type BaseKYCWallOnyxProps = { + /** The user's wallet */ + userWallet: OnyxEntry; + + /** Information related to the last step of the wallet activation flow */ + walletTerms: OnyxEntry; + + /** List of user's cards */ + fundList: OnyxEntry; + + /** List of bank accounts */ + bankAccountList: OnyxEntry; + + /** The reimbursement account linked to the Workspace */ + reimbursementAccount: OnyxEntry; +}; + +type BaseKYCWallProps = KYCWallProps & BaseKYCWallOnyxProps; + +// This component allows us to block various actions by forcing the user to first add a default payment method and successfully make it through our Know Your Customer flow +// before continuing to take whatever action they originally intended to take. It requires a button as a child and a native event so we can get the coordinates and use it +// to render the AddPaymentMethodMenu in the correct location. +function KYCWall({ + addBankAccountRoute, + addDebitCardRoute, + anchorAlignment = { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }, + bankAccountList = {}, + chatReportID = '', + children, + enablePaymentsRoute, + fundList, + iouReport, + onSelectPaymentMethod = () => {}, + onSuccessfulKYC, + reimbursementAccount, + shouldIncludeDebitCard = true, + shouldListenForResize = false, + source, + userWallet, + walletTerms, + shouldShowPersonalBankAccountOption = false, +}: BaseKYCWallProps) { + const anchorRef = useRef(null); + const transferBalanceButtonRef = useRef(null); + + const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false); + + const [anchorPosition, setAnchorPosition] = useState({ + anchorPositionVertical: 0, + anchorPositionHorizontal: 0, + }); + + const getAnchorPosition = useCallback( + (domRect: DomRect): AnchorPosition => { + if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { + return { + anchorPositionVertical: domRect.top + domRect.height + CONST.MODAL.POPOVER_MENU_PADDING, + anchorPositionHorizontal: domRect.left + POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET, + }; + } + + return { + anchorPositionVertical: domRect.top - CONST.MODAL.POPOVER_MENU_PADDING, + anchorPositionHorizontal: domRect.left, + }; + }, + [anchorAlignment.vertical], + ); + + /** + * Set position of the transfer payment menu + */ + const setPositionAddPaymentMenu = ({anchorPositionVertical, anchorPositionHorizontal}: AnchorPosition) => { + setAnchorPosition({ + anchorPositionVertical, + anchorPositionHorizontal, + }); + }; + + const setMenuPosition = useCallback(() => { + if (!transferBalanceButtonRef.current) { + return; + } + + const buttonPosition = getClickedTargetLocation(transferBalanceButtonRef.current); + const position = getAnchorPosition(buttonPosition); + + setPositionAddPaymentMenu(position); + }, [getAnchorPosition]); + + const selectPaymentMethod = useCallback( + (paymentMethod: PaymentMethod) => { + onSelectPaymentMethod(paymentMethod); + + if (paymentMethod === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) { + BankAccounts.openPersonalBankAccountSetupView(); + } else if (paymentMethod === CONST.PAYMENT_METHODS.DEBIT_CARD) { + Navigation.navigate(addDebitCardRoute); + } else if (paymentMethod === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT) { + if (iouReport && ReportUtils.isIOUReport(iouReport)) { + const policyID = Policy.createWorkspaceFromIOUPayment(iouReport); + + // Navigate to the bank account set up flow for this specific policy + Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('', policyID)); + + return; + } + Navigation.navigate(addBankAccountRoute); + } + }, + [addBankAccountRoute, addDebitCardRoute, iouReport, onSelectPaymentMethod], + ); + + /** + * Take the position of the button that calls this method and show the Add Payment method menu when the user has no valid payment method. + * If they do have a valid payment method they are navigated to the "enable payments" route to complete KYC checks. + * If they are already KYC'd we will continue whatever action is gated behind the KYC wall. + * + */ + const continueAction = useCallback( + (event?: SyntheticEvent, iouPaymentType?: TransferMethod) => { + const currentSource = walletTerms?.source ?? source; + + /** + * Set the source, so we can tailor the process according to how we got here. + * We do not want to set this on mount, as the source can change upon completing the flow, e.g. when upgrading the wallet to Gold. + */ + Wallet.setKYCWallSource(source, chatReportID); + + if (shouldShowAddPaymentMenu) { + setShouldShowAddPaymentMenu(false); + return; + } + + // Use event target as fallback if anchorRef is null for safety + const targetElement = anchorRef.current ?? (event?.nativeEvent.target as HTMLDivElement); + + transferBalanceButtonRef.current = targetElement; + + const isExpenseReport = ReportUtils.isExpenseReport(iouReport ?? null); + const paymentCardList = fundList ?? {}; + + // Check to see if user has a valid payment method on file and display the add payment popover if they don't + if ( + (isExpenseReport && reimbursementAccount?.achData?.state !== CONST.BANK_ACCOUNT.STATE.OPEN) || + (!isExpenseReport && bankAccountList !== null && !PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, bankAccountList, shouldIncludeDebitCard)) + ) { + Log.info('[KYC Wallet] User does not have valid payment method'); + + if (!shouldIncludeDebitCard) { + selectPaymentMethod(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); + return; + } + + const clickedElementLocation = getClickedTargetLocation(targetElement); + const position = getAnchorPosition(clickedElementLocation); + + setPositionAddPaymentMenu(position); + setShouldShowAddPaymentMenu(true); + + return; + } + if (!isExpenseReport) { + // Ask the user to upgrade to a gold wallet as this means they have not yet gone through our Know Your Customer (KYC) checks + const hasActivatedWallet = userWallet?.tierName && [CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM].some((name) => name === userWallet.tierName); + + if (!hasActivatedWallet) { + Log.info('[KYC Wallet] User does not have active wallet'); + + Navigation.navigate(enablePaymentsRoute); + + return; + } + } + + Log.info('[KYC Wallet] User has valid payment method and passed KYC checks or did not need them'); + + onSuccessfulKYC(currentSource, iouPaymentType); + }, + [ + bankAccountList, + chatReportID, + enablePaymentsRoute, + fundList, + getAnchorPosition, + iouReport, + onSuccessfulKYC, + reimbursementAccount?.achData?.state, + selectPaymentMethod, + shouldIncludeDebitCard, + shouldShowAddPaymentMenu, + source, + userWallet?.tierName, + walletTerms?.source, + ], + ); + + useEffect(() => { + let dimensionsSubscription: EmitterSubscription | null = null; + + PaymentMethods.kycWallRef.current = {continueAction}; + + if (shouldListenForResize) { + dimensionsSubscription = Dimensions.addEventListener('change', setMenuPosition); + } + + return () => { + if (shouldListenForResize && dimensionsSubscription) { + dimensionsSubscription.remove(); + } + + PaymentMethods.kycWallRef.current = null; + }; + }, [chatReportID, setMenuPosition, shouldListenForResize, continueAction]); + + return ( + <> + setShouldShowAddPaymentMenu(false)} + anchorRef={anchorRef} + anchorPosition={{ + vertical: anchorPosition.anchorPositionVertical, + horizontal: anchorPosition.anchorPositionHorizontal, + }} + anchorAlignment={anchorAlignment} + onItemSelected={(item: PaymentMethod) => { + setShouldShowAddPaymentMenu(false); + selectPaymentMethod(item); + }} + shouldShowPersonalBankAccountOption={shouldShowPersonalBankAccountOption} + /> + {children(continueAction, anchorRef)} + + ); +} + +KYCWall.displayName = 'BaseKYCWall'; + +export default withOnyx({ + userWallet: { + key: ONYXKEYS.USER_WALLET, + }, + walletTerms: { + key: ONYXKEYS.WALLET_TERMS, + }, + fundList: { + key: ONYXKEYS.FUND_LIST, + }, + bankAccountList: { + key: ONYXKEYS.BANK_ACCOUNT_LIST, + }, + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, +})(KYCWall); diff --git a/src/components/KYCWall/index.native.js b/src/components/KYCWall/index.native.ts similarity index 100% rename from src/components/KYCWall/index.native.js rename to src/components/KYCWall/index.native.ts diff --git a/src/components/KYCWall/index.js b/src/components/KYCWall/index.tsx similarity index 66% rename from src/components/KYCWall/index.js rename to src/components/KYCWall/index.tsx index 49329c73d474..e99efee67210 100644 --- a/src/components/KYCWall/index.js +++ b/src/components/KYCWall/index.tsx @@ -1,8 +1,8 @@ import React from 'react'; import BaseKYCWall from './BaseKYCWall'; -import {defaultProps, propTypes} from './kycWallPropTypes'; +import type {KYCWallProps} from './types'; -function KYCWall(props) { +function KYCWall(props: KYCWallProps) { return ( {}, - shouldShowPersonalBankAccountOption: false, -}; - -export {propTypes, defaultProps}; diff --git a/src/components/KYCWall/types.ts b/src/components/KYCWall/types.ts new file mode 100644 index 000000000000..aee5b569cc46 --- /dev/null +++ b/src/components/KYCWall/types.ts @@ -0,0 +1,73 @@ +import type {ForwardedRef, SyntheticEvent} from 'react'; +import type {NativeTouchEvent} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; +import type {Route} from '@src/ROUTES'; +import type {Report} from '@src/types/onyx'; + +type Source = ValueOf; + +type TransferMethod = ValueOf; + +type DOMRectProperties = 'top' | 'bottom' | 'left' | 'right' | 'height' | 'x' | 'y'; + +type DomRect = Pick; + +type AnchorAlignment = { + horizontal: ValueOf; + vertical: ValueOf; +}; + +type AnchorPosition = { + anchorPositionVertical: number; + anchorPositionHorizontal: number; +}; + +type PaymentMethod = ValueOf; + +type KYCWallProps = { + /** Route for the Add Bank Account screen for a given navigation stack */ + addBankAccountRoute: Route; + + /** Route for the Add Debit Card screen for a given navigation stack */ + addDebitCardRoute?: Route; + + /** Route for the KYC enable payments screen for a given navigation stack */ + enablePaymentsRoute: Route; + + /** Listen for window resize event on web and desktop */ + shouldListenForResize?: boolean; + + /** Wrapped components should be disabled, and not in spinner/loading state */ + isDisabled?: boolean; + + /** The source that triggered the KYC wall */ + source?: Source; + + /** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */ + chatReportID?: string; + + /** The IOU/Expense report we are paying */ + iouReport?: OnyxEntry; + + /** Where the popover should be positioned relative to the anchor points. */ + anchorAlignment?: AnchorAlignment; + + /** Whether the option to add a debit card should be included */ + shouldIncludeDebitCard?: boolean; + + /** Callback for when a payment method has been selected */ + onSelectPaymentMethod?: (paymentMethod: PaymentMethod) => void; + + /** Whether the personal bank account option should be shown */ + shouldShowPersonalBankAccountOption?: boolean; + + /** Callback for the end of the onContinue trigger on option selection */ + onSuccessfulKYC: (currentSource?: Source, iouPaymentType?: TransferMethod) => void; + + /** Children to build the KYC */ + children: (continueAction: (event: SyntheticEvent, method: TransferMethod) => void, anchorRef: ForwardedRef) => void; +}; + +export type {AnchorPosition, KYCWallProps, PaymentMethod, TransferMethod, DomRect}; diff --git a/src/components/LocationErrorMessage/types.ts b/src/components/LocationErrorMessage/types.ts index 41b71dbc3c69..27aa89d07ede 100644 --- a/src/components/LocationErrorMessage/types.ts +++ b/src/components/LocationErrorMessage/types.ts @@ -1,3 +1,5 @@ +import type {GeolocationErrorCodeType} from '@libs/getCurrentPosition/getCurrentPosition.types'; + type LocationErrorMessageProps = { /** A callback that runs when close icon is pressed */ onClose: () => void; @@ -9,7 +11,7 @@ type LocationErrorMessageProps = { * - code 2 = location is unavailable or there is some connection issue * - code 3 = location fetch timeout */ - locationErrorCode?: -1 | 1 | 2 | 3; + locationErrorCode?: GeolocationErrorCodeType | null; }; export default LocationErrorMessageProps; diff --git a/src/components/MenuItemList.tsx b/src/components/MenuItemList.tsx index f83f173a644f..4ba9260e23ff 100644 --- a/src/components/MenuItemList.tsx +++ b/src/components/MenuItemList.tsx @@ -10,7 +10,7 @@ type MenuItemLink = string | (() => Promise); type MenuItemWithLink = MenuItemProps & { /** The link to open when the menu item is clicked */ - link: MenuItemLink; + link?: MenuItemLink; }; type MenuItemListProps = { @@ -31,7 +31,7 @@ function MenuItemList({menuItems = [], shouldUseSingleExecution = false}: MenuIt * @param link the menu item link or function to get the link * @param event the interaction event */ - const secondaryInteraction = (link: MenuItemLink, event: GestureResponderEvent | MouseEvent) => { + const secondaryInteraction = (link: MenuItemLink | undefined, event: GestureResponderEvent | MouseEvent) => { if (typeof link === 'function') { link().then((url) => ReportActionContextMenu.showContextMenu(CONST.CONTEXT_MENU_TYPES.LINK, event, url, popoverAnchor.current)); } else if (link) { diff --git a/src/components/Modal/index.android.tsx b/src/components/Modal/index.android.tsx index 4d7ae128a114..86a1fd272185 100644 --- a/src/components/Modal/index.android.tsx +++ b/src/components/Modal/index.android.tsx @@ -1,6 +1,5 @@ import React from 'react'; import {AppState} from 'react-native'; -import withWindowDimensions from '@components/withWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import BaseModal from './BaseModal'; import type BaseModalProps from './types'; @@ -28,4 +27,4 @@ function Modal({useNativeDriver = true, ...rest}: BaseModalProps) { } Modal.displayName = 'Modal'; -export default withWindowDimensions(Modal); +export default Modal; diff --git a/src/components/Modal/index.ios.tsx b/src/components/Modal/index.ios.tsx index cbe58a071d7d..b26ba6cd0f89 100644 --- a/src/components/Modal/index.ios.tsx +++ b/src/components/Modal/index.ios.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import withWindowDimensions from '@components/withWindowDimensions'; import BaseModal from './BaseModal'; import type BaseModalProps from './types'; @@ -15,4 +14,4 @@ function Modal({children, ...rest}: BaseModalProps) { } Modal.displayName = 'Modal'; -export default withWindowDimensions(Modal); +export default Modal; diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 56f3c76a8879..71c0fe47ffca 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -1,5 +1,4 @@ import React, {useState} from 'react'; -import withWindowDimensions from '@components/withWindowDimensions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import StatusBar from '@libs/StatusBar'; @@ -55,4 +54,4 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = ( } Modal.displayName = 'Modal'; -export default withWindowDimensions(Modal); +export default Modal; diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 0fed37ffea8b..0773f0741233 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -1,7 +1,6 @@ import type {ViewStyle} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import type {ValueOf} from 'type-fest'; -import type {WindowDimensionsProps} from '@components/withWindowDimensions/types'; import type CONST from '@src/CONST'; type PopoverAnchorPosition = { @@ -11,57 +10,56 @@ type PopoverAnchorPosition = { left?: number; }; -type BaseModalProps = WindowDimensionsProps & - Partial & { - /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */ - fullscreen?: boolean; +type BaseModalProps = Partial & { + /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */ + fullscreen?: boolean; - /** Should we close modal on outside click */ - shouldCloseOnOutsideClick?: boolean; + /** Should we close modal on outside click */ + shouldCloseOnOutsideClick?: boolean; - /** Should we announce the Modal visibility changes? */ - shouldSetModalVisibility?: boolean; + /** Should we announce the Modal visibility changes? */ + shouldSetModalVisibility?: boolean; - /** Callback method fired when the user requests to close the modal */ - onClose: (ref?: React.RefObject) => void; + /** Callback method fired when the user requests to close the modal */ + onClose: () => void; - /** State that determines whether to display the modal or not */ - isVisible: boolean; + /** State that determines whether to display the modal or not */ + isVisible: boolean; - /** Callback method fired when the user requests to submit the modal content. */ - onSubmit?: () => void; + /** Callback method fired when the user requests to submit the modal content. */ + onSubmit?: () => void; - /** Callback method fired when the modal is hidden */ - onModalHide?: () => void; + /** Callback method fired when the modal is hidden */ + onModalHide?: () => void; - /** Callback method fired when the modal is shown */ - onModalShow?: () => void; + /** Callback method fired when the modal is shown */ + onModalShow?: () => void; - /** Style of modal to display */ - type?: ValueOf; + /** Style of modal to display */ + type?: ValueOf; - /** The anchor position of a popover modal. Has no effect on other modal types. */ - popoverAnchorPosition?: PopoverAnchorPosition; + /** The anchor position of a popover modal. Has no effect on other modal types. */ + popoverAnchorPosition?: PopoverAnchorPosition; - outerStyle?: ViewStyle; + outerStyle?: ViewStyle; - /** Whether the modal should go under the system statusbar */ - statusBarTranslucent?: boolean; + /** Whether the modal should go under the system statusbar */ + statusBarTranslucent?: boolean; - /** Whether the modal should avoid the keyboard */ - avoidKeyboard?: boolean; + /** Whether the modal should avoid the keyboard */ + avoidKeyboard?: boolean; - /** Modal container styles */ - innerContainerStyle?: ViewStyle; + /** Modal container styles */ + innerContainerStyle?: ViewStyle; - /** - * Whether the modal should hide its content while animating. On iOS, set to true - * if `useNativeDriver` is also true, to avoid flashes in the UI. - * - * See: https://github.com/react-native-modal/react-native-modal/pull/116 - * */ - hideModalContentWhileAnimating?: boolean; - }; + /** + * Whether the modal should hide its content while animating. On iOS, set to true + * if `useNativeDriver` is also true, to avoid flashes in the UI. + * + * See: https://github.com/react-native-modal/react-native-modal/pull/116 + * */ + hideModalContentWhileAnimating?: boolean; +}; export default BaseModalProps; export type {PopoverAnchorPosition}; diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.tsx similarity index 69% rename from src/components/MoneyReportHeader.js rename to src/components/MoneyReportHeader.tsx index ce1c9611c733..afdc62218f95 100644 --- a/src/components/MoneyReportHeader.js +++ b/src/components/MoneyReportHeader.tsx @@ -1,108 +1,69 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import GoogleMeetIcon from '@assets/images/google-meet.svg'; import ZoomIcon from '@assets/images/zoom-icon.svg'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; -import iouReportPropTypes from '@pages/iouReportPropTypes'; -import nextStepPropTypes from '@pages/nextStepPropTypes'; -import reportPropTypes from '@pages/reportPropTypes'; import * as IOU from '@userActions/IOU'; import * as Link from '@userActions/Link'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; import Button from './Button'; -import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; -import * as Expensicons from './Icon/Expensicons'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; -import participantPropTypes from './participantPropTypes'; import SettlementButton from './SettlementButton'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; -const propTypes = { - /** The report currently being looked at */ - report: iouReportPropTypes.isRequired, - - /** The policy tied to the money request report */ - policy: PropTypes.shape({ - /** Name of the policy */ - name: PropTypes.string, - - /** Type of the policy */ - type: PropTypes.string, - - /** The role of the current user in the policy */ - role: PropTypes.string, - - /** Whether Scheduled Submit is turned on for this policy */ - isHarvestingEnabled: PropTypes.bool, - }), +type PaymentType = DeepValueOf; +type MoneyReportHeaderOnyxProps = { /** The chat report this report is linked to */ - chatReport: reportPropTypes, + chatReport: OnyxEntry; /** The next step for the report */ - nextStep: nextStepPropTypes, - - /** Personal details so we can get the ones for the report participants */ - personalDetails: PropTypes.objectOf(participantPropTypes).isRequired, + nextStep: OnyxEntry; /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user email */ - email: PropTypes.string, - }), - - ...windowDimensionsPropTypes, + session: OnyxEntry; }; -const defaultProps = { - chatReport: {}, - nextStep: {}, - session: { - email: null, - }, - policy: { - isHarvestingEnabled: false, - }, +type MoneyReportHeaderProps = MoneyReportHeaderOnyxProps & { + /** The report currently being looked at */ + report: OnyxTypes.Report; + + /** The policy tied to the money request report */ + policy: OnyxTypes.Policy; + + /** Personal details so we can get the ones for the report participants */ + personalDetails: OnyxTypes.PersonalDetailsList; }; -function MoneyReportHeader({session, personalDetails, policy, chatReport, nextStep, report: moneyRequestReport, isSmallScreenWidth}) { +function MoneyReportHeader({session, personalDetails, policy, chatReport, nextStep, report: moneyRequestReport}: MoneyReportHeaderProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {windowWidth} = useWindowDimensions(); + const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); const reimbursableTotal = ReportUtils.getMoneyRequestReimbursableTotal(moneyRequestReport); const isApproved = ReportUtils.isReportApproved(moneyRequestReport); const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); - const policyType = lodashGet(policy, 'type'); - const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && lodashGet(policy, 'role') === CONST.POLICY.ROLE.ADMIN; + const policyType = policy?.type; + const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && policy?.role === CONST.POLICY.ROLE.ADMIN; const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicy(moneyRequestReport); - const isManager = ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(session, 'accountID', null) === moneyRequestReport.managerID; + const isManager = ReportUtils.isMoneyRequestReport(moneyRequestReport) && session?.accountID === moneyRequestReport.managerID; const isPayer = isPaidGroupPolicy ? // In a group policy, the admin approver can pay the report directly by skipping the approval step isPolicyAdmin && (isApproved || isManager) : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager); const isDraft = ReportUtils.isDraftExpenseReport(moneyRequestReport); - const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); - - const cancelPayment = useCallback(() => { - IOU.cancelPayment(moneyRequestReport, chatReport); - setIsConfirmModalVisible(false); - }, [moneyRequestReport, chatReport]); - const shouldShowPayButton = useMemo( () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport), [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableTotal, chatReport], @@ -116,7 +77,7 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; const shouldShowSubmitButton = isDraft && reimbursableTotal !== 0; const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; - const shouldShowNextStep = isFromPaidPolicy && nextStep && !_.isEmpty(nextStep.message); + const shouldShowNextStep = isFromPaidPolicy && !!nextStep?.message?.length; const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep; const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableTotal, moneyRequestReport.currency); @@ -124,18 +85,11 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt // The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on const isWaitingForSubmissionFromCurrentUser = useMemo( - () => chatReport.isOwnPolicyExpenseChat && !policy.isHarvestingEnabled, - [chatReport.isOwnPolicyExpenseChat, policy.isHarvestingEnabled], + () => chatReport?.isOwnPolicyExpenseChat && !policy.isHarvestingEnabled, + [chatReport?.isOwnPolicyExpenseChat, policy.isHarvestingEnabled], ); const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(moneyRequestReport)]; - if (isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport)) { - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('iou.cancelPayment'), - onSelected: () => setIsConfirmModalVisible(true), - }); - } if (!ReportUtils.isArchivedRoom(chatReport)) { threeDotsMenuItems.push({ icon: ZoomIcon, @@ -173,11 +127,13 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt {shouldShowSettlementButton && !isSmallScreenWidth && ( IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)} + // @ts-expect-error TODO: Remove this once IOU (https://github.com/Expensify/App/issues/24926) is migrated to TypeScript. + onPress={(paymentType: PaymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)} enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} shouldHidePaymentOptions={!shouldShowPayButton} @@ -203,11 +159,13 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt {shouldShowSettlementButton && isSmallScreenWidth && ( IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)} + // @ts-expect-error TODO: Remove this once IOU (https://github.com/Expensify/App/issues/24926) is migrated to TypeScript. + onPress={(paymentType: PaymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)} enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} shouldHidePaymentOptions={!shouldShowPayButton} @@ -233,35 +191,20 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt )} - setIsConfirmModalVisible(false)} - prompt={translate('iou.cancelPaymentConfirmation')} - confirmText={translate('iou.cancelPayment')} - cancelText={translate('common.dismiss')} - danger - /> ); } MoneyReportHeader.displayName = 'MoneyReportHeader'; -MoneyReportHeader.propTypes = propTypes; -MoneyReportHeader.defaultProps = defaultProps; -export default compose( - withWindowDimensions, - withOnyx({ - chatReport: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`, - }, - nextStep: { - key: ({report}) => `${ONYXKEYS.COLLECTION.NEXT_STEP}${report.reportID}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, - }), -)(MoneyReportHeader); +export default withOnyx({ + chatReport: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`, + }, + nextStep: { + key: ({report}) => `${ONYXKEYS.COLLECTION.NEXT_STEP}${report.reportID}`, + }, + session: { + key: ONYXKEYS.SESSION, + }, +})(MoneyReportHeader); diff --git a/src/components/MoneyReportHeaderStatusBar.tsx b/src/components/MoneyReportHeaderStatusBar.tsx index 4b30276a204f..7d2b749cce0a 100644 --- a/src/components/MoneyReportHeaderStatusBar.tsx +++ b/src/components/MoneyReportHeaderStatusBar.tsx @@ -1,11 +1,12 @@ import React, {useMemo} from 'react'; -import {Text, View} from 'react-native'; +import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as NextStepUtils from '@libs/NextStepUtils'; import CONST from '@src/CONST'; import type ReportNextStep from '@src/types/onyx/ReportNextStep'; import RenderHTML from './RenderHTML'; +import Text from './Text'; type MoneyReportHeaderStatusBarProps = { /** The next step for the report */ diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index 3d1f95822e6a..87a09895d50f 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -1,8 +1,11 @@ +import type {RefObject} from 'react'; +import type {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {PopoverAnchorPosition} from '@components/Modal/types'; import type BaseModalProps from '@components/Modal/types'; import type {WindowDimensionsProps} from '@components/withWindowDimensions/types'; import type CONST from '@src/CONST'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; type AnchorAlignment = { /** The horizontal anchor alignment of the popover */ @@ -17,34 +20,32 @@ type PopoverDimensions = { height: number; }; -type PopoverProps = BaseModalProps & { - /** The anchor position of the popover */ - anchorPosition?: PopoverAnchorPosition; +type PopoverProps = BaseModalProps & + ChildrenProps & { + /** The anchor position of the popover */ + anchorPosition?: PopoverAnchorPosition; - /** The anchor alignment of the popover */ - anchorAlignment: AnchorAlignment; + /** The anchor alignment of the popover */ + anchorAlignment?: AnchorAlignment; - /** The anchor ref of the popover */ - anchorRef: React.RefObject; + /** The anchor ref of the popover */ + anchorRef: RefObject; - /** Whether disable the animations */ - disableAnimation: boolean; + /** Whether disable the animations */ + disableAnimation?: boolean; - /** Whether we don't want to show overlay */ - withoutOverlay: boolean; + /** Whether we don't want to show overlay */ + withoutOverlay: boolean; - /** The dimensions of the popover */ - popoverDimensions?: PopoverDimensions; + /** The dimensions of the popover */ + popoverDimensions?: PopoverDimensions; - /** The ref of the popover */ - withoutOverlayRef?: React.RefObject; + /** The ref of the popover */ + withoutOverlayRef?: RefObject; - /** Whether we want to show the popover on the right side of the screen */ - fromSidebarMediumScreen?: boolean; - - /** The popover children */ - children: React.ReactNode; -}; + /** Whether we want to show the popover on the right side of the screen */ + fromSidebarMediumScreen?: boolean; + }; type PopoverWithWindowDimensionsProps = PopoverProps & WindowDimensionsProps; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index b50b04289813..b1a6ebb0c5c0 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -1,18 +1,27 @@ -import React from 'react'; +import type {RefObject} from 'react'; +import React, {createContext, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {View} from 'react-native'; import type {AnchorRef, PopoverContextProps, PopoverContextValue} from './types'; -const PopoverContext = React.createContext({ +const PopoverContext = createContext({ onOpen: () => {}, popover: {}, close: () => {}, isOpen: false, }); +function elementContains(ref: RefObject | undefined, target: EventTarget | null) { + if (ref?.current && 'contains' in ref?.current && ref?.current?.contains(target as Node)) { + return true; + } + return false; +} + function PopoverContextProvider(props: PopoverContextProps) { - const [isOpen, setIsOpen] = React.useState(false); - const activePopoverRef = React.useRef(null); + const [isOpen, setIsOpen] = useState(false); + const activePopoverRef = useRef(null); - const closePopover = React.useCallback((anchorRef?: React.RefObject) => { + const closePopover = useCallback((anchorRef?: RefObject) => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { return; } @@ -25,10 +34,9 @@ function PopoverContextProvider(props: PopoverContextProps) { setIsOpen(false); }, []); - React.useEffect(() => { + useEffect(() => { const listener = (e: Event) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (activePopoverRef.current?.ref?.current?.contains(e.target as Node) || activePopoverRef.current?.anchorRef?.current?.contains(e.target as Node)) { + if (elementContains(activePopoverRef.current?.ref, e.target) || elementContains(activePopoverRef.current?.anchorRef, e.target)) { return; } const ref = activePopoverRef.current?.anchorRef; @@ -40,9 +48,9 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - React.useEffect(() => { + useEffect(() => { const listener = (e: Event) => { - if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) { + if (elementContains(activePopoverRef.current?.ref, e.target)) { return; } closePopover(); @@ -53,7 +61,7 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - React.useEffect(() => { + useEffect(() => { const listener = (e: KeyboardEvent) => { if (e.key !== 'Escape') { return; @@ -66,7 +74,7 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - React.useEffect(() => { + useEffect(() => { const listener = () => { if (document.hasFocus()) { return; @@ -79,9 +87,9 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - React.useEffect(() => { + useEffect(() => { const listener = (e: Event) => { - if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) { + if (elementContains(activePopoverRef.current?.ref, e.target)) { return; } @@ -93,7 +101,7 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - const onOpen = React.useCallback( + const onOpen = useCallback( (popoverParams: AnchorRef) => { if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams?.ref) { closePopover(activePopoverRef.current.anchorRef); @@ -107,7 +115,7 @@ function PopoverContextProvider(props: PopoverContextProps) { [closePopover], ); - const contextValue = React.useMemo( + const contextValue = useMemo( () => ({ onOpen, close: closePopover, diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts index ffd0087cd5ff..49705d7ea7a8 100644 --- a/src/components/PopoverProvider/types.ts +++ b/src/components/PopoverProvider/types.ts @@ -1,18 +1,21 @@ +import type {ReactNode, RefObject} from 'react'; +import type {View} from 'react-native'; + type PopoverContextProps = { - children: React.ReactNode; + children: ReactNode; }; type PopoverContextValue = { onOpen?: (popoverParams: AnchorRef) => void; popover?: AnchorRef | Record | null; - close: (anchorRef?: React.RefObject) => void; + close: (anchorRef?: RefObject) => void; isOpen: boolean; }; type AnchorRef = { - ref: React.RefObject; - close: (anchorRef?: React.RefObject) => void; - anchorRef: React.RefObject; + ref: RefObject; + close: (anchorRef?: RefObject) => void; + anchorRef: RefObject; onOpenCallback?: () => void; onCloseCallback?: () => void; }; diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index 6aed275bd2dc..58d022ef9d65 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -8,6 +8,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Modal from '@userActions/Modal'; +import viewRef from '@src/types/utils/viewRef'; import type PopoverWithoutOverlayProps from './types'; function PopoverWithoutOverlay( @@ -52,7 +53,7 @@ function PopoverWithoutOverlay( close: onClose, anchorRef, }); - removeOnClose = Modal.setCloseModal(() => onClose(anchorRef)); + removeOnClose = Modal.setCloseModal(onClose); } else { onModalHide(); close(anchorRef); @@ -119,7 +120,7 @@ function PopoverWithoutOverlay( return ( ; + anchorRef: RefObject; /** A react-native-animatable animation timing for the modal display animation */ animationInTiming?: number; @@ -22,7 +23,7 @@ type PopoverWithoutOverlayProps = ChildrenProps & disableAnimation?: boolean; /** The ref of the popover */ - withoutOverlayRef: React.RefObject; + withoutOverlayRef: RefObject; }; export default PopoverWithoutOverlayProps; diff --git a/src/components/ProcessMoneyRequestHoldMenu.tsx b/src/components/ProcessMoneyRequestHoldMenu.tsx index 1b711633ed3b..5f32240aca9b 100644 --- a/src/components/ProcessMoneyRequestHoldMenu.tsx +++ b/src/components/ProcessMoneyRequestHoldMenu.tsx @@ -1,3 +1,4 @@ +import type {RefObject} from 'react'; import React from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; @@ -27,7 +28,7 @@ type ProcessMoneyRequestHoldMenuProps = { anchorAlignment: AnchorAlignment; /** The anchor ref of the popover menu */ - anchorRef: React.RefObject; + anchorRef: RefObject; }; function ProcessMoneyRequestHoldMenu({isVisible, onClose, onConfirm, anchorPosition, anchorAlignment, anchorRef}: ProcessMoneyRequestHoldMenuProps) { diff --git a/src/components/QRShare/QRShareWithDownload/index.native.tsx b/src/components/QRShare/QRShareWithDownload/index.native.tsx index d1d9f13147f1..7d192c84c454 100644 --- a/src/components/QRShare/QRShareWithDownload/index.native.tsx +++ b/src/components/QRShare/QRShareWithDownload/index.native.tsx @@ -3,6 +3,7 @@ import React, {forwardRef, useImperativeHandle, useRef} from 'react'; import ViewShot from 'react-native-view-shot'; import getQrCodeFileName from '@components/QRShare/getQrCodeDownloadFileName'; import type {QRShareProps} from '@components/QRShare/types'; +import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import fileDownload from '@libs/fileDownload'; import QRShare from '..'; @@ -10,14 +11,16 @@ import type QRShareWithDownloadHandle from './types'; function QRShareWithDownload(props: QRShareProps, ref: ForwardedRef) { const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + const qrCodeScreenshotRef = useRef(null); useImperativeHandle( ref, () => ({ - download: () => qrCodeScreenshotRef.current?.capture?.().then((uri) => fileDownload(uri, getQrCodeFileName(props.title))), + download: () => qrCodeScreenshotRef.current?.capture?.().then((uri) => fileDownload(uri, getQrCodeFileName(props.title), translate('fileDownload.success.qrMessage'))), }), - [props.title], + [props.title, translate], ); return ( diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx index 8aa3ef7e8ffe..cb6843af65c0 100644 --- a/src/components/RadioButtons.tsx +++ b/src/components/RadioButtons.tsx @@ -1,4 +1,5 @@ import React, {useState} from 'react'; +import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import RadioButtonWithLabel from './RadioButtonWithLabel'; @@ -20,7 +21,7 @@ function RadioButtons({items, onPress}: RadioButtonsProps) { const [checkedValue, setCheckedValue] = useState(''); return ( - <> + {items.map((item) => ( ))} - + ); } diff --git a/src/components/Reactions/MiniQuickEmojiReactions.tsx b/src/components/Reactions/MiniQuickEmojiReactions.tsx index 9f38da6bdb3d..815736d8af76 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.tsx +++ b/src/components/Reactions/MiniQuickEmojiReactions.tsx @@ -1,6 +1,5 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; import BaseMiniContextMenuItem from '@components/BaseMiniContextMenuItem'; @@ -12,20 +11,12 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as EmojiUtils from '@libs/EmojiUtils'; import getButtonState from '@libs/getButtonState'; +import variables from '@styles/variables'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportActionReactions} from '@src/types/onyx'; -import type {BaseQuickEmojiReactionsProps} from './QuickEmojiReactions/types'; - -type MiniQuickEmojiReactionsOnyxProps = { - /** All the emoji reactions for the report action. */ - emojiReactions: OnyxEntry; - - /** The user's preferred skin tone. */ - preferredSkinTone: OnyxEntry; -}; +import type {BaseQuickEmojiReactionsOnyxProps, BaseQuickEmojiReactionsProps} from './QuickEmojiReactions/types'; type MiniQuickEmojiReactionsProps = BaseQuickEmojiReactionsProps & { /** @@ -71,7 +62,7 @@ function MiniQuickEmojiReactions({ return ( - {CONST.QUICK_REACTIONS.map((emoji: Emoji) => ( + {CONST.QUICK_REACTIONS.slice(0, 3).map((emoji: Emoji) => ( {({hovered, pressed}) => ( @@ -112,11 +104,14 @@ function MiniQuickEmojiReactions({ MiniQuickEmojiReactions.displayName = 'MiniQuickEmojiReactions'; -export default withOnyx({ +export default withOnyx({ preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, }, emojiReactions: { key: ({reportActionID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, }, + preferredLocale: { + key: ONYXKEYS.NVP_PREFERRED_LOCALE, + }, })(MiniQuickEmojiReactions); diff --git a/src/components/Reactions/QuickEmojiReactions/types.ts b/src/components/Reactions/QuickEmojiReactions/types.ts index d782d5ae35c7..9c17a87c56c0 100644 --- a/src/components/Reactions/QuickEmojiReactions/types.ts +++ b/src/components/Reactions/QuickEmojiReactions/types.ts @@ -11,18 +11,7 @@ type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrig type CloseContextMenuCallback = () => void; -type BaseQuickEmojiReactionsOnyxProps = { - /** All the emoji reactions for the report action. */ - emojiReactions: OnyxEntry; - - /** The user's preferred locale. */ - preferredLocale: OnyxEntry; - - /** The user's preferred skin tone. */ - preferredSkinTone: OnyxEntry; -}; - -type BaseQuickEmojiReactionsProps = BaseQuickEmojiReactionsOnyxProps & { +type BaseReactionsProps = { /** Callback to fire when an emoji is selected. */ onEmojiSelected: (emoji: Emoji, emojiReactions: OnyxEntry) => void; @@ -45,7 +34,20 @@ type BaseQuickEmojiReactionsProps = BaseQuickEmojiReactionsOnyxProps & { reportActionID: string; }; -type QuickEmojiReactionsProps = BaseQuickEmojiReactionsProps & { +type BaseQuickEmojiReactionsOnyxProps = { + /** All the emoji reactions for the report action. */ + emojiReactions: OnyxEntry; + + /** The user's preferred locale. */ + preferredLocale: OnyxEntry; + + /** The user's preferred skin tone. */ + preferredSkinTone: OnyxEntry; +}; + +type BaseQuickEmojiReactionsProps = BaseReactionsProps & BaseQuickEmojiReactionsOnyxProps; + +type QuickEmojiReactionsProps = BaseReactionsProps & { /** * Function that can be called to close the context menu * in which this component is rendered. diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index 96c9e1b364d6..9cb27e6fac4a 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -1,3 +1,4 @@ +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import {truncate} from 'lodash'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; @@ -12,6 +13,7 @@ import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithoutFeedback'; import refPropTypes from '@components/refPropTypes'; +import RenderHTML from '@components/RenderHTML'; import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import transactionPropTypes from '@components/transactionPropTypes'; @@ -132,6 +134,7 @@ function MoneyRequestPreview(props) { const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); + const parser = new ExpensiMark(); if (_.isEmpty(props.iouReport) && !props.isBillSplit) { return null; @@ -328,7 +331,8 @@ function MoneyRequestPreview(props) { {!isCurrentUserManager && props.shouldShowPendingConversionMessage && ( {translate('iou.pendingConversionMessage')} )} - {(shouldShowDescription || shouldShowMerchant) && {merchantOrDescription}} + {shouldShowDescription && } + {shouldShowMerchant && {merchantOrDescription}} {props.isBillSplit && !_.isEmpty(participantAccountIDs) && requestAmount > 0 && ( diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 622cd75a568b..8483b7a481f2 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -166,6 +166,9 @@ function ReportPreview(props) { const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, (transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction)); let formattedMerchant = numberOfRequests === 1 && hasReceipts ? TransactionUtils.getMerchant(transactionsWithReceipts[0]) : null; + if (TransactionUtils.isPartialMerchant(formattedMerchant)) { + formattedMerchant = null; + } const hasPendingWaypoints = formattedMerchant && hasOnlyDistanceRequests && _.every(transactionsWithReceipts, (transaction) => lodashGet(transaction, 'pendingFields.waypoints', null)); if (hasPendingWaypoints) { formattedMerchant = formattedMerchant.replace(CONST.REGEX.FIRST_SPACE, props.translate('common.tbd')); @@ -294,7 +297,7 @@ function ReportPreview(props) { )} - {!isScanning && (numberOfRequests > 1 || hasReceipts) && ( + {!isScanning && (numberOfRequests > 1 || (hasReceipts && numberOfRequests === 1 && formattedMerchant)) && ( {previewSubtitle || moneyRequestComment} diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index a509d8d922e1..8ef837ed986d 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -1,5 +1,7 @@ import Str from 'expensify-common/lib/str'; import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {Text as RNText} from 'react-native'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -63,7 +65,7 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & chatReportID: string; /** Popover context menu anchor, used for showing context menu */ - contextMenuAnchor: Element; + contextMenuAnchor: RNText | null; /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: () => void; @@ -112,7 +114,7 @@ function TaskPreview({ onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(taskReportID))} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action ?? {}, checkIfContextMenuActive)} + onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} style={[styles.flexRow, styles.justifyContentBetween]} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('task.task')} diff --git a/src/components/SectionList/index.android.tsx b/src/components/SectionList/index.android.tsx index 1aa9b501146c..c119e8e9bcf3 100644 --- a/src/components/SectionList/index.android.tsx +++ b/src/components/SectionList/index.android.tsx @@ -1,19 +1,22 @@ import React, {forwardRef} from 'react'; +import type {ForwardedRef} from 'react'; import {SectionList as RNSectionList} from 'react-native'; -import type ForwardedSectionList from './types'; +import type {SectionListProps} from 'react-native'; // eslint-disable-next-line react/function-component-definition -const SectionListWithRef: ForwardedSectionList = (props, ref) => ( - -); +function SectionListWithRef(props: SectionListProps, ref: ForwardedRef>) { + return ( + + ); +} SectionListWithRef.displayName = 'SectionListWithRef'; diff --git a/src/components/SectionList/index.tsx b/src/components/SectionList/index.tsx index 4af7ad33705c..1129b2bdbb8f 100644 --- a/src/components/SectionList/index.tsx +++ b/src/components/SectionList/index.tsx @@ -1,16 +1,17 @@ import React, {forwardRef} from 'react'; +import type {ForwardedRef} from 'react'; import {SectionList as RNSectionList} from 'react-native'; -import type ForwardedSectionList from './types'; +import type {SectionListProps} from 'react-native'; // eslint-disable-next-line react/function-component-definition -const SectionList: ForwardedSectionList = (props, ref) => ( - -); - -SectionList.displayName = 'SectionList'; +function SectionList(props: SectionListProps, ref: ForwardedRef>) { + return ( + + ); +} export default forwardRef(SectionList); diff --git a/src/components/SectionList/types.ts b/src/components/SectionList/types.ts deleted file mode 100644 index 4648172aabfd..000000000000 --- a/src/components/SectionList/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {ForwardedRef} from 'react'; -import type {SectionList, SectionListProps} from 'react-native'; - -type ForwardedSectionList = { - (props: SectionListProps, ref: ForwardedRef): React.ReactNode; - displayName: string; -}; - -export default ForwardedSectionList; diff --git a/src/components/SelectionList/BaseListItem.js b/src/components/SelectionList/BaseListItem.tsx similarity index 73% rename from src/components/SelectionList/BaseListItem.js rename to src/components/SelectionList/BaseListItem.tsx index cfd39ab0ebb8..59a1c4dd08ce 100644 --- a/src/components/SelectionList/BaseListItem.js +++ b/src/components/SelectionList/BaseListItem.tsx @@ -1,4 +1,3 @@ -import lodashGet from 'lodash/get'; import React from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; @@ -12,10 +11,10 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import RadioListItem from './RadioListItem'; -import {baseListItemPropTypes} from './selectionListPropTypes'; +import type {BaseListItemProps, RadioItem, User} from './types'; import UserListItem from './UserListItem'; -function BaseListItem({ +function BaseListItem({ item, isFocused = false, isDisabled = false, @@ -26,13 +25,12 @@ function BaseListItem({ onDismissError = () => {}, rightHandSideComponent, keyForList, -}) { +}: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const isUserItem = lodashGet(item, 'icons.length', 0) > 0; - const ListItem = isUserItem ? UserListItem : RadioListItem; + const isRadioItem = item.rightElement === undefined; const rightHandSideComponentRender = () => { if (canSelectMultiple || !rightHandSideComponent) { @@ -70,7 +68,7 @@ function BaseListItem({ styles.justifyContentBetween, styles.sidebarLinkInner, styles.userSelectNone, - isUserItem ? styles.peopleRow : styles.optionRow, + isRadioItem ? styles.optionRow : styles.peopleRow, isFocused && styles.sidebarLinkActive, ]} > @@ -100,20 +98,32 @@ function BaseListItem({ )} - + + {isRadioItem ? ( + onSelectRow(item)} + showTooltip={showTooltip} + /> + ) : ( + onSelectRow(item)} + showTooltip={showTooltip} + /> + )} {!canSelectMultiple && item.isSelected && !rightHandSideComponent && ( - {Boolean(item.invitedSecondaryLogin) && ( + {!!item.invitedSecondaryLogin && ( {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} @@ -140,6 +150,5 @@ function BaseListItem({ } BaseListItem.displayName = 'BaseListItem'; -BaseListItem.propTypes = baseListItemPropTypes; export default BaseListItem; diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.tsx similarity index 76% rename from src/components/SelectionList/BaseSelectionList.js rename to src/components/SelectionList/BaseSelectionList.tsx index 960618808fd9..cc55b8e4fc17 100644 --- a/src/components/SelectionList/BaseSelectionList.js +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -1,8 +1,8 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; -import lodashGet from 'lodash/get'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {ForwardedRef} from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListRenderItemInfo} from 'react-native'; import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; @@ -13,69 +13,60 @@ import SafeAreaConsumer from '@components/SafeAreaConsumer'; import SectionList from '@components/SectionList'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import withKeyboardState, {keyboardStatePropTypes} from '@components/withKeyboardState'; import useActiveElementRole from '@hooks/useActiveElementRole'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; -import {propTypes as selectionListPropTypes} from './selectionListPropTypes'; - -const propTypes = { - ...keyboardStatePropTypes, - ...selectionListPropTypes, -}; - -function BaseSelectionList({ - sections, - canSelectMultiple = false, - onSelectRow, - onSelectAll, - onDismissError, - textInputLabel = '', - textInputPlaceholder = '', - textInputValue = '', - textInputHint = '', - textInputMaxLength, - inputMode = CONST.INPUT_MODE.TEXT, - onChangeText, - initiallyFocusedOptionKey = '', - onScroll, - onScrollBeginDrag, - headerMessage = '', - confirmButtonText = '', - onConfirm, - headerContent, - footerContent, - showScrollIndicator = false, - showLoadingPlaceholder = false, - showConfirmButton = false, - shouldPreventDefaultFocusOnSelectRow = false, - isKeyboardShown = false, - containerStyle = [], - disableInitialFocusOptionStyle = false, - inputRef = null, - disableKeyboardShortcuts = false, - children, - shouldStopPropagation = false, - shouldShowTooltips = true, - shouldUseDynamicMaxToRenderPerBatch = false, - rightHandSideComponent, -}) { - const theme = useTheme(); +import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, RadioItem, Section, SectionListDataType, User} from './types'; + +function BaseSelectionList( + { + sections, + canSelectMultiple = false, + onSelectRow, + onSelectAll, + onDismissError, + textInputLabel = '', + textInputPlaceholder = '', + textInputValue = '', + textInputHint, + textInputMaxLength, + inputMode = CONST.INPUT_MODE.TEXT, + onChangeText, + initiallyFocusedOptionKey = '', + onScroll, + onScrollBeginDrag, + headerMessage = '', + confirmButtonText = '', + onConfirm = () => {}, + headerContent, + footerContent, + showScrollIndicator = false, + showLoadingPlaceholder = false, + showConfirmButton = false, + shouldPreventDefaultFocusOnSelectRow = false, + containerStyle, + isKeyboardShown = false, + disableKeyboardShortcuts = false, + children, + shouldStopPropagation = false, + shouldShowTooltips = true, + shouldUseDynamicMaxToRenderPerBatch = false, + rightHandSideComponent, + }: BaseSelectionListProps, + inputRef: ForwardedRef, +) { const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const listRef = useRef(null); - const textInputRef = useRef(null); - const focusTimeoutRef = useRef(null); - const shouldShowTextInput = Boolean(textInputLabel); - const shouldShowSelectAll = Boolean(onSelectAll); + const listRef = useRef>>(null); + const textInputRef = useRef(null); + const focusTimeoutRef = useRef(null); + const shouldShowTextInput = !!textInputLabel; + const shouldShowSelectAll = !!onSelectAll; const activeElementRole = useActiveElementRole(); const isFocused = useIsFocused(); const [maxToRenderPerBatch, setMaxToRenderPerBatch] = useState(shouldUseDynamicMaxToRenderPerBatch ? 0 : CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); @@ -87,26 +78,24 @@ function BaseSelectionList({ * - `disabledOptionsIndexes`: Contains the indexes of all the disabled items in the list, to be used by the ArrowKeyFocusManager * - `itemLayouts`: Contains the layout information for each item, header and footer in the list, * so we can calculate the position of any given item when scrolling programmatically - * - * @return {{itemLayouts: [{offset: number, length: number}], disabledOptionsIndexes: *[], allOptions: *[]}} */ - const flattenedSections = useMemo(() => { - const allOptions = []; + const flattenedSections = useMemo>(() => { + const allOptions: TItem[] = []; - const disabledOptionsIndexes = []; + const disabledOptionsIndexes: number[] = []; let disabledIndex = 0; let offset = 0; const itemLayouts = [{length: 0, offset}]; - const selectedOptions = []; + const selectedOptions: TItem[] = []; - _.each(sections, (section, sectionIndex) => { + sections.forEach((section, sectionIndex) => { const sectionHeaderHeight = variables.optionsListSectionHeaderHeight; itemLayouts.push({length: sectionHeaderHeight, offset}); offset += sectionHeaderHeight; - _.each(section.data, (item, optionIndex) => { + section.data.forEach((item, optionIndex) => { // Add item to the general flattened array allOptions.push({ ...item, @@ -115,7 +104,7 @@ function BaseSelectionList({ }); // If disabled, add to the disabled indexes array - if (section.isDisabled || item.isDisabled) { + if (!!section.isDisabled || item.isDisabled) { disabledOptionsIndexes.push(disabledIndex); } disabledIndex += 1; @@ -155,19 +144,19 @@ function BaseSelectionList({ }, [canSelectMultiple, sections]); // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member - const [focusedIndex, setFocusedIndex] = useState(() => _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey)); + const [focusedIndex, setFocusedIndex] = useState(() => flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey)); // Disable `Enter` shortcut if the active element is a button or checkbox - const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX].includes(activeElementRole); + const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX].includes(activeElementRole as ButtonOrCheckBoxRoles); /** * Scrolls to the desired item index in the section list * - * @param {Number} index - the index of the item to scroll to - * @param {Boolean} animated - whether to animate the scroll + * @param index - the index of the item to scroll to + * @param animated - whether to animate the scroll */ const scrollToIndex = useCallback( - (index, animated = true) => { + (index: number, animated = true) => { const item = flattenedSections.allOptions[index]; if (!listRef.current || !item) { @@ -182,7 +171,7 @@ function BaseSelectionList({ // Otherwise, it will cause an index-out-of-bounds error and crash the app. let adjustedSectionIndex = sectionIndex; for (let i = 0; i < sectionIndex; i++) { - if (_.isEmpty(lodashGet(sections, `[${i}].data`))) { + if (sections[i].data) { adjustedSectionIndex--; } } @@ -197,10 +186,10 @@ function BaseSelectionList({ /** * Logic to run when a row is selected, either with click/press or keyboard hotkeys. * - * @param {Object} item - the list item - * @param {Boolean} shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) + * @param item - the list item + * @param shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) */ - const selectRow = (item, shouldUnfocusRow = false) => { + const selectRow = (item: TItem, shouldUnfocusRow = false) => { // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item if (canSelectMultiple) { if (sections.length > 1) { @@ -233,15 +222,15 @@ function BaseSelectionList({ }; const selectAllRow = () => { - onSelectAll(); + onSelectAll?.(); + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && textInputRef.current) { textInputRef.current.focus(); } }; - const selectFocusedOption = (e) => { - const focusedItemKey = lodashGet(e, ['target', 'attributes', 'id', 'value']); - const focusedOption = focusedItemKey ? _.find(flattenedSections.allOptions, (option) => option.keyForList === focusedItemKey) : flattenedSections.allOptions[focusedIndex]; + const selectFocusedOption = () => { + const focusedOption = flattenedSections.allOptions[focusedIndex]; if (!focusedOption || focusedOption.isDisabled) { return; @@ -254,8 +243,8 @@ function BaseSelectionList({ * 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: + * @param data - This is the same as the data we pass into the component + * @param 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. @@ -263,10 +252,8 @@ function BaseSelectionList({ * 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) => { + const getItemLayout = (data: Array> | null, flatDataArrayIndex: number) => { const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex]; if (!targetItem) { @@ -284,8 +271,8 @@ function BaseSelectionList({ }; }; - const renderSectionHeader = ({section}) => { - if (!section.title || _.isEmpty(section.data)) { + const renderSectionHeader = ({section}: {section: SectionListDataType}) => { + if (!section.title || !section.data) { return null; } @@ -300,9 +287,10 @@ function BaseSelectionList({ ); }; - const renderItem = ({item, index, section}) => { - const normalizedIndex = index + lodashGet(section, 'indexOffset', 0); - const isDisabled = section.isDisabled || item.isDisabled; + const renderItem = ({item, index, section}: SectionListRenderItemInfo>) => { + const indexOffset = section.indexOffset ? section.indexOffset : 0; + const normalizedIndex = index + indexOffset; + const isDisabled = !!section.isDisabled || item.isDisabled; const isItemFocused = !isDisabled && focusedIndex === normalizedIndex; // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const showTooltip = shouldShowTooltips && normalizedIndex < 10; @@ -312,11 +300,9 @@ function BaseSelectionList({ item={item} isFocused={isItemFocused} isDisabled={isDisabled} - isHide={!maxToRenderPerBatch} showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={() => selectRow(item, true)} - disableIsFocusStyle={disableInitialFocusOptionStyle} onDismissError={onDismissError} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} rightHandSideComponent={rightHandSideComponent} @@ -326,11 +312,10 @@ function BaseSelectionList({ }; const scrollToFocusedIndexOnFirstRender = useCallback( - ({nativeEvent}) => { + (nativeEvent: LayoutChangeEvent) => { if (shouldUseDynamicMaxToRenderPerBatch) { - const listHeight = lodashGet(nativeEvent, 'layout.height', 0); - const itemHeight = lodashGet(nativeEvent, 'layout.y', 0); - + const listHeight = nativeEvent.nativeEvent.layout.height; + const itemHeight = nativeEvent.nativeEvent.layout.y; setMaxToRenderPerBatch((Math.ceil(listHeight / itemHeight) || 0) + CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); } @@ -344,7 +329,7 @@ function BaseSelectionList({ ); const updateAndScrollToFocusedIndex = useCallback( - (newFocusedIndex) => { + (newFocusedIndex: number) => { setFocusedIndex(newFocusedIndex); scrollToIndex(newFocusedIndex, true); }, @@ -355,7 +340,12 @@ function BaseSelectionList({ useFocusEffect( useCallback(() => { if (shouldShowTextInput) { - focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION); + focusTimeoutRef.current = setTimeout(() => { + if (!textInputRef.current) { + return; + } + textInputRef.current.focus(); + }, CONST.ANIMATED_TRANSITION); } return () => { if (!focusTimeoutRef.current) { @@ -382,7 +372,7 @@ function BaseSelectionList({ /** Selects row when pressing Enter */ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { captureOnInputs: true, - shouldBubble: () => !flattenedSections.allOptions[focusedIndex], + shouldBubble: !flattenedSections.allOptions[focusedIndex], shouldStopPropagation, isActive: !disableKeyboardShortcuts && !disableEnterShortcut && isFocused, }); @@ -390,8 +380,8 @@ function BaseSelectionList({ /** Calls confirm action when pressing CTRL (CMD) + Enter */ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm, { captureOnInputs: true, - shouldBubble: () => !flattenedSections.allOptions[focusedIndex], - isActive: !disableKeyboardShortcuts && Boolean(onConfirm) && isFocused, + shouldBubble: !flattenedSections.allOptions[focusedIndex], + isActive: !disableKeyboardShortcuts && !!onConfirm && isFocused, }); return ( @@ -401,19 +391,22 @@ function BaseSelectionList({ maxIndex={flattenedSections.allOptions.length - 1} onFocusedIndexChanged={updateAndScrollToFocusedIndex} > - {/* */} {({safeAreaPaddingBottomStyle}) => ( - + {shouldShowTextInput && ( { - if (inputRef) { - // eslint-disable-next-line no-param-reassign - inputRef.current = el; + ref={(element) => { + textInputRef.current = element as RNTextInput; + + if (!inputRef) { + return; + } + + if (typeof inputRef === 'function') { + inputRef(element as RNTextInput); } - textInputRef.current = el; }} label={textInputLabel} accessibilityLabel={textInputLabel} @@ -427,16 +420,16 @@ function BaseSelectionList({ selectTextOnFocus spellCheck={false} onSubmitEditing={selectFocusedOption} - blurOnSubmit={Boolean(flattenedSections.allOptions.length)} + blurOnSubmit={!!flattenedSections.allOptions.length} /> )} - {Boolean(headerMessage) && ( + {!!headerMessage && ( {headerMessage} )} - {Boolean(headerContent) && headerContent} + {!!headerContent && headerContent} {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? ( ) : ( @@ -472,9 +465,9 @@ function BaseSelectionList({ getItemLayout={getItemLayout} onScroll={onScroll} onScrollBeginDrag={onScrollBeginDrag} - keyExtractor={(item) => item.keyForList} + keyExtractor={(item: TItem) => item.keyForList} extraData={focusedIndex} - indicatorStyle={theme.white} + indicatorStyle="white" keyboardShouldPersistTaps="always" showsVerticalScrollIndicator={showScrollIndicator} initialNumToRender={12} @@ -500,7 +493,7 @@ function BaseSelectionList({ /> )} - {Boolean(footerContent) && {footerContent}} + {!!footerContent && {footerContent}} )} @@ -509,6 +502,5 @@ function BaseSelectionList({ } BaseSelectionList.displayName = 'BaseSelectionList'; -BaseSelectionList.propTypes = propTypes; -export default withKeyboardState(BaseSelectionList); +export default forwardRef(BaseSelectionList); diff --git a/src/components/SelectionList/RadioListItem.js b/src/components/SelectionList/RadioListItem.tsx similarity index 87% rename from src/components/SelectionList/RadioListItem.js rename to src/components/SelectionList/RadioListItem.tsx index 2de0c96932ea..769eaa80df4b 100644 --- a/src/components/SelectionList/RadioListItem.js +++ b/src/components/SelectionList/RadioListItem.tsx @@ -3,10 +3,11 @@ import {View} from 'react-native'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useThemeStyles from '@hooks/useThemeStyles'; -import {radioListItemPropTypes} from './selectionListPropTypes'; +import type {RadioListItemProps} from './types'; -function RadioListItem({item, showTooltip, textStyles, alternateTextStyles}) { +function RadioListItem({item, showTooltip, textStyles, alternateTextStyles}: RadioListItemProps) { const styles = useThemeStyles(); + return ( - {Boolean(item.alternateText) && ( + {!!item.alternateText && ( - {Boolean(item.icons) && ( + {!!item.icons && ( )} @@ -26,19 +23,19 @@ function UserListItem({item, textStyles, alternateTextStyles, showTooltip, style text={item.text} > {item.text} - {Boolean(item.alternateText) && ( + {!!item.alternateText && ( {item.alternateText} @@ -46,12 +43,11 @@ function UserListItem({item, textStyles, alternateTextStyles, showTooltip, style )} - {Boolean(item.rightElement) && item.rightElement} + {!!item.rightElement && item.rightElement} ); } UserListItem.displayName = 'UserListItem'; -UserListItem.propTypes = userListItemPropTypes; export default UserListItem; diff --git a/src/components/SelectionList/index.android.js b/src/components/SelectionList/index.android.js deleted file mode 100644 index 53d5b6bbce06..000000000000 --- a/src/components/SelectionList/index.android.js +++ /dev/null @@ -1,17 +0,0 @@ -import React, {forwardRef} from 'react'; -import {Keyboard} from 'react-native'; -import BaseSelectionList from './BaseSelectionList'; - -const SelectionList = forwardRef((props, ref) => ( - Keyboard.dismiss()} - /> -)); - -SelectionList.displayName = 'SelectionList'; - -export default SelectionList; diff --git a/src/components/SelectionList/index.android.tsx b/src/components/SelectionList/index.android.tsx new file mode 100644 index 000000000000..8487c6e2cc67 --- /dev/null +++ b/src/components/SelectionList/index.android.tsx @@ -0,0 +1,22 @@ +import React, {forwardRef} from 'react'; +import type {ForwardedRef} from 'react'; +import {Keyboard} from 'react-native'; +import type {TextInput} from 'react-native'; +import BaseSelectionList from './BaseSelectionList'; +import type {BaseSelectionListProps, RadioItem, User} from './types'; + +function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { + return ( + Keyboard.dismiss()} + /> + ); +} + +SelectionList.displayName = 'SelectionList'; + +export default forwardRef(SelectionList); diff --git a/src/components/SelectionList/index.ios.js b/src/components/SelectionList/index.ios.js deleted file mode 100644 index 7f2a282aeb89..000000000000 --- a/src/components/SelectionList/index.ios.js +++ /dev/null @@ -1,16 +0,0 @@ -import React, {forwardRef} from 'react'; -import {Keyboard} from 'react-native'; -import BaseSelectionList from './BaseSelectionList'; - -const SelectionList = forwardRef((props, ref) => ( - Keyboard.dismiss()} - /> -)); - -SelectionList.displayName = 'SelectionList'; - -export default SelectionList; diff --git a/src/components/SelectionList/index.ios.tsx b/src/components/SelectionList/index.ios.tsx new file mode 100644 index 000000000000..9c32d38314e2 --- /dev/null +++ b/src/components/SelectionList/index.ios.tsx @@ -0,0 +1,21 @@ +import React, {forwardRef} from 'react'; +import type {ForwardedRef} from 'react'; +import {Keyboard} from 'react-native'; +import type {TextInput} from 'react-native'; +import BaseSelectionList from './BaseSelectionList'; +import type {BaseSelectionListProps, RadioItem, User} from './types'; + +function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + ref={ref} + onScrollBeginDrag={() => Keyboard.dismiss()} + /> + ); +} + +SelectionList.displayName = 'SelectionList'; + +export default forwardRef(SelectionList); diff --git a/src/components/SelectionList/index.js b/src/components/SelectionList/index.tsx similarity index 82% rename from src/components/SelectionList/index.js rename to src/components/SelectionList/index.tsx index 24ea60d29be5..93754926cacb 100644 --- a/src/components/SelectionList/index.js +++ b/src/components/SelectionList/index.tsx @@ -1,9 +1,12 @@ import React, {forwardRef, useEffect, useState} from 'react'; +import type {ForwardedRef} from 'react'; import {Keyboard} from 'react-native'; +import type {TextInput} from 'react-native'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import BaseSelectionList from './BaseSelectionList'; +import type {BaseSelectionListProps, RadioItem, User} from './types'; -const SelectionList = forwardRef((props, ref) => { +function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { const [isScreenTouched, setIsScreenTouched] = useState(false); const touchStart = () => setIsScreenTouched(true); @@ -39,8 +42,8 @@ const SelectionList = forwardRef((props, ref) => { }} /> ); -}); +} SelectionList.displayName = 'SelectionList'; -export default SelectionList; +export default forwardRef(SelectionList); diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts new file mode 100644 index 000000000000..5c28a139903d --- /dev/null +++ b/src/components/SelectionList/types.ts @@ -0,0 +1,277 @@ +import type {ReactElement, ReactNode} from 'react'; +import type {GestureResponderEvent, InputModeOptions, SectionListData, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {SubAvatar} from '@components/SubscriptAvatar'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type CommonListItemProps = { + /** Whether this item is focused (for arrow key controls) */ + isFocused?: boolean; + + /** Style to be applied to Text */ + textStyles?: StyleProp; + + /** Style to be applied on the alternate text */ + alternateTextStyles?: StyleProp; + + /** Whether this item is disabled */ + isDisabled?: boolean; + + /** Whether this item should show Tooltip */ + showTooltip: boolean; + + /** Whether to use the Checkbox (multiple selection) instead of the Checkmark (single selection) */ + canSelectMultiple?: boolean; + + /** Callback to fire when the item is pressed */ + onSelectRow: (item: TItem) => void; + + /** Callback to fire when an error is dismissed */ + onDismissError?: (item: TItem) => void; + + /** Component to display on the right side */ + rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null; +}; + +type User = { + /** Text to display */ + text: string; + + /** Alternate text to display */ + alternateText?: string; + + /** Key used internally by React */ + keyForList: string; + + /** Whether this option is selected */ + isSelected?: boolean; + + /** Whether this option is disabled for selection */ + isDisabled?: boolean; + + /** User accountID */ + accountID?: number; + + /** User login */ + login?: string; + + /** Element to show on the right side of the item */ + rightElement: ReactElement; + + /** Icons for the user (can be multiple if it's a Workspace) */ + icons?: SubAvatar[]; + + /** Errors that this user may contain */ + errors?: Errors; + + /** The type of action that's pending */ + pendingAction?: PendingAction; + + invitedSecondaryLogin?: string; + + /** Represents the index of the section it came from */ + sectionIndex: number; + + /** Represents the index of the option within the section it came from */ + index: number; +}; + +type UserListItemProps = CommonListItemProps & { + /** The section list item */ + item: User; + + /** Additional styles to apply to text */ + style?: StyleProp; +}; + +type RadioItem = { + /** Text to display */ + text: string; + + /** Alternate text to display */ + alternateText?: string; + + /** Key used internally by React */ + keyForList: string; + + /** Whether this option is selected */ + isSelected?: boolean; + + /** Element to show on the right side of the item */ + rightElement?: undefined; + + /** Whether this option is disabled for selection */ + isDisabled?: undefined; + + invitedSecondaryLogin?: undefined; + + /** Errors that this user may contain */ + errors?: undefined; + + /** The type of action that's pending */ + pendingAction?: undefined; + + /** Represents the index of the section it came from */ + sectionIndex: number; + + /** Represents the index of the option within the section it came from */ + index: number; +}; + +type RadioListItemProps = CommonListItemProps & { + /** The section list item */ + item: RadioItem; +}; + +type BaseListItemProps = CommonListItemProps & { + item: TItem; + shouldPreventDefaultFocusOnSelectRow?: boolean; + keyForList?: string; +}; + +type Section = { + /** Title of the section */ + title?: string; + + /** The initial index of this section given the total number of options in each section's data array */ + indexOffset?: number; + + /** Array of options */ + data: TItem[]; + + /** Whether this section items disabled for selection */ + isDisabled?: boolean; +}; + +type BaseSelectionListProps = Partial & { + /** Sections for the section list */ + sections: Array>; + + /** Whether this is a multi-select list */ + canSelectMultiple?: boolean; + + /** Callback to fire when a row is pressed */ + onSelectRow: (item: TItem) => void; + + /** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */ + onSelectAll?: () => void; + + /** Callback to fire when an error is dismissed */ + onDismissError?: () => void; + + /** Label for the text input */ + textInputLabel?: string; + + /** Placeholder for the text input */ + textInputPlaceholder?: string; + + /** Hint for the text input */ + textInputHint?: string; + + /** Value for the text input */ + textInputValue?: string; + + /** Max length for the text input */ + textInputMaxLength?: number; + + /** Callback to fire when the text input changes */ + onChangeText?: (text: string) => void; + + /** Input mode for the text input */ + inputMode?: InputModeOptions; + + /** Item `keyForList` to focus initially */ + initiallyFocusedOptionKey?: string; + + /** Callback to fire when the list is scrolled */ + onScroll?: () => void; + + /** Callback to fire when the list is scrolled and the user begins dragging */ + onScrollBeginDrag?: () => void; + + /** Message to display at the top of the list */ + headerMessage?: string; + + /** Text to display on the confirm button */ + confirmButtonText?: string; + + /** Callback to fire when the confirm button is pressed */ + onConfirm?: (e?: GestureResponderEvent | KeyboardEvent | undefined) => void; + + /** Whether to show the vertical scroll indicator */ + showScrollIndicator?: boolean; + + /** Whether to show the loading placeholder */ + showLoadingPlaceholder?: boolean; + + /** Whether to show the default confirm button */ + showConfirmButton?: boolean; + + /** Whether tooltips should be shown */ + shouldShowTooltips?: boolean; + + /** Whether to stop automatic form submission on pressing enter key or not */ + shouldStopPropagation?: boolean; + + /** Whether to prevent default focusing of options and focus the textinput when selecting an option */ + shouldPreventDefaultFocusOnSelectRow?: boolean; + + /** Custom content to display in the header */ + headerContent?: ReactNode; + + /** Custom content to display in the footer */ + footerContent?: ReactNode; + + /** Whether to use dynamic maxToRenderPerBatch depending on the visible number of elements */ + shouldUseDynamicMaxToRenderPerBatch?: boolean; + + /** Whether keyboard shortcuts should be disabled */ + disableKeyboardShortcuts?: boolean; + + /** Whether to disable initial styling for focused option */ + disableInitialFocusOptionStyle?: boolean; + + /** Styles to apply to SelectionList container */ + containerStyle?: ViewStyle; + + /** Whether keyboard is visible on the screen */ + isKeyboardShown?: boolean; + + /** Whether focus event should be delayed */ + shouldDelayFocus?: boolean; + + /** Component to display on the right side of each child */ + rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null; +}; + +type ItemLayout = { + length: number; + offset: number; +}; + +type FlattenedSectionsReturn = { + allOptions: TItem[]; + selectedOptions: TItem[]; + disabledOptionsIndexes: number[]; + itemLayouts: ItemLayout[]; + allSelected: boolean; +}; + +type ButtonOrCheckBoxRoles = 'button' | 'checkbox'; + +type SectionListDataType = SectionListData>; + +export type { + BaseSelectionListProps, + CommonListItemProps, + UserListItemProps, + Section, + RadioListItemProps, + BaseListItemProps, + User, + RadioItem, + FlattenedSectionsReturn, + ItemLayout, + ButtonOrCheckBoxRoles, + SectionListDataType, +}; diff --git a/src/components/ShowContextMenuContext.js b/src/components/ShowContextMenuContext.js deleted file mode 100644 index 04ccd5002b60..000000000000 --- a/src/components/ShowContextMenuContext.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import CONST from '@src/CONST'; - -const ShowContextMenuContext = React.createContext({ - anchor: null, - report: null, - action: undefined, - checkIfContextMenuActive: () => {}, -}); - -ShowContextMenuContext.displayName = 'ShowContextMenuContext'; - -/** - * Show the report action context menu. - * - * @param {Object} event - Press event object - * @param {Element} anchor - Context menu anchor - * @param {String} reportID - Active Report ID - * @param {Object} action - ReportAction for ContextMenu - * @param {Function} checkIfContextMenuActive Callback to update context menu active state - * @param {Boolean} [isArchivedRoom=false] - Is the report an archived room - */ -function showContextMenuForReport(event, anchor, reportID, action, checkIfContextMenuActive, isArchivedRoom = false) { - if (!DeviceCapabilities.canUseTouchScreen()) { - return; - } - ReportActionContextMenu.showContextMenu( - CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - '', - anchor, - reportID, - action.reportActionID, - ReportUtils.getOriginalReportID(reportID, action), - undefined, - checkIfContextMenuActive, - checkIfContextMenuActive, - isArchivedRoom, - ); -} - -export {ShowContextMenuContext, showContextMenuForReport}; diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts new file mode 100644 index 000000000000..17557051bef9 --- /dev/null +++ b/src/components/ShowContextMenuContext.ts @@ -0,0 +1,64 @@ +import {createContext} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, Text as RNText} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; + +type ShowContextMenuContextProps = { + anchor: RNText | null; + report: OnyxEntry; + action: OnyxEntry; + checkIfContextMenuActive: () => void; +}; + +const ShowContextMenuContext = createContext({ + anchor: null, + report: null, + action: null, + checkIfContextMenuActive: () => {}, +}); + +ShowContextMenuContext.displayName = 'ShowContextMenuContext'; + +/** + * Show the report action context menu. + * + * @param event - Press event object + * @param anchor - Context menu anchor + * @param reportID - Active Report ID + * @param action - ReportAction for ContextMenu + * @param checkIfContextMenuActive Callback to update context menu active state + * @param isArchivedRoom - Is the report an archived room + */ +function showContextMenuForReport( + event: GestureResponderEvent | MouseEvent, + anchor: RNText | null, + reportID: string, + action: OnyxEntry, + checkIfContextMenuActive: () => void, + isArchivedRoom = false, +) { + if (!DeviceCapabilities.canUseTouchScreen()) { + return; + } + + ReportActionContextMenu.showContextMenu( + CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + '', + anchor, + reportID, + action?.reportActionID, + ReportUtils.getOriginalReportID(reportID, action), + undefined, + checkIfContextMenuActive, + checkIfContextMenuActive, + isArchivedRoom, + ); +} + +export {ShowContextMenuContext, showContextMenuForReport}; diff --git a/src/components/ShowMoreButton/index.js b/src/components/ShowMoreButton/index.js index 34b55fa5dcf1..28c33d185cff 100644 --- a/src/components/ShowMoreButton/index.js +++ b/src/components/ShowMoreButton/index.js @@ -1,9 +1,10 @@ import PropTypes from 'prop-types'; import React from 'react'; -import {Text, View} from 'react-native'; +import {View} from 'react-native'; import _ from 'underscore'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/SingleChoiceQuestion.tsx b/src/components/SingleChoiceQuestion.tsx index 4c2ba9c34a9f..c8bf783032ad 100644 --- a/src/components/SingleChoiceQuestion.tsx +++ b/src/components/SingleChoiceQuestion.tsx @@ -1,5 +1,6 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef} from 'react'; +// eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import type {MaybePhraseKey} from '@libs/Localize'; diff --git a/src/components/SubscriptAvatar.tsx b/src/components/SubscriptAvatar.tsx index 00cf248ad838..2e2ae6d06e0f 100644 --- a/src/components/SubscriptAvatar.tsx +++ b/src/components/SubscriptAvatar.tsx @@ -104,3 +104,4 @@ function SubscriptAvatar({mainAvatar = {}, secondaryAvatar = {}, size = CONST.AV SubscriptAvatar.displayName = 'SubscriptAvatar'; export default memo(SubscriptAvatar); +export type {SubAvatar}; diff --git a/src/components/Text.tsx b/src/components/Text.tsx index f436b9f4495a..b94530a423f7 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -1,5 +1,6 @@ import type {ForwardedRef} from 'react'; import React from 'react'; +// eslint-disable-next-line no-restricted-imports import {Text as RNText, StyleSheet} from 'react-native'; import type {TextProps as RNTextProps, TextStyle} from 'react-native'; import useTheme from '@hooks/useTheme'; diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index d19d835d68bb..99b3e98588ac 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -263,7 +263,7 @@ function BaseTextInput( return ( <> diff --git a/src/components/TextInput/TextInputLabel/index.tsx b/src/components/TextInput/TextInputLabel/index.tsx index 507d40f475a7..8f6d3efdcd8d 100644 --- a/src/components/TextInput/TextInputLabel/index.tsx +++ b/src/components/TextInput/TextInputLabel/index.tsx @@ -1,4 +1,5 @@ import React, {useEffect, useRef} from 'react'; +// eslint-disable-next-line no-restricted-imports import type {Text} from 'react-native'; import {Animated} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/TextLink.tsx b/src/components/TextLink.tsx index d3c515115d56..c8cd39b05fcc 100644 --- a/src/components/TextLink.tsx +++ b/src/components/TextLink.tsx @@ -1,5 +1,6 @@ import type {ForwardedRef, KeyboardEventHandler, MouseEventHandler} from 'react'; import React, {forwardRef} from 'react'; +// eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Text as RNText, StyleProp, TextStyle} from 'react-native'; import useEnvironment from '@hooks/useEnvironment'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx index 0df9993f8c69..21e19ac7c2e8 100644 --- a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx +++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx @@ -1,8 +1,9 @@ import Str from 'expensify-common/lib/str'; import React, {useCallback} from 'react'; -import {Text, View} from 'react-native'; +import {View} from 'react-native'; import Avatar from '@components/Avatar'; import {usePersonalDetails} from '@components/OnyxProvider'; +import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import type UserDetailsTooltipProps from '@components/UserDetailsTooltip/types'; import useLocalize from '@hooks/useLocalize'; diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx similarity index 71% rename from src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js rename to src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx index 54e7309ee48b..9f615cef525d 100755 --- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js +++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx @@ -1,7 +1,5 @@ -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Dimensions, View} from 'react-native'; -import _ from 'underscore'; import GoogleMeetIcon from '@assets/images/google-meet.svg'; import ZoomIcon from '@assets/images/zoom-icon.svg'; import Icon from '@components/Icon'; @@ -10,37 +8,34 @@ import MenuItem from '@components/MenuItem'; import Popover from '@components/Popover'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; +import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Link from '@userActions/Link'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; -import {defaultProps, propTypes as videoChatButtonAndMenuPropTypes} from './videoChatButtonAndMenuPropTypes'; +import type VideoChatButtonAndMenuProps from './types'; -const propTypes = { +type BaseVideoChatButtonAndMenuProps = VideoChatButtonAndMenuProps & { /** Link to open when user wants to create a new google meet meeting */ - googleMeetURL: PropTypes.string.isRequired, - - ...videoChatButtonAndMenuPropTypes, - ...withLocalizePropTypes, - ...windowDimensionsPropTypes, + googleMeetURL: string; }; -function BaseVideoChatButtonAndMenu(props) { +function BaseVideoChatButtonAndMenu({googleMeetURL, isConcierge = false, guideCalendarLink}: BaseVideoChatButtonAndMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); const [isVideoChatMenuActive, setIsVideoChatMenuActive] = useState(false); const [videoChatIconPosition, setVideoChatIconPosition] = useState({x: 0, y: 0}); - const videoChatIconWrapperRef = useRef(null); - const videoChatButtonRef = useRef(null); + const videoChatIconWrapperRef = useRef(null); + const videoChatButtonRef = useRef(null); const menuItemData = [ { icon: ZoomIcon, - text: props.translate('videoChatButtonAndMenu.zoom'), + text: translate('videoChatButtonAndMenu.zoom'), onPress: () => { setIsVideoChatMenuActive(false); Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL); @@ -48,10 +43,10 @@ function BaseVideoChatButtonAndMenu(props) { }, { icon: GoogleMeetIcon, - text: props.translate('videoChatButtonAndMenu.googleMeet'), + text: translate('videoChatButtonAndMenu.googleMeet'), onPress: () => { setIsVideoChatMenuActive(false); - Link.openExternalLink(props.googleMeetURL); + Link.openExternalLink(googleMeetURL); }, }, ]; @@ -87,22 +82,22 @@ function BaseVideoChatButtonAndMenu(props) { ref={videoChatIconWrapperRef} onLayout={measureVideoChatIconPosition} > - + { // Drop focus to avoid blue focus ring. - videoChatButtonRef.current.blur(); + videoChatButtonRef.current?.blur(); // If this is the Concierge chat, we'll open the modal for requesting a setup call instead - if (props.isConcierge && props.guideCalendarLink) { - Link.openExternalLink(props.guideCalendarLink); + if (isConcierge && guideCalendarLink) { + Link.openExternalLink(guideCalendarLink); return; } setIsVideoChatMenuActive((previousVal) => !previousVal); })} style={styles.touchableButtonImage} - accessibilityLabel={props.translate('videoChatButtonAndMenu.tooltip')} + accessibilityLabel={translate('videoChatButtonAndMenu.tooltip')} role={CONST.ROLE.BUTTON} > - - {_.map(menuItemData, ({icon, text, onPress}) => ( + + {menuItemData.map(({icon, text, onPress}) => ( void, config: KeyboardShortcutConfig | Record = {}) { +export default function useKeyboardShortcut(shortcut: Shortcut, callback: (e?: GestureResponderEvent | KeyboardEvent) => void, config: KeyboardShortcutConfig = {}) { const { captureOnInputs = true, shouldBubble = false, diff --git a/src/languages/en.ts b/src/languages/en.ts index b6fa37560536..b6da38df21a0 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3,7 +3,6 @@ import Str from 'expensify-common/lib/str'; import CONST from '@src/CONST'; import type { AddressLineParams, - AdminCanceledRequestParams, AlreadySignedInParams, AmountEachParams, ApprovedAmountParams, @@ -113,7 +112,6 @@ type AllCountries = Record; export default { common: { cancel: 'Cancel', - dismiss: 'Dismiss', yes: 'Yes', no: 'No', ok: 'OK', @@ -457,9 +455,10 @@ export default { deleteConfirmation: ({action}: DeleteConfirmationParams) => `Are you sure you want to delete this ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'request' : 'comment'}?`, onlyVisible: 'Only visible to', replyInThread: 'Reply in thread', - subscribeToThread: 'Subscribe to thread', - unsubscribeFromThread: 'Unsubscribe from thread', + joinThread: 'Join thread', + leaveThread: 'Leave thread', flagAsOffensive: 'Flag as offensive', + menu: 'Menu', }, emojiReactions: { addReactionTooltip: 'Add reaction', @@ -576,8 +575,6 @@ export default { requestMoney: 'Request money', sendMoney: 'Send money', pay: 'Pay', - cancelPayment: 'Cancel payment', - cancelPaymentConfirmation: 'Are you sure that you want to cancel this payment?', viewDetails: 'View details', pending: 'Pending', canceled: 'Canceled', @@ -615,7 +612,6 @@ export default { payerSettled: ({amount}: PayerSettledParams) => `paid ${amount}`, approvedAmount: ({amount}: ApprovedAmountParams) => `approved ${amount}`, waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up, payment is held until ${submitterDisplayName} adds a bank account`, - adminCanceledRequest: ({amount}: AdminCanceledRequestParams) => `The ${amount} payment has been cancelled by the admin.`, canceledRequest: ({amount, submitterDisplayName}: CanceledRequestParams) => `Canceled the ${amount} payment, because ${submitterDisplayName} did not enable their Expensify Wallet within 30 days`, settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) => @@ -1803,6 +1799,7 @@ export default { success: { title: 'Downloaded!', message: 'Attachment successfully downloaded!', + qrMessage: 'Check your photos or downloads folder for a copy of your QR code. Protip: Add it to a presentation for your audience to scan and connect with you directly.', }, generalError: { title: 'Attachment Error', @@ -2032,9 +2029,11 @@ export default { cardDamaged: 'My card was damaged', cardLostOrStolen: 'My card was lost or stolen', confirmAddressTitle: "Please confirm the address below is where you'd like us to send your new card.", - currentCardInfo: 'Your current card will be permanently deactivated as soon as your order is placed. Most cards arrive in a few business days.', + cardDamagedInfo: 'Your new card will arrive in 2-3 business days, and your existing card will continue to work until you activate your new one.', + cardLostOrStolenInfo: 'Your current card will be permanently deactivated as soon as your order is placed. Most cards arrive in a few business days.', address: 'Address', deactivateCardButton: 'Deactivate card', + shipNewCardButton: 'Ship new card', addressError: 'Address is required', reasonError: 'Reason is required', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 271f0787bfde..2478c8ba8bd2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2,7 +2,6 @@ import Str from 'expensify-common/lib/str'; import CONST from '@src/CONST'; import type { AddressLineParams, - AdminCanceledRequestParams, AlreadySignedInParams, AmountEachParams, ApprovedAmountParams, @@ -103,7 +102,6 @@ import type { export default { common: { cancel: 'Cancelar', - dismiss: 'Descartar', yes: 'Sí', no: 'No', ok: 'OK', @@ -449,9 +447,10 @@ export default { `¿Estás seguro de que quieres eliminar este ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'pedido' : 'comentario'}`, onlyVisible: 'Visible sólo para', replyInThread: 'Responder en el hilo', - subscribeToThread: 'Suscribirse al hilo', - unsubscribeFromThread: 'Darse de baja del hilo', + joinThread: 'Unirse al hilo', + leaveThread: 'Dejar hilo', flagAsOffensive: 'Marcar como ofensivo', + menu: 'Menú', }, emojiReactions: { addReactionTooltip: 'Añadir una reacción', @@ -569,8 +568,6 @@ export default { requestMoney: 'Pedir dinero', sendMoney: 'Enviar dinero', pay: 'Pagar', - cancelPayment: 'Cancelar el pago', - cancelPaymentConfirmation: '¿Estás seguro de que quieres cancelar este pago?', viewDetails: 'Ver detalles', pending: 'Pendiente', canceled: 'Canceló', @@ -608,7 +605,6 @@ export default { payerSettled: ({amount}: PayerSettledParams) => `pagó ${amount}`, approvedAmount: ({amount}: ApprovedAmountParams) => `aprobó ${amount}`, waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inicio el pago, pero no se procesará hasta que ${submitterDisplayName} añada una cuenta bancaria`, - adminCanceledRequest: ({amount}: AdminCanceledRequestParams) => `El pago de ${amount} ha sido cancelado por el administrador.`, canceledRequest: ({amount, submitterDisplayName}: CanceledRequestParams) => `Canceló el pago ${amount}, porque ${submitterDisplayName} no habilitó su billetera Expensify en un plazo de 30 días.`, settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) => @@ -1829,6 +1825,8 @@ export default { success: { title: '!Descargado!', message: 'Archivo descargado correctamente', + qrMessage: + 'Busca la copia de tu código QR en la carpeta de fotos o descargas. Consejo: Añádelo a una presentación para que el público pueda escanearlo y conectar contigo directamente.', }, generalError: { title: 'Error en la descarga', @@ -2519,9 +2517,11 @@ export default { cardDamaged: 'Mi tarjeta está dañada', cardLostOrStolen: 'He perdido o me han robado la tarjeta', confirmAddressTitle: 'Confirma que la dirección que aparece a continuación es a la que deseas que te enviemos tu nueva tarjeta.', - currentCardInfo: 'La tarjeta actual se desactivará permanentemente en cuanto se realice el pedido. La mayoría de las tarjetas llegan en unos pocos días laborables.', + cardDamagedInfo: 'La nueva tarjeta te llegará en 2-3 días laborables y la tarjeta actual seguirá funcionando hasta que actives la nueva.', + cardLostOrStolenInfo: 'La tarjeta actual se desactivará permanentemente en cuanto realices el pedido. La mayoría de las tarjetas llegan en pocos días laborables.', address: 'Dirección', deactivateCardButton: 'Desactivar tarjeta', + shipNewCardButton: 'Enviar tarjeta nueva', addressError: 'La dirección es obligatoria', reasonError: 'Se requiere justificación', }, diff --git a/src/languages/types.ts b/src/languages/types.ts index 35a5110abf79..3185b7a8f6f1 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -135,8 +135,6 @@ type WaitingOnBankAccountParams = {submitterDisplayName: string}; type CanceledRequestParams = {amount: string; submitterDisplayName: string}; -type AdminCanceledRequestParams = {amount: string}; - type SettledAfterAddedBankAccountParams = {submitterDisplayName: string; amount: string}; type PaidElsewhereWithAmountParams = {payer?: string; amount: string}; @@ -290,7 +288,6 @@ type TranslationFlatObject = { }; export type { - AdminCanceledRequestParams, ApprovedAmountParams, AddressLineParams, AlreadySignedInParams, diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 790a9b1e37cd..c92e9bfd3f67 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -7,8 +7,8 @@ import * as CurrencyUtils from './CurrencyUtils'; import * as PolicyUtils from './PolicyUtils'; type DefaultMileageRate = { - rate: number; - currency: string; + rate?: number; + currency?: string; unit: Unit; }; diff --git a/src/libs/HeaderUtils.ts b/src/libs/HeaderUtils.ts index a1822aca00f4..4d58d74169e8 100644 --- a/src/libs/HeaderUtils.ts +++ b/src/libs/HeaderUtils.ts @@ -1,22 +1,11 @@ -import type {OnyxEntry} from 'react-native-onyx'; +import type {ThreeDotsMenuItem} from '@components/HeaderWithBackButton/types'; import * as Expensicons from '@components/Icon/Expensicons'; import type OnyxReport from '@src/types/onyx/Report'; -import type IconAsset from '@src/types/utils/IconAsset'; import * as Report from './actions/Report'; import * as Session from './actions/Session'; import * as Localize from './Localize'; -type MenuItem = { - icon: string | IconAsset; - text: string; - onSelected: () => void; -}; - -function getPinMenuItem(report: OnyxEntry): MenuItem | undefined { - if (!report) { - return; - } - +function getPinMenuItem(report: OnyxReport): ThreeDotsMenuItem { const isPinned = !!report.isPinned; return { diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 37b7a9424fee..0be73ce2735e 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -420,7 +420,7 @@ function getLastMessageTextForReport(report) { } else if (ReportActionUtils.isReimbursementQueuedAction(lastReportAction)) { lastMessageTextFromReport = ReportUtils.getReimbursementQueuedActionMessage(lastReportAction, report); } else if (ReportActionUtils.isReimbursementDeQueuedAction(lastReportAction)) { - lastMessageTextFromReport = ReportUtils.getReimbursementDeQueuedActionMessage(lastReportAction, report); + lastMessageTextFromReport = ReportUtils.getReimbursementDeQueuedActionMessage(report); } else if (ReportActionUtils.isDeletedParentAction(lastReportAction) && ReportUtils.isChatReport(report)) { lastMessageTextFromReport = ReportUtils.getDeletedParentActionMessageForChatReport(lastReportAction); } else if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml, translationKey: report.lastMessageTranslationKey})) { @@ -436,10 +436,9 @@ function getLastMessageTextForReport(report) { lastMessageTextFromReport = lodashGet(lastReportAction, 'message[0].text', ''); } else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) { lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction); - } else { - lastMessageTextFromReport = report ? report.lastMessageText || '' : ''; } - return lastMessageTextFromReport; + + return lastMessageTextFromReport || lodashGet(report, 'lastMessageText', ''); } /** diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 3346094adeec..78cde95fb0e4 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -1,8 +1,9 @@ -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; +import type {OnyxData} from '@src/types/onyx/Request'; import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; import * as UserUtils from './UserUtils'; @@ -91,16 +92,15 @@ function getLoginsByAccountIDs(accountIDs: number[]): string[] { * @param accountIDs Array of user accountIDs * @returns Object with optimisticData, successData and failureData (object of personal details objects) */ -function getNewPersonalDetailsOnyxData(logins: string[], accountIDs: number[]) { - const optimisticData: PersonalDetailsList = {}; - const successData: PersonalDetailsList = {}; - const failureData: PersonalDetailsList = {}; +function getNewPersonalDetailsOnyxData(logins: string[], accountIDs: number[]): Required> { + const personalDetailsNew: PersonalDetailsList = {}; + const personalDetailsCleanup: PersonalDetailsList = {}; logins.forEach((login, index) => { const accountID = accountIDs[index]; if (allPersonalDetails && Object.keys(allPersonalDetails?.[accountID] ?? {}).length === 0) { - optimisticData[accountID] = { + personalDetailsNew[accountID] = { login, accountID, avatar: UserUtils.getDefaultAvatarURL(accountID), @@ -111,32 +111,29 @@ function getNewPersonalDetailsOnyxData(logins: string[], accountIDs: number[]) { * Cleanup the optimistic user to ensure it does not permanently persist. * This is done to prevent duplicate entries (upon success) since the BE will return other personal details with the correct account IDs. */ - successData[accountID] = null; + personalDetailsCleanup[accountID] = null; } }); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: personalDetailsNew, + }, + ]; + + const finallyData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: personalDetailsCleanup, + }, + ]; + return { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: optimisticData, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: successData, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: failureData, - }, - ], + optimisticData, + finallyData, }; } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index ee4fa201ee2f..f967cb244268 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -6,9 +6,9 @@ import OnyxUtils from 'react-native-onyx/lib/utils'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ActionName, ChangeLog} from '@src/types/onyx/OriginalMessage'; +import type {ActionName, ChangeLog, OriginalMessageReimbursementDequeued} from '@src/types/onyx/OriginalMessage'; import type Report from '@src/types/onyx/Report'; -import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; +import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -136,7 +136,7 @@ function isInviteMemberAction(reportAction: OnyxEntry) { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM; } -function isReimbursementDeQueuedAction(reportAction: OnyxEntry): boolean { +function isReimbursementDeQueuedAction(reportAction: OnyxEntry): reportAction is ReportActionBase & OriginalMessageReimbursementDequeued { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTDEQUEUED; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 561934273ae8..6422de347b17 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -16,7 +16,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, PolicyReportField, Report, ReportAction, ReportMetadata, Session, Transaction} from '@src/types/onyx'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {IOUMessage, OriginalMessageActionName, OriginalMessageCreated, ReimbursementDeQueuedMessage} from '@src/types/onyx/OriginalMessage'; +import type {IOUMessage, OriginalMessageActionName, OriginalMessageCreated} from '@src/types/onyx/OriginalMessage'; import type {Status} from '@src/types/onyx/PersonalDetails'; import type {NotificationPreference} from '@src/types/onyx/Report'; import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; @@ -179,11 +179,6 @@ type OptimisticSubmittedReportAction = Pick< 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachment' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' >; -type OptimisticCancelPaymentReportAction = Pick< - ReportAction, - 'actionName' | 'actorAccountID' | 'message' | 'originalMessage' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' ->; - type OptimisticEditedTaskReportAction = Pick< ReportAction, 'reportActionID' | 'actionName' | 'pendingAction' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'shouldShow' | 'message' | 'person' @@ -535,7 +530,7 @@ function getReportParticipantsTitle(accountIDs: number[]): string { /** * Checks if a report is a chat report. */ -function isChatReport(report: OnyxEntry): boolean { +function isChatReport(report: OnyxEntry | EmptyObject): boolean { return report?.type === CONST.REPORT.TYPE.CHAT; } @@ -1609,13 +1604,9 @@ function getReimbursementQueuedActionMessage(reportAction: OnyxEntry, report: OnyxEntry): string { - const amount = CurrencyUtils.convertToDisplayString(Math.abs(report?.total ?? 0), report?.currency); - const originalMessage = reportAction?.originalMessage as ReimbursementDeQueuedMessage | undefined; - if (originalMessage?.cancellationReason === CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN) { - return Localize.translateLocal('iou.adminCanceledRequest', {amount}); - } +function getReimbursementDeQueuedActionMessage(report: OnyxEntry): string { const submitterDisplayName = getDisplayNameForParticipant(report?.ownerAccountID, true) ?? ''; + const amount = CurrencyUtils.convertToDisplayString(report?.total ?? 0, report?.currency); return Localize.translateLocal('iou.canceledRequest', {submitterDisplayName, amount}); } @@ -1664,9 +1655,9 @@ function isUnreadWithMention(reportOrOption: OnyxEntry | OptionData): bo /** * Determines if the option requires action from the current user. This can happen when it: - - is unread and the user was mentioned in one of the unread comments - - is for an outstanding task waiting on the user - - has an outstanding child money request that is waiting for an action from the current user (e.g. pay, approve, add bank account) + - is unread and the user was mentioned in one of the unread comments + - is for an outstanding task waiting on the user + - has an outstanding child money request that is waiting for an action from the current user (e.g. pay, approve, add bank account) * * @param option (report or optionItem) * @param parentReportAction (the report action the current report is a thread of) @@ -2895,40 +2886,6 @@ function buildOptimisticSubmittedReportAction(amount: number, currency: string, }; } -/** - * Builds an optimistic REIMBURSEMENTDEQUEUED report action with a randomly generated reportActionID. - * - */ -function buildOptimisticCancelPaymentReportAction(expenseReportID: string): OptimisticCancelPaymentReportAction { - return { - actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTDEQUEUED, - actorAccountID: currentUserAccountID, - message: [ - { - cancellationReason: CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN, - expenseReportID, - type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - text: '', - }, - ], - originalMessage: { - cancellationReason: CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN, - expenseReportID, - }, - person: [ - { - style: 'strong', - text: currentUserPersonalDetails?.displayName ?? currentUserEmail, - type: 'TEXT', - }, - ], - reportActionID: NumberUtils.rand64(), - shouldShow: true, - created: DateUtils.getDBTime(), - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }; -} - /** * Builds an optimistic report preview action with a randomly generated reportActionID. * @@ -3611,7 +3568,7 @@ function getAllPolicyReports(policyID: string): Array> { /** * Returns true if Chronos is one of the chat participants (1:1) */ -function chatIncludesChronos(report: OnyxEntry): boolean { +function chatIncludesChronos(report: OnyxEntry | EmptyObject): boolean { return Boolean(report?.participantAccountIDs?.includes(CONST.ACCOUNT_ID.CHRONOS)); } @@ -3983,15 +3940,26 @@ function getAddWorkspaceRoomOrChatReportErrors(report: OnyxEntry): Recor return report?.errorFields?.addWorkspaceRoom ?? report?.errorFields?.createChat; } +/** + * Return true if the Money Request report is marked for deletion. + */ +function isMoneyRequestReportPendingDeletion(report: OnyxEntry): boolean { + if (!isMoneyRequestReport(report)) { + return false; + } + + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); + return parentReportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + 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; - } + if (isMoneyRequestReportPendingDeletion(report)) { + return false; } + return !isArchivedRoom(report) && isEmptyObject(reportErrors) && report && isAllowedToComment(report) && !isAnonymousUser; } @@ -4251,22 +4219,17 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry) const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency) ?? ''; const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport?.managerID, true); - // If the payment was cancelled, show the "Owes" message - if (!isSettled(IOUReportID)) { - translationKey = 'iou.payerOwesAmount'; - } else { - switch (originalMessage.paymentType) { - case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: - translationKey = 'iou.paidElsewhereWithAmount'; - break; - case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: - case CONST.IOU.PAYMENT_TYPE.VBBA: - translationKey = 'iou.paidWithExpensifyWithAmount'; - break; - default: - translationKey = 'iou.payerPaidAmount'; - break; - } + switch (originalMessage.paymentType) { + case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: + translationKey = 'iou.paidElsewhereWithAmount'; + break; + case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: + case CONST.IOU.PAYMENT_TYPE.VBBA: + translationKey = 'iou.paidWithExpensifyWithAmount'; + break; + default: + translationKey = 'iou.payerPaidAmount'; + break; } return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: payerName ?? ''}); } @@ -4404,9 +4367,9 @@ function getReportFieldTitle(report: OnyxEntry, reportField: PolicyRepor /** * Checks if thread replies should be displayed */ -function shouldDisplayThreadReplies(reportAction: ReportAction, reportID: string): boolean { - const hasReplies = (reportAction.childVisibleActionCount ?? 0) > 0; - return hasReplies && !!reportAction.childCommenterCount && !isThreadFirstChat(reportAction, reportID); +function shouldDisplayThreadReplies(reportAction: OnyxEntry, reportID: string): boolean { + const hasReplies = (reportAction?.childVisibleActionCount ?? 0) > 0; + return hasReplies && !!reportAction?.childCommenterCount && !isThreadFirstChat(reportAction, reportID); } /** @@ -4418,7 +4381,7 @@ function shouldDisplayThreadReplies(reportAction: ReportAction, reportID: string * - The action is a whisper action and it's neither a report preview nor IOU action * - The action is the thread's first chat */ -function shouldDisableThread(reportAction: ReportAction, reportID: string) { +function shouldDisableThread(reportAction: OnyxEntry, reportID: string) { const isSplitBillAction = ReportActionsUtils.isSplitBillAction(reportAction); const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); @@ -4426,9 +4389,9 @@ function shouldDisableThread(reportAction: ReportAction, reportID: string) { const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction); return ( - CONST.REPORT.ACTIONS.THREAD_DISABLED.some((action: string) => action === reportAction.actionName) || + CONST.REPORT.ACTIONS.THREAD_DISABLED.some((action: string) => action === reportAction?.actionName) || isSplitBillAction || - (isDeletedAction && !reportAction.childVisibleActionCount) || + (isDeletedAction && !reportAction?.childVisibleActionCount) || (isWhisperAction && !isReportPreviewAction && !isIOUAction) || isThreadFirstChat(reportAction, reportID) ); @@ -4512,7 +4475,6 @@ export { buildOptimisticIOUReportAction, buildOptimisticReportPreview, buildOptimisticModifiedExpenseReportAction, - buildOptimisticCancelPaymentReportAction, updateReportPreview, buildOptimisticTaskReportAction, buildOptimisticAddCommentReportAction, @@ -4567,6 +4529,7 @@ export { getParentReport, getRootParentReport, getReportPreviewMessage, + isMoneyRequestReportPendingDeletion, canUserPerformWriteAction, getOriginalReportID, canAccessReport, @@ -4615,4 +4578,4 @@ export { getChildReportNotificationPreference, }; -export type {ExpenseOriginalMessage, OptionData, OptimisticChatReport, OptimisticCreatedReportAction}; +export type {ExpenseOriginalMessage, OptionData, OptimisticChatReport, OptimisticClosedReportAction, OptimisticCreatedReportAction}; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index c34a6753c1d5..ba856a2b1afb 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -147,6 +147,13 @@ function isMerchantMissing(transaction: Transaction) { return isMerchantEmpty && isModifiedMerchantEmpty; } +/** + * Check if the merchant is partial i.e. `(none)` + */ +function isPartialMerchant(merchant: string): boolean { + return merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; +} + function isAmountMissing(transaction: Transaction) { return transaction.amount === 0 && (!transaction.modifiedAmount || transaction.modifiedAmount === 0); } @@ -573,6 +580,7 @@ export { getWaypoints, isAmountMissing, isMerchantMissing, + isPartialMerchant, isCreatedMissing, areRequiredFieldsEmpty, hasMissingSmartscanFields, diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index f7b7ec89c670..1ce6bc38191f 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -50,7 +50,7 @@ function setPlaidEvent(eventName: string) { /** * Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished. */ -function openPersonalBankAccountSetupView(exitReportID: string) { +function openPersonalBankAccountSetupView(exitReportID?: string) { clearPlaid().then(() => { if (exitReportID) { Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID}); diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 172b0ac73ca6..aa892d3817aa 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -52,10 +52,10 @@ function reportVirtualExpensifyCardFraud(cardID: number) { /** * 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 cardID - id of the card that is going to be replaced * @param reason - reason for replacement */ -function requestReplacementExpensifyCard(cardId: number, reason: ReplacementReason) { +function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReason) { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -88,12 +88,12 @@ function requestReplacementExpensifyCard(cardId: number, reason: ReplacementReas ]; type RequestReplacementExpensifyCardParams = { - cardId: number; + cardID: number; reason: string; }; const parameters: RequestReplacementExpensifyCardParams = { - cardId, + cardID, reason, }; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 49b6a3b445e1..3e2ffc875155 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -2491,7 +2491,7 @@ function updateMoneyRequestAmountAndCurrency(transactionID, transactionThreadRep } /** - * @param {String} transactionID + * @param {String | undefined} transactionID * @param {Object} reportAction - the money request reportAction we are deleting * @param {Boolean} isSingleTransactionView */ @@ -2713,6 +2713,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView value: { [reportAction.reportActionID]: { ...reportAction, + pendingAction: null, errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericDeleteFailureMessage'), }, }, @@ -3367,108 +3368,6 @@ function submitReport(expenseReport) { ); } -/** - * @param {Object} expenseReport - * @param {Object} chatReport - */ -function cancelPayment(expenseReport, chatReport) { - const optimisticReportAction = ReportUtils.buildOptimisticCancelPaymentReportAction(expenseReport.reportID); - const policy = ReportUtils.getPolicy(chatReport.policyID); - const isFree = policy && policy.type === CONST.POLICY.TYPE.FREE; - const optimisticData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticReportAction.reportActionID]: { - ...optimisticReportAction, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - value: { - ...expenseReport, - lastMessageText: lodashGet(optimisticReportAction, 'message.0.text', ''), - lastMessageHtml: lodashGet(optimisticReportAction, 'message.0.html', ''), - stateNum: isFree ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.OPEN, - statusNum: isFree ? CONST.REPORT.STATUS_NUM.SUBMITTED : CONST.REPORT.STATUS_NUM.OPEN, - }, - }, - ...(chatReport.reportID - ? [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, - value: { - ...chatReport, - hasOutstandingIOU: true, - hasOutstandingChildRequest: true, - iouReportID: expenseReport.reportID, - }, - }, - ] - : []), - ]; - - const successData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticReportAction.reportActionID]: { - pendingAction: null, - }, - }, - }, - ]; - - const failureData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [expenseReport.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - value: { - statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, - }, - }, - ...(chatReport.reportID - ? [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, - value: { - hasOutstandingIOU: false, - hasOutstandingChildRequest: false, - iouReportID: 0, - }, - }, - ] - : []), - ]; - - API.write( - 'CancelPayment', - { - iouReportID: expenseReport.reportID, - chatReportID: chatReport.reportID, - managerAccountID: expenseReport.managerID, - reportActionID: optimisticReportAction.reportActionID, - }, - {optimisticData, successData, failureData}, - ); -} - /** * @param {String} paymentType * @param {Object} chatReport @@ -3799,5 +3698,4 @@ export { detachReceipt, getIOUReportID, editMoneyRequest, - cancelPayment, }; diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index a7ae54f46416..7e91c3531b3a 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -1,8 +1,11 @@ import {createRef} from 'react'; +import type {MutableRefObject, SyntheticEvent} from 'react'; +import type {NativeTouchEvent} from 'react-native'; import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx/lib/types'; import type {ValueOf} from 'type-fest'; +import type {TransferMethod} from '@components/KYCWall/types'; import * as API from '@libs/API'; import * as CardUtils from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -14,13 +17,13 @@ import type PaymentMethod from '@src/types/onyx/PaymentMethod'; import type {FilterMethodPaymentType} from '@src/types/onyx/WalletTransfer'; type KYCWallRef = { - continueAction?: () => void; + continueAction?: (event?: SyntheticEvent, iouPaymentType?: TransferMethod) => void; }; /** * Sets up a ref to an instance of the KYC Wall component. */ -const kycWallRef = createRef(); +const kycWallRef: MutableRefObject = createRef(); /** * When we successfully add a payment method or pass the KYC checks we will continue with our setup action if we have one set. diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.ts similarity index 57% rename from src/libs/actions/Policy.js rename to src/libs/actions/Policy.ts index 25c8cf5ade80..263d5fb68529 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.ts @@ -2,11 +2,11 @@ import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; import {escapeRegExp} from 'lodash'; -import filter from 'lodash/filter'; -import lodashGet from 'lodash/get'; +import lodashClone from 'lodash/clone'; import lodashUnion from 'lodash/union'; +import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import _ from 'underscore'; +import type {NullishDeep, OnyxEntry} from 'react-native-onyx/lib/types'; import * as API from '@libs/API'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -19,8 +19,41 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy, PolicyMember, PolicyTags, RecentlyUsedCategories, RecentlyUsedTags, ReimbursementAccount, Report, ReportAction, Transaction} from '@src/types/onyx'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {CustomUnit} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type AnnounceRoomMembersOnyxData = { + onyxOptimisticData: OnyxUpdate[]; + onyxFailureData: OnyxUpdate[]; +}; + +type ReportCreationData = Record< + string, + { + reportID: string; + reportActionID?: string; + } +>; + +type WorkspaceMembersChats = { + onyxSuccessData: OnyxUpdate[]; + onyxOptimisticData: OnyxUpdate[]; + onyxFailureData: OnyxUpdate[]; + reportCreationData: ReportCreationData; +}; + +type OptimisticCustomUnits = { + customUnits: Record; + customUnitID: string; + customUnitRateID: string; + outputCurrency: string; +}; + +type PoliciesRecord = Record>; -const allPolicies = {}; +const allPolicies: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, callback: (val, key) => { @@ -33,9 +66,13 @@ Onyx.connect({ // More info: https://github.com/Expensify/App/issues/14260 const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); const policyReports = ReportUtils.getAllPolicyReports(policyID); - const cleanUpMergeQueries = {}; - const cleanUpSetQueries = {}; - _.each(policyReports, ({reportID}) => { + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; cleanUpMergeQueries[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = {hasDraft: false}; cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; @@ -50,14 +87,16 @@ Onyx.connect({ }, }); -let allPolicyMembers; +let allPolicyMembers: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_MEMBERS, waitForCollectionCallback: true, - callback: (val) => (allPolicyMembers = val), + callback: (val) => { + allPolicyMembers = val; + }, }); -let lastAccessedWorkspacePolicyID = null; +let lastAccessedWorkspacePolicyID: OnyxEntry = null; Onyx.connect({ key: ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, callback: (value) => (lastAccessedWorkspacePolicyID = value), @@ -68,31 +107,31 @@ let sessionAccountID = 0; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { - sessionEmail = lodashGet(val, 'email', ''); - sessionAccountID = lodashGet(val, 'accountID', 0); + sessionEmail = val?.email ?? ''; + sessionAccountID = val?.accountID ?? -1; }, }); -let allPersonalDetails; +let allPersonalDetails: OnyxEntry; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (val) => (allPersonalDetails = val), }); -let reimbursementAccount; +let reimbursementAccount: OnyxEntry; Onyx.connect({ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, callback: (val) => (reimbursementAccount = val), }); -let allRecentlyUsedCategories = {}; +let allRecentlyUsedCategories: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, waitForCollectionCallback: true, callback: (val) => (allRecentlyUsedCategories = val), }); -let allPolicyTags = {}; +let allPolicyTags: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_TAGS, waitForCollectionCallback: true, @@ -106,7 +145,7 @@ Onyx.connect({ }, }); -let allRecentlyUsedTags = {}; +let allRecentlyUsedTags: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS, waitForCollectionCallback: true, @@ -115,34 +154,30 @@ Onyx.connect({ /** * Stores in Onyx the policy ID of the last workspace that was accessed by the user - * @param {String|null} policyID */ -function updateLastAccessedWorkspace(policyID) { +function updateLastAccessedWorkspace(policyID: OnyxEntry) { Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); } /** * Check if the user has any active free policies (aka workspaces) - * - * @param {Array} policies - * @returns {Boolean} */ -function hasActiveFreePolicy(policies) { - const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); +function hasActiveFreePolicy(policies: Array> | PoliciesRecord): boolean { + const adminFreePolicies = Object.values(policies).filter((policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); if (adminFreePolicies.length === 0) { return false; } - if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { + if (adminFreePolicies.some((policy) => !policy?.pendingAction)) { return true; } - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { + if (adminFreePolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { return true; } - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { + if (adminFreePolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { return false; } @@ -153,14 +188,14 @@ function hasActiveFreePolicy(policies) { /** * Delete the workspace - * - * @param {String} policyID - * @param {Array} reports - * @param {String} policyName */ -function deleteWorkspace(policyID, reports, policyName) { - const filteredPolicies = filter(allPolicies, (policy) => policy.id !== policyID); - const optimisticData = [ +function deleteWorkspace(policyID: string, reports: Report[], policyName: string) { + if (!allPolicies) { + return; + } + + const filteredPolicies = Object.values(allPolicies).filter((policy): policy is Policy => policy?.id !== policyID); + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -169,40 +204,6 @@ function deleteWorkspace(policyID, reports, policyName) { errors: null, }, }, - ..._.map(reports, ({reportID}) => ({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: { - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - hasDraft: false, - oldPolicyName: allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`].name, - }, - })), - - ..._.map(reports, ({reportID}) => ({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, - value: null, - })), - - // Add closed actions to all chat reports linked to this policy - ..._.map(reports, ({reportID, ownerAccountID}) => { - // Announce & admin chats have FAKE owners, but workspace chats w/ users do have owners. - let emailClosingReport = CONST.POLICY.OWNER_EMAIL_FAKE; - if (ownerAccountID !== CONST.POLICY.OWNER_ACCOUNT_ID_FAKE) { - emailClosingReport = lodashGet(allPersonalDetails, [ownerAccountID, 'login'], ''); - } - const optimisticClosedReportAction = ReportUtils.buildOptimisticClosedReportAction(emailClosingReport, policyName, CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED); - const optimisticReportActions = {}; - optimisticReportActions[optimisticClosedReportAction.reportActionID] = optimisticClosedReportAction; - return { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: optimisticReportActions, - }; - }), - ...(!hasActiveFreePolicy(filteredPolicies) ? [ { @@ -216,31 +217,71 @@ function deleteWorkspace(policyID, reports, policyName) { : []), ]; - // Restore the old report stateNum and statusNum - const failureData = [ - ..._.map(reports, ({reportID, stateNum, statusNum, hasDraft, oldPolicyName}) => ({ + reports.forEach(({reportID, ownerAccountID}) => { + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - stateNum, - statusNum, - hasDraft, - oldPolicyName, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + hasDraft: false, + oldPolicyName: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name ?? '', }, - })), + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, + value: null, + }); + + // Add closed actions to all chat reports linked to this policy + // Announce & admin chats have FAKE owners, but workspace chats w/ users do have owners. + let emailClosingReport: string = CONST.POLICY.OWNER_EMAIL_FAKE; + if (!!ownerAccountID && ownerAccountID !== CONST.POLICY.OWNER_ACCOUNT_ID_FAKE) { + emailClosingReport = allPersonalDetails?.[ownerAccountID]?.login ?? ''; + } + const optimisticClosedReportAction = ReportUtils.buildOptimisticClosedReportAction(emailClosingReport, policyName, CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED); + const optimisticReportActions: Record = {}; + optimisticReportActions[optimisticClosedReportAction.reportActionID] = optimisticClosedReportAction as ReportAction; + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: optimisticReportActions, + }); + }); + + // Restore the old report stateNum and statusNum + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, value: { - errors: lodashGet(reimbursementAccount, 'errors', null), + errors: reimbursementAccount?.errors ?? null, }, }, ]; - // We don't need success data since the push notification will update - // the onyxData for all connected clients. - const successData = []; - API.write('DeleteWorkspace', {policyID}, {optimisticData, successData, failureData}); + reports.forEach(({reportID, stateNum, statusNum, hasDraft, oldPolicyName}) => { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + stateNum, + statusNum, + hasDraft, + oldPolicyName, + }, + }); + }); + + type DeleteWorkspaceParams = { + policyID: string; + }; + + const params: DeleteWorkspaceParams = {policyID}; + + API.write('DeleteWorkspace', params, {optimisticData, failureData}); // Reset the lastAccessedWorkspacePolicyID if (policyID === lastAccessedWorkspacePolicyID) { @@ -250,23 +291,17 @@ function deleteWorkspace(policyID, reports, policyName) { /** * Is the user an admin of a free policy (aka workspace)? - * - * @param {Record} [policies] - * @returns {Boolean} */ -function isAdminOfFreePolicy(policies) { - return _.some(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); +function isAdminOfFreePolicy(policies?: PoliciesRecord): boolean { + return Object.values(policies ?? {}).some((policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); } /** - * Build optimistic data for adding members to the announce room - * @param {String} policyID - * @param {Array} accountIDs - * @returns {Object} + * Build optimistic data for adding members to the announcement room */ -function buildAnnounceRoomMembersOnyxData(policyID, accountIDs) { +function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]): AnnounceRoomMembersOnyxData { const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); - const announceRoomMembers = { + const announceRoomMembers: AnnounceRoomMembersOnyxData = { onyxOptimisticData: [], onyxFailureData: [], }; @@ -275,38 +310,37 @@ function buildAnnounceRoomMembersOnyxData(policyID, accountIDs) { return announceRoomMembers; } - // Everyone in special policy rooms is visible - const participantAccountIDs = [...announceReport.participantAccountIDs, ...accountIDs]; + if (announceReport?.participantAccountIDs) { + // Everyone in special policy rooms is visible + const participantAccountIDs = [...announceReport.participantAccountIDs, ...accountIDs]; - announceRoomMembers.onyxOptimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, - value: { - participantAccountIDs, - visibleChatMemberAccountIDs: participantAccountIDs, - }, - }); + announceRoomMembers.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`, + value: { + participantAccountIDs, + visibleChatMemberAccountIDs: participantAccountIDs, + }, + }); + } announceRoomMembers.onyxFailureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`, value: { - participantAccountIDs: announceReport.participantAccountIDs, - visibleChatMemberAccountIDs: announceReport.visibleChatMemberAccountIDs, + participantAccountIDs: announceReport?.participantAccountIDs, + visibleChatMemberAccountIDs: announceReport?.visibleChatMemberAccountIDs, }, }); return announceRoomMembers; } /** - * Build optimistic data for removing users from the announce room - * @param {String} policyID - * @param {Array} accountIDs - * @returns {Object} + * Build optimistic data for removing users from the announcement room */ -function removeOptimisticAnnounceRoomMembers(policyID, accountIDs) { +function removeOptimisticAnnounceRoomMembers(policyID: string, accountIDs: number[]): AnnounceRoomMembersOnyxData { const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); - const announceRoomMembers = { + const announceRoomMembers: AnnounceRoomMembersOnyxData = { onyxOptimisticData: [], onyxFailureData: [], }; @@ -315,87 +349,103 @@ function removeOptimisticAnnounceRoomMembers(policyID, accountIDs) { return announceRoomMembers; } - const remainUsers = _.difference(announceReport.participantAccountIDs, accountIDs); - announceRoomMembers.onyxOptimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, - value: { - participantAccountIDs: [...remainUsers], - visibleChatMemberAccountIDs: [...remainUsers], - }, - }); + if (announceReport?.participantAccountIDs) { + const remainUsers = announceReport.participantAccountIDs.filter((e) => !accountIDs.includes(e)); + announceRoomMembers.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: [...remainUsers], + visibleChatMemberAccountIDs: [...remainUsers], + }, + }); + + announceRoomMembers.onyxFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: announceReport.participantAccountIDs, + visibleChatMemberAccountIDs: announceReport.visibleChatMemberAccountIDs, + }, + }); + } - announceRoomMembers.onyxFailureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, - value: { - participantAccountIDs: announceReport.participantAccountIDs, - visibleChatMemberAccountIDs: announceReport.visibleChatMemberAccountIDs, - }, - }); return announceRoomMembers; } /** * Remove the passed members from the policy employeeList - * - * @param {Array} accountIDs - * @param {String} policyID */ -function removeMembers(accountIDs, policyID) { +function removeMembers(accountIDs: number[], policyID: string) { // In case user selects only themselves (admin), their email will be filtered out and the members // array passed will be empty, prevent the function from proceeding in that case as there is no one to remove if (accountIDs.length === 0) { return; } - const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`; + const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}` as const; const policy = ReportUtils.getPolicy(policyID); const workspaceChats = ReportUtils.getWorkspaceChats(policyID, accountIDs); - const optimisticClosedReportActions = _.map(workspaceChats, () => - ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy.name, CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY), - ); + const optimisticClosedReportActions = workspaceChats.map(() => ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy.name, CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY)); const announceRoomMembers = removeOptimisticAnnounceRoomMembers(policyID, accountIDs); - const optimisticData = [ + const optimisticMembersState: OnyxCollection = {}; + const successMembersState: OnyxCollection = {}; + const failureMembersState: OnyxCollection = {}; + accountIDs.forEach((accountID) => { + optimisticMembersState[accountID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + successMembersState[accountID] = null; + failureMembersState[accountID] = {errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericRemove')}; + }); + + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, - value: _.object(accountIDs, Array(accountIDs.length).fill({pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})), + value: optimisticMembersState, }, - ..._.map(workspaceChats, (report) => ({ + ...announceRoomMembers.onyxOptimisticData, + ]; + + workspaceChats.forEach((report) => { + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`, value: { statusNum: CONST.REPORT.STATUS_NUM.CLOSED, stateNum: CONST.REPORT.STATE_NUM.APPROVED, oldPolicyName: policy.name, hasDraft: false, }, - })), - ..._.map(optimisticClosedReportActions, (reportAction, index) => ({ + }); + }); + optimisticClosedReportActions.forEach((reportAction, index) => { + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`, - value: {[reportAction.reportActionID]: reportAction}, - })), - ...announceRoomMembers.onyxOptimisticData, - ]; + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`, + value: {[reportAction.reportActionID]: reportAction as ReportAction}, + }); + }); // If the policy has primaryLoginsInvited, then it displays informative messages on the members page about which primary logins were added by secondary logins. // If we delete all these logins then we should clear the informative messages since they are no longer relevant. - if (!_.isEmpty(policy.primaryLoginsInvited)) { + if (!isEmptyObject(policy?.primaryLoginsInvited ?? {})) { // Take the current policy members and remove them optimistically - const policyMemberAccountIDs = _.map(allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`], (value, key) => Number(key)); - const remainingMemberAccountIDs = _.difference(policyMemberAccountIDs, accountIDs); - const remainingLogins = PersonalDetailsUtils.getLoginsByAccountIDs(remainingMemberAccountIDs); - const invitedPrimaryToSecondaryLogins = _.invert(policy.primaryLoginsInvited); + const policyMemberAccountIDs = Object.keys(allPolicyMembers?.[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`] ?? {}).map((accountID) => Number(accountID)); + const remainingMemberAccountIDs = policyMemberAccountIDs.filter((accountID) => !accountIDs.includes(accountID)); + const remainingLogins: string[] = PersonalDetailsUtils.getLoginsByAccountIDs(remainingMemberAccountIDs); + const invitedPrimaryToSecondaryLogins: Record = {}; + + if (policy.primaryLoginsInvited) { + Object.keys(policy.primaryLoginsInvited).forEach((key) => (invitedPrimaryToSecondaryLogins[policy.primaryLoginsInvited?.[key] ?? ''] = key)); + } // Then, if no remaining members exist that were invited by a secondary login, clear the informative messages - if (!_.some(remainingLogins, (remainingLogin) => Boolean(invitedPrimaryToSecondaryLogins[remainingLogin]))) { + if (!remainingLogins.some((remainingLogin) => !!invitedPrimaryToSecondaryLogins[remainingLogin])) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + key: membersListKey, value: { primaryLoginsInvited: null, }, @@ -403,20 +453,26 @@ function removeMembers(accountIDs, policyID) { } } - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, - value: _.object(accountIDs, Array(accountIDs.length).fill(null)), + value: successMembersState, }, ]; - const failureData = [ + + const filteredWorkspaceChats = workspaceChats.filter((report): report is Report => report !== null); + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, - value: _.object(accountIDs, Array(accountIDs.length).fill({errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericRemove')})), + value: failureMembersState, }, - ..._.map(workspaceChats, ({reportID, stateNum, statusNum, hasDraft, oldPolicyName = null}) => ({ + ...announceRoomMembers.onyxFailureData, + ]; + + filteredWorkspaceChats.forEach(({reportID, stateNum, statusNum, hasDraft, oldPolicyName = null}) => { + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { @@ -425,41 +481,44 @@ function removeMembers(accountIDs, policyID) { hasDraft, oldPolicyName, }, - })), - ..._.map(optimisticClosedReportActions, (reportAction, index) => ({ + }); + }); + optimisticClosedReportActions.forEach((reportAction, index) => { + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`, value: {[reportAction.reportActionID]: null}, - })), - ...announceRoomMembers.onyxFailureData, - ]; - API.write( - 'DeleteMembersFromWorkspace', - { - emailList: _.map(accountIDs, (accountID) => allPersonalDetails[accountID].login).join(','), - policyID, - }, - {optimisticData, successData, failureData}, - ); + }); + }); + + type DeleteMembersFromWorkspaceParams = { + emailList: string; + policyID: string; + }; + + const params: DeleteMembersFromWorkspaceParams = { + emailList: accountIDs.map((accountID) => allPersonalDetails?.[accountID]?.login).join(','), + policyID, + }; + + API.write('DeleteMembersFromWorkspace', params, {optimisticData, successData, failureData}); } /** * Optimistically create a chat for each member of the workspace, creates both optimistic and success data for onyx. * - * @param {String} policyID - * @param {Object} invitedEmailsToAccountIDs - * @param {Boolean} hasOutstandingChildRequest - * @returns {Object} - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID) + * @returns - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID) */ -function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, hasOutstandingChildRequest = false) { - const workspaceMembersChats = { +function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: Record, hasOutstandingChildRequest = false): WorkspaceMembersChats { + const workspaceMembersChats: WorkspaceMembersChats = { onyxSuccessData: [], onyxOptimisticData: [], onyxFailureData: [], reportCreationData: {}, }; - _.each(invitedEmailsToAccountIDs, (accountID, email) => { + Object.keys(invitedEmailsToAccountIDs).forEach((email) => { + const accountID = invitedEmailsToAccountIDs[email]; const cleanAccountID = Number(accountID); const login = OptionsListUtils.addSMSDomainIfPhoneNumber(email); @@ -538,15 +597,12 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, hasOutsta /** * Adds members to the specified workspace/policyID - * - * @param {Object} invitedEmailsToAccountIDs - * @param {String} welcomeNote - * @param {String} policyID */ -function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) { - const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`; - const logins = _.map(_.keys(invitedEmailsToAccountIDs), (memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin)); - const accountIDs = _.values(invitedEmailsToAccountIDs); +function addMembersToWorkspace(invitedEmailsToAccountIDs: Record, welcomeNote: string, policyID: string) { + const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}` as const; + const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin)); + const accountIDs = Object.values(invitedEmailsToAccountIDs); + const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, accountIDs); const announceRoomMembers = buildAnnounceRoomMembersOnyxData(policyID, accountIDs); @@ -554,73 +610,77 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) // create onyx data for policy expense chats for each new member const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs); - const optimisticData = [ + const optimisticMembersState: OnyxCollection = {}; + const failureMembersState: OnyxCollection = {}; + accountIDs.forEach((accountID) => { + optimisticMembersState[accountID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; + failureMembersState[accountID] = { + errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericAdd'), + }; + }); + + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, // Convert to object with each key containing {pendingAction: ‘add’} - value: _.object(accountIDs, Array(accountIDs.length).fill({pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD})), + value: optimisticMembersState, }, ...newPersonalDetailsOnyxData.optimisticData, ...membersChats.onyxOptimisticData, ...announceRoomMembers.onyxOptimisticData, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, // Convert to object with each key clearing pendingAction, when it is an existing account. // Remove the object, when it is a newly created account. - value: _.reduce( - accountIDs, - (accountIDsWithClearedPendingAction, accountID) => { - let value = null; - const accountAlreadyExists = !_.isEmpty(allPersonalDetails[accountID]); - - if (accountAlreadyExists) { - value = {pendingAction: null, errors: null}; - } + value: accountIDs.reduce((accountIDsWithClearedPendingAction, accountID) => { + let value = null; + const accountAlreadyExists = !isEmptyObject(allPersonalDetails?.[accountID]); - // eslint-disable-next-line no-param-reassign - accountIDsWithClearedPendingAction[accountID] = value; + if (accountAlreadyExists) { + value = {pendingAction: null, errors: null}; + } - return accountIDsWithClearedPendingAction; - }, - {}, - ), + return {...accountIDsWithClearedPendingAction, [accountID]: value}; + }, {}), }, - ...newPersonalDetailsOnyxData.successData, + ...newPersonalDetailsOnyxData.finallyData, ...membersChats.onyxSuccessData, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, // Convert to object with each key containing the error. We don’t // need to remove the members since that is handled by onClose of OfflineWithFeedback. - value: _.object( - accountIDs, - Array(accountIDs.length).fill({ - errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericAdd'), - }), - ), - }, - ...newPersonalDetailsOnyxData.failureData, + value: failureMembersState, + }, + ...newPersonalDetailsOnyxData.finallyData, ...membersChats.onyxFailureData, ...announceRoomMembers.onyxFailureData, ]; - const params = { - employees: JSON.stringify(_.map(logins, (login) => ({email: login}))), + type AddMembersToWorkspaceParams = { + employees: string; + welcomeNote: string; + policyID: string; + reportCreationData?: string; + }; + + const params: AddMembersToWorkspaceParams = { + employees: JSON.stringify(logins.map((login) => ({email: login}))), welcomeNote: new ExpensiMark().replace(welcomeNote), policyID, }; - if (!_.isEmpty(membersChats.reportCreationData)) { + if (!isEmptyObject(membersChats.reportCreationData)) { params.reportCreationData = JSON.stringify(membersChats.reportCreationData); } API.write('AddMembersToWorkspace', params, {optimisticData, successData, failureData}); @@ -628,12 +688,9 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) /** * Updates a workspace avatar image - * - * @param {String} policyID - * @param {File|Object} file */ -function updateWorkspaceAvatar(policyID, file) { - const optimisticData = [ +function updateWorkspaceAvatar(policyID: string, file: File) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -649,7 +706,7 @@ function updateWorkspaceAvatar(policyID, file) { }, }, ]; - const successData = [ + const finallyData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -660,28 +717,34 @@ function updateWorkspaceAvatar(policyID, file) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - avatar: allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`].avatar, - pendingFields: { - avatar: null, - }, + avatar: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.avatar, }, }, ]; - API.write('UpdateWorkspaceAvatar', {policyID, file}, {optimisticData, successData, failureData}); + type UpdateWorkspaceAvatarParams = { + policyID: string; + file: File; + }; + + const params: UpdateWorkspaceAvatarParams = { + policyID, + file, + }; + + API.write('UpdateWorkspaceAvatar', params, {optimisticData, finallyData, failureData}); } /** * Deletes the avatar image for the workspace - * @param {String} policyID */ -function deleteWorkspaceAvatar(policyID) { - const optimisticData = [ +function deleteWorkspaceAvatar(policyID: string) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -696,7 +759,7 @@ function deleteWorkspaceAvatar(policyID) { }, }, ]; - const successData = [ + const finallyData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -707,28 +770,31 @@ function deleteWorkspaceAvatar(policyID) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - pendingFields: { - avatar: null, - }, errorFields: { avatar: ErrorUtils.getMicroSecondOnyxError('avatarWithImagePicker.deleteWorkspaceError'), }, }, }, ]; - API.write('DeleteWorkspaceAvatar', {policyID}, {optimisticData, successData, failureData}); + + type DeleteWorkspaceAvatarParams = { + policyID: string; + }; + + const params: DeleteWorkspaceAvatarParams = {policyID}; + + API.write('DeleteWorkspaceAvatar', params, {optimisticData, finallyData, failureData}); } /** * Clear error and pending fields for the workspace avatar - * @param {String} policyID */ -function clearAvatarErrors(policyID) { +function clearAvatarErrors(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { errorFields: { avatar: null, @@ -742,22 +808,24 @@ function clearAvatarErrors(policyID) { /** * Optimistically update the general settings. Set the general settings as pending until the response succeeds. * If the response fails set a general error message. Clear the error message when updating. - * - * @param {String} policyID - * @param {String} name - * @param {String} currency */ -function updateGeneralSettings(policyID, name, currency) { - const policy = allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - const distanceUnit = _.find(_.values(policy.customUnits), (unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - const distanceRate = _.find(_.values(distanceUnit ? distanceUnit.rates : {}), (rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - const optimisticData = [ +function updateGeneralSettings(policyID: string, name: string, currency: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + if (!policy) { + return; + } + + const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceRate = Object.values(distanceUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + + const optimisticData: OnyxUpdate[] = [ { // We use SET because it's faster than merge and avoids a race condition when setting the currency and navigating the user to the Bank account page in confirmCurrencyChangeAndHideModal onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - ...allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`], + ...policy, pendingFields: { generalSettings: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -769,13 +837,13 @@ function updateGeneralSettings(policyID, name, currency) { }, name, outputCurrency: currency, - ...(distanceUnit + ...(distanceUnit?.customUnitID && distanceRate?.customUnitRateID ? { customUnits: { - [distanceUnit.customUnitID]: { + [distanceUnit?.customUnitID]: { ...distanceUnit, rates: { - [distanceRate.customUnitRateID]: { + [distanceRate?.customUnitRateID]: { ...distanceRate, currency, }, @@ -787,7 +855,7 @@ function updateGeneralSettings(policyID, name, currency) { }, }, ]; - const successData = [ + const finallyData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -798,18 +866,15 @@ function updateGeneralSettings(policyID, name, currency) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - pendingFields: { - generalSettings: null, - }, errorFields: { generalSettings: ErrorUtils.getMicroSecondOnyxError('workspace.editor.genericFailureMessage'), }, - ...(distanceUnit + ...(distanceUnit?.customUnitID ? { customUnits: { [distanceUnit.customUnitID]: distanceUnit, @@ -820,21 +885,26 @@ function updateGeneralSettings(policyID, name, currency) { }, ]; - API.write( - 'UpdateWorkspaceGeneralSettings', - {policyID, workspaceName: name, currency}, - { - optimisticData, - successData, - failureData, - }, - ); + type UpdateWorkspaceGeneralSettingsParams = { + policyID: string; + workspaceName: string; + currency: string; + }; + + const params: UpdateWorkspaceGeneralSettingsParams = { + policyID, + workspaceName: name, + currency, + }; + + API.write('UpdateWorkspaceGeneralSettings', params, { + optimisticData, + finallyData, + failureData, + }); } -/** - * @param {String} policyID The id of the workspace / policy - */ -function clearWorkspaceGeneralSettingsErrors(policyID) { +function clearWorkspaceGeneralSettingsErrors(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { errorFields: { generalSettings: null, @@ -842,12 +912,8 @@ function clearWorkspaceGeneralSettingsErrors(policyID) { }); } -/** - * @param {String} policyID - * @param {Object} errors - */ -function setWorkspaceErrors(policyID, errors) { - if (!allPolicies[policyID]) { +function setWorkspaceErrors(policyID: string, errors: Errors) { + if (!allPolicies?.[policyID]) { return; } @@ -855,12 +921,7 @@ function setWorkspaceErrors(policyID, errors) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errors}); } -/** - * @param {String} policyID - * @param {String} customUnitID - * @param {String} customUnitRateID - */ -function clearCustomUnitErrors(policyID, customUnitID, customUnitRateID) { +function clearCustomUnitErrors(policyID: string, customUnitID: string, customUnitRateID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { customUnits: { [customUnitID]: { @@ -877,25 +938,20 @@ function clearCustomUnitErrors(policyID, customUnitID, customUnitRateID) { }); } -/** - * @param {String} policyID - */ -function hideWorkspaceAlertMessage(policyID) { - if (!allPolicies[policyID]) { +function hideWorkspaceAlertMessage(policyID: string) { + if (!allPolicies?.[policyID]) { return; } Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {alertMessage: ''}); } -/** - * @param {String} policyID - * @param {Object} currentCustomUnit - * @param {Object} newCustomUnit - * @param {Number} lastModified - */ -function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustomUnit, lastModified) { - const optimisticData = [ +function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit, lastModified: number) { + if (!currentCustomUnit.customUnitID || !newCustomUnit?.customUnitID || !newCustomUnit.rates?.customUnitRateID) { + return; + } + + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -904,7 +960,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom [newCustomUnit.customUnitID]: { ...newCustomUnit, rates: { - [newCustomUnit.rates.customUnitRateID]: { + [newCustomUnit.rates.customUnitRateID as string]: { ...newCustomUnit.rates, errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -917,7 +973,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -927,7 +983,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom pendingAction: null, errors: null, rates: { - [newCustomUnit.rates.customUnitRateID]: { + [newCustomUnit.rates.customUnitRateID as string]: { pendingAction: null, }, }, @@ -937,7 +993,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -946,7 +1002,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom [currentCustomUnit.customUnitID]: { customUnitID: currentCustomUnit.customUnitID, rates: { - [currentCustomUnit.rates.customUnitRateID]: { + [newCustomUnit.rates.customUnitRateID as string]: { ...currentCustomUnit.rates, errors: ErrorUtils.getMicroSecondOnyxError('workspace.reimburse.updateCustomUnitError'), }, @@ -957,27 +1013,31 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom }, ]; - const newCustomUnitParam = _.clone(newCustomUnit); - newCustomUnitParam.rates = _.omit(newCustomUnitParam.rates, ['pendingAction', 'errors']); - API.write( - 'UpdateWorkspaceCustomUnitAndRate', - { - policyID, - lastModified, - customUnit: JSON.stringify(newCustomUnitParam), - customUnitRate: JSON.stringify(newCustomUnitParam.rates), - }, - {optimisticData, successData, failureData}, - ); + const newCustomUnitParam = lodashClone(newCustomUnit); + const {pendingAction, errors, ...newRates} = newCustomUnitParam.rates ?? {}; + newCustomUnitParam.rates = newRates; + + type UpdateWorkspaceCustomUnitAndRate = { + policyID: string; + lastModified: number; + customUnit: string; + customUnitRate: string; + }; + + const params: UpdateWorkspaceCustomUnitAndRate = { + policyID, + lastModified, + customUnit: JSON.stringify(newCustomUnitParam), + customUnitRate: JSON.stringify(newCustomUnitParam.rates), + }; + + API.write('UpdateWorkspaceCustomUnitAndRate', params, {optimisticData, successData, failureData}); } /** * Removes an error after trying to delete a member - * - * @param {String} policyID - * @param {Number} accountID */ -function clearDeleteMemberError(policyID, accountID) { +function clearDeleteMemberError(policyID: string, accountID: number) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, { [accountID]: { pendingAction: null, @@ -988,11 +1048,8 @@ function clearDeleteMemberError(policyID, accountID) { /** * Removes an error after trying to add a member - * - * @param {String} policyID - * @param {Number} accountID */ -function clearAddMemberError(policyID, accountID) { +function clearAddMemberError(policyID: string, accountID: number) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, { [accountID]: null, }); @@ -1003,10 +1060,8 @@ function clearAddMemberError(policyID, accountID) { /** * Removes an error after trying to delete a workspace - * - * @param {String} policyID */ -function clearDeleteWorkspaceError(policyID) { +function clearDeleteWorkspaceError(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { pendingAction: null, errors: null, @@ -1015,19 +1070,16 @@ function clearDeleteWorkspaceError(policyID) { /** * Removes the workspace after failure to create. - * - * @param {String} policyID */ -function removeWorkspace(policyID) { +function removeWorkspace(policyID: string) { Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, null); } /** * Generate a policy name based on an email and policy list. - * @param {String} [email] the email to base the workspace name on. If not passed, will use the logged in user's email instead - * @returns {String} + * @param [email] the email to base the workspace name on. If not passed, will use the logged-in user's email instead */ -function generateDefaultWorkspaceName(email = '') { +function generateDefaultWorkspaceName(email = ''): string { const emailParts = email ? email.split('@') : sessionEmail.split('@'); let defaultWorkspaceName = ''; if (!emailParts || emailParts.length !== 2) { @@ -1036,7 +1088,7 @@ function generateDefaultWorkspaceName(email = '') { const username = emailParts[0]; const domain = emailParts[1]; - if (_.includes(PUBLIC_DOMAINS, domain.toLowerCase())) { + if (PUBLIC_DOMAINS.some((publicDomain) => publicDomain === domain.toLowerCase())) { defaultWorkspaceName = `${Str.UCFirst(username)}'s Workspace`; } else { defaultWorkspaceName = `${Str.UCFirst(domain.split('.')[0])}'s Workspace`; @@ -1046,45 +1098,39 @@ function generateDefaultWorkspaceName(email = '') { defaultWorkspaceName = 'My Group Workspace'; } - if (allPolicies.length === 0) { + if (isEmptyObject(allPolicies)) { return defaultWorkspaceName; } // find default named workspaces and increment the last number const numberRegEx = new RegExp(`${escapeRegExp(defaultWorkspaceName)} ?(\\d*)`, 'i'); - const lastWorkspaceNumber = _.chain(allPolicies) - .filter((policy) => policy.name && numberRegEx.test(policy.name)) - .map((policy) => parseInt(numberRegEx.exec(policy.name)[1] || 1, 10)) // parse the number at the end - .max() - .value(); + const parsedWorkspaceNumbers = Object.values(allPolicies ?? {}) + .filter((policy) => policy?.name && numberRegEx.test(policy.name)) + .map((policy) => Number(numberRegEx.exec(policy?.name ?? '')?.[1] ?? '1')); // parse the number at the end + const lastWorkspaceNumber = Math.max(...parsedWorkspaceNumbers); return lastWorkspaceNumber !== -Infinity ? `${defaultWorkspaceName} ${lastWorkspaceNumber + 1}` : defaultWorkspaceName; } /** * Returns a client generated 16 character hexadecimal value for the policyID - * @returns {String} */ -function generatePolicyID() { +function generatePolicyID(): string { return NumberUtils.generateHexadecimalValue(16); } /** * Returns a client generated 13 character hexadecimal value for a custom unit ID - * @returns {String} */ -function generateCustomUnitID() { +function generateCustomUnitID(): string { return NumberUtils.generateHexadecimalValue(13); } -/** - * @returns {Object} - */ -function buildOptimisticCustomUnits() { - const currency = lodashGet(allPersonalDetails, [sessionAccountID, 'localCurrencyCode'], CONST.CURRENCY.USD); +function buildOptimisticCustomUnits(): OptimisticCustomUnits { + const currency = allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD; const customUnitID = generateCustomUnitID(); const customUnitRateID = generateCustomUnitID(); - const customUnits = { + const customUnits: Record = { [customUnitID]: { customUnitID, name: CONST.CUSTOM_UNITS.NAME_DISTANCE, @@ -1113,16 +1159,16 @@ function buildOptimisticCustomUnits() { /** * Optimistically creates a Policy Draft for a new workspace * - * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy - * @param {String} [policyName] Optional, custom policy name we will use for created workspace - * @param {String} [policyID] Optional, custom policy id we will use for created workspace - * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy + * @param [policyOwnerEmail] the email of the account to make the owner of the policy + * @param [policyName] custom policy name we will use for created workspace + * @param [policyID] custom policy id we will use for created workspace + * @param [makeMeAdmin] leave the calling account as an admin on the policy */ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID(), makeMeAdmin = false) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); const {customUnits, outputCurrency} = buildOptimisticCustomUnits(); - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, @@ -1135,6 +1181,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol isPolicyExpenseChatEnabled: true, outputCurrency, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + areChatRoomsEnabled: true, customUnits, makeMeAdmin, }, @@ -1157,13 +1204,12 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol /** * Optimistically creates a new workspace and default workspace chats * - * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy - * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy - * @param {String} [policyName] Optional, custom policy name we will use for created workspace - * @param {String} [policyID] Optional, custom policy id we will use for created workspace - * @returns {String} + * @param [policyOwnerEmail] the email of the account to make the owner of the policy + * @param [makeMeAdmin] leave the calling account as an admin on the policy + * @param [policyName] custom policy name we will use for created workspace + * @param [policyID] custom policy id we will use for created workspace */ -function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()) { +function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()): string { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); @@ -1183,343 +1229,356 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName expenseCreatedReportActionID, } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); - API.write( - 'CreateWorkspace', - { - policyID, - announceChatReportID, - adminsChatReportID, - expenseChatReportID, - ownerEmail: policyOwnerEmail, - makeMeAdmin, - policyName: workspaceName, - type: CONST.POLICY.TYPE.FREE, - announceCreatedReportActionID, - adminsCreatedReportActionID, - expenseCreatedReportActionID, - customUnitID, - customUnitRateID, - }, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - id: policyID, - type: CONST.POLICY.TYPE.FREE, - name: workspaceName, - role: CONST.POLICY.ROLE.ADMIN, - owner: sessionEmail, - isPolicyExpenseChatEnabled: true, - outputCurrency, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - customUnits, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, - value: { - [sessionAccountID]: { - role: CONST.POLICY.ROLE.ADMIN, - errors: {}, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - ...announceChatData, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: announceReportActionData, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - ...adminsChatData, - }, + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + id: policyID, + type: CONST.POLICY.TYPE.FREE, + name: workspaceName, + role: CONST.POLICY.ROLE.ADMIN, + owner: sessionEmail, + isPolicyExpenseChatEnabled: true, + outputCurrency, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + areChatRoomsEnabled: true, + customUnits, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, + value: { + [sessionAccountID]: { + role: CONST.POLICY.ROLE.ADMIN, + errors: {}, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, - value: adminsReportActionData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - ...expenseChatData, - }, + ...announceChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: announceReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, - value: expenseReportActionData, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, - value: null, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`, - value: null, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {pendingAction: null}, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: null, - }, - pendingAction: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: { - [announceCreatedReportActionID]: { - pendingAction: null, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, - value: { - pendingFields: { - addWorkspaceRoom: null, - }, - pendingAction: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, - value: { - [adminsCreatedReportActionID]: { - 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: { - [expenseCreatedReportActionID]: { - pendingAction: null, - }, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, - value: null, + ...adminsChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: adminsReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: null, + ...expenseChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: expenseReportActionData, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`, + value: null, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {pendingAction: null}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: null, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: { + [announceCreatedReportActionID]: { + pendingAction: null, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, - value: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, - value: null, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: { + [adminsCreatedReportActionID]: { + pendingAction: null, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, - value: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, - value: null, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: { + [expenseCreatedReportActionID]: { + 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}${announceChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + 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 CreateWorkspaceParams = { + policyID: string; + announceChatReportID: string; + adminsChatReportID: string; + expenseChatReportID: string; + ownerEmail: string; + makeMeAdmin: boolean; + policyName: string; + type: string; + announceCreatedReportActionID: string; + adminsCreatedReportActionID: string; + expenseCreatedReportActionID: string; + customUnitID: string; + customUnitRateID: string; + }; + + const params: CreateWorkspaceParams = { + policyID, + announceChatReportID, + adminsChatReportID, + expenseChatReportID, + ownerEmail: policyOwnerEmail, + makeMeAdmin, + policyName: workspaceName, + type: CONST.POLICY.TYPE.FREE, + announceCreatedReportActionID, + adminsCreatedReportActionID, + expenseCreatedReportActionID, + customUnitID, + customUnitRateID, + }; + + API.write('CreateWorkspace', params, {optimisticData, successData, failureData}); return adminsChatReportID; } -/** - * - * @param {string} policyID - */ -function openWorkspaceReimburseView(policyID) { +function openWorkspaceReimburseView(policyID: string) { if (!policyID) { Log.warn('openWorkspaceReimburseView invalid params', {policyID}); return; } - const onyxData = { - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - value: { - isLoading: false, - }, + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: false, }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - value: { - isLoading: false, - }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: false, }, - ], + }, + ]; + + type OpenWorkspaceReimburseViewParams = { + policyID: string; }; - API.read('OpenWorkspaceReimburseView', {policyID}, onyxData); + const params: OpenWorkspaceReimburseViewParams = {policyID}; + + API.read('OpenWorkspaceReimburseView', params, {successData, failureData}); } -function openWorkspaceMembersPage(policyID, clientMemberEmails) { +function openWorkspaceMembersPage(policyID: string, clientMemberEmails: string[]) { if (!policyID || !clientMemberEmails) { Log.warn('openWorkspaceMembersPage invalid params', {policyID, clientMemberEmails}); return; } - API.read('OpenWorkspaceMembersPage', { + type OpenWorkspaceMembersPageParams = { + policyID: string; + clientMemberEmails: string; + }; + + const params: OpenWorkspaceMembersPageParams = { policyID, clientMemberEmails: JSON.stringify(clientMemberEmails), - }); + }; + + API.read('OpenWorkspaceMembersPage', params); } -function openWorkspaceInvitePage(policyID, clientMemberEmails) { +function openWorkspaceInvitePage(policyID: string, clientMemberEmails: string[]) { if (!policyID || !clientMemberEmails) { Log.warn('openWorkspaceInvitePage invalid params', {policyID, clientMemberEmails}); return; } - API.read('OpenWorkspaceInvitePage', { + type OpenWorkspaceInvitePageParams = { + policyID: string; + clientMemberEmails: string; + }; + + const params: OpenWorkspaceInvitePageParams = { policyID, clientMemberEmails: JSON.stringify(clientMemberEmails), - }); + }; + + API.read('OpenWorkspaceInvitePage', params); } -/** - * @param {String} policyID - */ -function openDraftWorkspaceRequest(policyID) { - API.read('OpenDraftWorkspaceRequest', {policyID}); +function openDraftWorkspaceRequest(policyID: string) { + type OpenDraftWorkspaceRequestParams = { + policyID: string; + }; + + const params: OpenDraftWorkspaceRequestParams = {policyID}; + + API.read('OpenDraftWorkspaceRequest', params); } -/** - * @param {String} policyID - * @param {Object} invitedEmailsToAccountIDs - */ -function setWorkspaceInviteMembersDraft(policyID, invitedEmailsToAccountIDs) { +function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccountIDs: Record) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, invitedEmailsToAccountIDs); } -/** - * @param {String} policyID - * @param {String} message - */ -function setWorkspaceInviteMessageDraft(policyID, message) { +function setWorkspaceInviteMessageDraft(policyID: string, message: string) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID}`, message); } -/** - * @param {String} policyID - */ -function clearErrors(policyID) { +function clearErrors(policyID: string) { setWorkspaceErrors(policyID, {}); hideWorkspaceAlertMessage(policyID); } /** * Dismiss the informative messages about which policy members were added with primary logins when invited with their secondary login. - * - * @param {String} policyID */ -function dismissAddedWithPrimaryLoginMessages(policyID) { +function dismissAddedWithPrimaryLoginMessages(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {primaryLoginsInvited: null}); } -/** - * @param {String} policyID - * @param {String} category - * @returns {Object} - */ -function buildOptimisticPolicyRecentlyUsedCategories(policyID, category) { +function buildOptimisticPolicyRecentlyUsedCategories(policyID: string, category: string) { if (!policyID || !category) { return []; } - const policyRecentlyUsedCategories = lodashGet(allRecentlyUsedCategories, `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`, []); + const policyRecentlyUsedCategories = allRecentlyUsedCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`] ?? []; return lodashUnion([category], policyRecentlyUsedCategories); } -/** - * @param {String} policyID - * @param {String} tag - * @returns {Object} - */ -function buildOptimisticPolicyRecentlyUsedTags(policyID, tag) { +function buildOptimisticPolicyRecentlyUsedTags(policyID: string, tag: string): RecentlyUsedTags { if (!policyID || !tag) { return {}; } - const policyTags = lodashGet(allPolicyTags, `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {}); + const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; // For now it only uses the first tag of the policy, since multi-tags are not yet supported - const tagListKey = _.first(_.keys(policyTags)); - const policyRecentlyUsedTags = lodashGet(allRecentlyUsedTags, `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, {}); + const tagListKey = Object.keys(policyTags)[0]; + const policyRecentlyUsedTags = allRecentlyUsedTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`] ?? {}; return { ...policyRecentlyUsedTags, - [tagListKey]: lodashUnion([tag], lodashGet(policyRecentlyUsedTags, [tagListKey], [])), + [tagListKey]: lodashUnion([tag], policyRecentlyUsedTags?.[tagListKey] ?? []), }; } @@ -1528,10 +1587,9 @@ function buildOptimisticPolicyRecentlyUsedTags(policyID, tag) { * we create a Collect type workspace when the person taking the action becomes an owner and an admin, while we * add a new member to the workspace as an employee and convert the IOU report passed as a param into an expense report. * - * @param {Object} iouReport - * @returns {String} policyID of the workspace we have created + * @returns policyID of the workspace we have created */ -function createWorkspaceFromIOUPayment(iouReport) { +function createWorkspaceFromIOUPayment(iouReport: Report): string | undefined { // This flow only works for IOU reports if (!ReportUtils.isIOUReport(iouReport)) { return; @@ -1561,6 +1619,10 @@ function createWorkspaceFromIOUPayment(iouReport) { expenseCreatedReportActionID: workspaceChatCreatedReportActionID, } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); + if (!employeeAccountID || !employeeEmail) { + return; + } + // Create the workspace chat for the employee whose IOU is being paid const employeeWorkspaceChat = createPolicyExpenseChats(policyID, {[employeeEmail]: employeeAccountID}, true); const newWorkspace = { @@ -1576,10 +1638,11 @@ function createWorkspaceFromIOUPayment(iouReport) { // Setting the currency to USD as we can only add the VBBA for this policy currency right now outputCurrency: CONST.CURRENCY.USD, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + areChatRoomsEnabled: true, customUnits, }; - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -1647,17 +1710,24 @@ function createWorkspaceFromIOUPayment(iouReport) { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, - value: null, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`, - value: null, + value: { + pendingAction: null, + }, }, ...employeeWorkspaceChat.onyxOptimisticData, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -1677,7 +1747,7 @@ function createWorkspaceFromIOUPayment(iouReport) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, value: { - [_.keys(announceChatData)[0]]: { + [Object.keys(announceChatData)[0]]: { pendingAction: null, }, }, @@ -1696,7 +1766,7 @@ function createWorkspaceFromIOUPayment(iouReport) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, value: { - [_.keys(adminsChatData)[0]]: { + [Object.keys(adminsChatData)[0]]: { pendingAction: null, }, }, @@ -1715,7 +1785,7 @@ function createWorkspaceFromIOUPayment(iouReport) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, value: { - [_.keys(workspaceChatData)[0]]: { + [Object.keys(workspaceChatData)[0]]: { pendingAction: null, }, }, @@ -1723,41 +1793,64 @@ function createWorkspaceFromIOUPayment(iouReport) { ...employeeWorkspaceChat.onyxSuccessData, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, - value: null, + value: { + pendingAction: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, - value: null, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: null, + value: { + pendingAction: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, - value: null, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, - value: null, + value: { + pendingAction: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`, - value: null, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, - value: null, + value: { + pendingAction: null, + }, }, ]; @@ -1784,7 +1877,7 @@ function createWorkspaceFromIOUPayment(iouReport) { policyID, policyName: workspaceName, type: CONST.REPORT.TYPE.EXPENSE, - total: -iouReport.total, + total: -(iouReport?.total ?? 0), }; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -1801,9 +1894,9 @@ function createWorkspaceFromIOUPayment(iouReport) { const reportTransactions = TransactionUtils.getAllReportTransactions(iouReportID); // For performance reasons, we are going to compose a merge collection data for transactions - const transactionsOptimisticData = {}; - const transactionFailureData = {}; - _.each(reportTransactions, (transaction) => { + const transactionsOptimisticData: Record = {}; + const transactionFailureData: Record = {}; + reportTransactions.forEach((transaction) => { transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { ...transaction, amount: -transaction.amount, @@ -1853,18 +1946,26 @@ function createWorkspaceFromIOUPayment(iouReport) { }, }); - // Update the created timestamp of the report preview action to be after the workspace chat created timestamp. - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, - value: { - [reportPreview.reportActionID]: { - ...reportPreview, - message: ReportUtils.getReportPreviewMessage(expenseReport, {}, false, false, newWorkspace), - created: DateUtils.getDBTime(), + if (reportPreview?.reportActionID) { + // Update the created timestamp of the report preview action to be after the workspace chat created timestamp. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, + value: { + [reportPreview.reportActionID]: { + ...reportPreview, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: ReportUtils.getReportPreviewMessage(expenseReport, {}, false, false, newWorkspace), + }, + ], + created: DateUtils.getDBTime(), + }, }, - }, - }); + }); + } + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, @@ -1872,7 +1973,7 @@ function createWorkspaceFromIOUPayment(iouReport) { }); // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved - const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID, policyID, memberData.workspaceChatReportID, iouReportID, workspaceName); + const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID ?? '', policyID, memberData.workspaceChatReportID, iouReportID, workspaceName); optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, @@ -1894,28 +1995,45 @@ function createWorkspaceFromIOUPayment(iouReport) { value: {[movedReportAction.reportActionID]: null}, }); - API.write( - 'CreateWorkspaceFromIOUPayment', - { - policyID, - announceChatReportID, - adminsChatReportID, - expenseChatReportID: workspaceChatReportID, - ownerEmail: '', - makeMeAdmin: false, - policyName: workspaceName, - type: CONST.POLICY.TYPE.TEAM, - announceCreatedReportActionID, - adminsCreatedReportActionID, - expenseCreatedReportActionID: workspaceChatCreatedReportActionID, - customUnitID, - customUnitRateID, - iouReportID, - memberData: JSON.stringify(memberData), - reportActionID: movedReportAction.reportActionID, - }, - {optimisticData, successData, failureData}, - ); + type CreateWorkspaceFromIOUPayment = { + policyID: string; + announceChatReportID: string; + adminsChatReportID: string; + expenseChatReportID: string; + ownerEmail: string; + makeMeAdmin: boolean; + policyName: string; + type: string; + announceCreatedReportActionID: string; + adminsCreatedReportActionID: string; + expenseCreatedReportActionID: string; + customUnitID: string; + customUnitRateID: string; + iouReportID: string; + memberData: string; + reportActionID: string; + }; + + const params: CreateWorkspaceFromIOUPayment = { + policyID, + announceChatReportID, + adminsChatReportID, + expenseChatReportID: workspaceChatReportID, + ownerEmail: '', + makeMeAdmin: false, + policyName: workspaceName, + type: CONST.POLICY.TYPE.TEAM, + announceCreatedReportActionID, + adminsCreatedReportActionID, + expenseCreatedReportActionID: workspaceChatCreatedReportActionID, + customUnitID, + customUnitRateID, + iouReportID, + memberData: JSON.stringify(memberData), + reportActionID: movedReportAction.reportActionID, + }; + + API.write('CreateWorkspaceFromIOUPayment', params, {optimisticData, successData, failureData}); return policyID; } diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 6449ea416c31..56fcd6935b4a 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2,7 +2,7 @@ import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; import isEmpty from 'lodash/isEmpty'; -import {DeviceEventEmitter, InteractionManager} from 'react-native'; +import {DeviceEventEmitter, InteractionManager, Linking} from 'react-native'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {NullishDeep} from 'react-native-onyx/lib/types'; @@ -127,6 +127,14 @@ const allReports: OnyxCollection = {}; let conciergeChatReportID: string | undefined; const typingWatchTimers: Record = {}; +let reportIDDeeplinkedFromOldDot: string | undefined; +Linking.getInitialURL().then((url) => { + const params = new URLSearchParams(url ?? ''); + const exitToRoute = params.get('exitTo') ?? ''; + const {reportID} = ReportUtils.parseReportRouteParams(exitToRoute); + reportIDDeeplinkedFromOldDot = reportID; +}); + /** Get the private pusher channel name for a Report. */ function getReportChannelName(reportID: string): string { return `${CONST.PUSHER.PRIVATE_REPORT_CHANNEL_PREFIX}${reportID}${CONFIG.PUSHER.SUFFIX}`; @@ -343,6 +351,7 @@ function addActions(reportID: string, text = '', file?: File) { timezone?: string; shouldAllowActionableMentionWhispers?: boolean; clientCreatedTime?: string; + isOldDotConciergeChat?: boolean; }; const parameters: AddCommentOrAttachementParameters = { @@ -355,6 +364,10 @@ function addActions(reportID: string, text = '', file?: File) { clientCreatedTime: file ? attachmentAction?.created : reportCommentAction?.created, }; + if (reportIDDeeplinkedFromOldDot === reportID && report?.participantAccountIDs?.length === 1 && Number(report.participantAccountIDs?.[0]) === CONST.ACCOUNT_ID.CONCIERGE) { + parameters.isOldDotConciergeChat = true; + } + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1988,7 +2001,7 @@ function toggleEmojiReaction( reportID: string, reportAction: ReportAction, reactionObject: Emoji, - existingReactions: ReportActionReactions | undefined, + existingReactions: OnyxEntry, paramSkinTone: number = preferredSkinTone, ) { const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); @@ -2179,14 +2192,8 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record typeof accountID === 'number', ); - type PersonalDetailsOnyxData = { - optimisticData: OnyxUpdate[]; - successData: OnyxUpdate[]; - failureData: OnyxUpdate[]; - }; - const logins = inviteeEmails.map((memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin)); - const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, inviteeAccountIDs) as PersonalDetailsOnyxData; + const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, inviteeAccountIDs); const optimisticData: OnyxUpdate[] = [ { @@ -2200,7 +2207,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record, chatReportID = '') { Onyx.merge(ONYXKEYS.WALLET_TERMS, {source, chatReportID}); } diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 09cc1222310f..055abf140e64 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -7,11 +7,12 @@ import type {ReadFileAsync, SplitExtensionFromFileName} from './types'; /** * Show alert on successful attachment download + * @param successMessage */ -function showSuccessAlert() { +function showSuccessAlert(successMessage?: string) { Alert.alert( Localize.translateLocal('fileDownload.success.title'), - Localize.translateLocal('fileDownload.success.message'), + successMessage ?? Localize.translateLocal('fileDownload.success.message'), [ { text: Localize.translateLocal('common.ok'), diff --git a/src/libs/fileDownload/index.android.ts b/src/libs/fileDownload/index.android.ts index 577a42dd14a3..7c9d3caf6865 100644 --- a/src/libs/fileDownload/index.android.ts +++ b/src/libs/fileDownload/index.android.ts @@ -33,7 +33,7 @@ function hasAndroidPermission(): Promise { /** * Handling the download */ -function handleDownload(url: string, fileName: string): Promise { +function handleDownload(url: string, fileName: string, successMessage?: string): Promise { return new Promise((resolve) => { const dirs = RNFetchBlob.fs.dirs; @@ -84,7 +84,7 @@ function handleDownload(url: string, fileName: string): Promise { if (attachmentPath) { RNFetchBlob.fs.unlink(attachmentPath); } - FileUtils.showSuccessAlert(); + FileUtils.showSuccessAlert(successMessage); }) .catch(() => { FileUtils.showGeneralErrorAlert(); @@ -96,12 +96,12 @@ function handleDownload(url: string, fileName: string): Promise { /** * Checks permission and downloads the file for Android */ -const fileDownload: FileDownload = (url, fileName) => +const fileDownload: FileDownload = (url, fileName, successMessage) => new Promise((resolve) => { hasAndroidPermission() .then((hasPermission) => { if (hasPermission) { - return handleDownload(url, fileName); + return handleDownload(url, fileName, successMessage); } FileUtils.showPermissionErrorAlert(); }) diff --git a/src/libs/fileDownload/index.ios.ts b/src/libs/fileDownload/index.ios.ts index 7672b4b14926..4990c389fd9f 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -69,7 +69,7 @@ function downloadVideo(fileUrl: string, fileName: string): Promise { /** * Download the file based on type(image, video, other file types)for iOS */ -const fileDownload: FileDownload = (fileUrl, fileName) => +const fileDownload: FileDownload = (fileUrl, fileName, successMessage) => new Promise((resolve) => { let fileDownloadPromise; const fileType = FileUtils.getFileType(fileUrl); @@ -93,7 +93,7 @@ const fileDownload: FileDownload = (fileUrl, fileName) => return; } - FileUtils.showSuccessAlert(); + FileUtils.showSuccessAlert(successMessage); }) .catch((err) => { // iOS shows permission popup only once. Subsequent request will only throw an error. diff --git a/src/libs/fileDownload/types.ts b/src/libs/fileDownload/types.ts index bc8ba0807eb1..6d92bddd5816 100644 --- a/src/libs/fileDownload/types.ts +++ b/src/libs/fileDownload/types.ts @@ -1,6 +1,6 @@ import type {Asset} from 'react-native-image-picker'; -type FileDownload = (url: string, fileName: string) => Promise; +type FileDownload = (url: string, fileName: string, successMessage?: string) => Promise; type ImageResolution = {width: number; height: number}; type GetImageResolution = (url: File | Asset) => Promise; diff --git a/src/libs/getCurrentPosition/getCurrentPosition.types.ts b/src/libs/getCurrentPosition/getCurrentPosition.types.ts index 792f51ceba7d..67a510efa97f 100644 --- a/src/libs/getCurrentPosition/getCurrentPosition.types.ts +++ b/src/libs/getCurrentPosition/getCurrentPosition.types.ts @@ -1,3 +1,5 @@ +import type {ValueOf} from 'type-fest'; + type GeolocationSuccessCallback = (position: { coords: { latitude: number; @@ -11,8 +13,10 @@ type GeolocationSuccessCallback = (position: { timestamp: number; }) => void; +type GeolocationErrorCodeType = ValueOf | null; + type GeolocationErrorCallback = (error: { - code: (typeof GeolocationErrorCode)[keyof typeof GeolocationErrorCode]; + code: GeolocationErrorCodeType; message: string; PERMISSION_DENIED: typeof GeolocationErrorCode.PERMISSION_DENIED; POSITION_UNAVAILABLE: typeof GeolocationErrorCode.POSITION_UNAVAILABLE; @@ -51,4 +55,4 @@ type GetCurrentPosition = (success: GeolocationSuccessCallback, error: Geolocati export {GeolocationErrorCode}; -export type {GeolocationSuccessCallback, GeolocationErrorCallback, GeolocationOptions, GetCurrentPosition}; +export type {GeolocationSuccessCallback, GeolocationErrorCallback, GeolocationOptions, GetCurrentPosition, GeolocationErrorCodeType}; diff --git a/src/libs/localFileDownload/index.android.ts b/src/libs/localFileDownload/index.android.ts index b6d8ea13738f..dd266d3be405 100644 --- a/src/libs/localFileDownload/index.android.ts +++ b/src/libs/localFileDownload/index.android.ts @@ -7,7 +7,7 @@ import type LocalFileDownload from './types'; * and textContent, so we're able to copy it to the Android public download dir. * After the file is copied, it is removed from the internal dir. */ -const localFileDownload: LocalFileDownload = (fileName, textContent) => { +const localFileDownload: LocalFileDownload = (fileName, textContent, successMessage) => { const newFileName = FileUtils.appendTimeToFileName(fileName); const dir = RNFetchBlob.fs.dirs.DocumentDir; const path = `${dir}/${newFileName}.txt`; @@ -23,7 +23,7 @@ const localFileDownload: LocalFileDownload = (fileName, textContent) => { path, ) .then(() => { - FileUtils.showSuccessAlert(); + FileUtils.showSuccessAlert(successMessage); }) .catch(() => { FileUtils.showGeneralErrorAlert(); diff --git a/src/libs/localFileDownload/types.ts b/src/libs/localFileDownload/types.ts index 2086e2334d39..68e013e60bb3 100644 --- a/src/libs/localFileDownload/types.ts +++ b/src/libs/localFileDownload/types.ts @@ -1,3 +1,3 @@ -type LocalFileDownload = (fileName: string, textContent: string) => void; +type LocalFileDownload = (fileName: string, textContent: string, successMessage?: string) => void; export default LocalFileDownload; diff --git a/src/pages/EnablePayments/IdologyQuestions.js b/src/pages/EnablePayments/IdologyQuestions.js index 8f21d68abe4f..a0c202b0bbbc 100644 --- a/src/pages/EnablePayments/IdologyQuestions.js +++ b/src/pages/EnablePayments/IdologyQuestions.js @@ -139,6 +139,7 @@ function IdologyQuestions({questions, idNumber}) { scrollContextEnabled style={[styles.flexGrow1, styles.ph5]} submitButtonText={translate('common.saveAndContinue')} + shouldHideFixErrorsAlert > ; }; -const defaultProps = { - account: { - guideCalendarLink: null, - }, +type GetAssistancePageProps = GetAssistanceOnyxProps & { + /** Route object from navigation */ + route: RouteProp<{params: {backTo: Route}}>; }; -function GetAssistancePage(props) { +function GetAssistancePage({route, account}: GetAssistancePageProps) { const styles = useThemeStyles(); - const navigateBackTo = lodashGet(props.route, 'params.backTo', ROUTES.SETTINGS_CONTACT_METHODS); - const menuItems = [ + const {translate} = useLocalize(); + const navigateBackTo = route?.params.backTo || ROUTES.SETTINGS_CONTACT_METHODS.getRoute(); + const menuItems: MenuItemWithLink[] = [ { - title: props.translate('getAssistancePage.chatWithConcierge'), + title: translate('getAssistancePage.chatWithConcierge'), onPress: () => Report.navigateToConciergeChat(), icon: Expensicons.ChatBubble, shouldShowRightIcon: true, wrapperStyle: [styles.cardMenuItem], }, { - title: props.translate('getAssistancePage.exploreHelpDocs'), + title: translate('getAssistancePage.exploreHelpDocs'), onPress: () => Link.openExternalLink(CONST.NEWHELP_URL), icon: Expensicons.QuestionMark, shouldShowRightIcon: true, @@ -66,10 +55,10 @@ function GetAssistancePage(props) { ]; // If the user is eligible for calls with their Guide, add the 'Schedule a setup call' item at the second position in the list - const guideCalendarLink = lodashGet(props.account, 'guideCalendarLink'); + const guideCalendarLink = account?.guideCalendarLink; if (guideCalendarLink) { menuItems.splice(1, 0, { - title: props.translate('getAssistancePage.scheduleSetupCall'), + title: translate('getAssistancePage.scheduleSetupCall'), onPress: () => Link.openExternalLink(guideCalendarLink), icon: Expensicons.Phone, shouldShowRightIcon: true, @@ -82,17 +71,17 @@ function GetAssistancePage(props) { return ( Navigation.goBack(navigateBackTo)} />
- {props.translate('getAssistancePage.description')} + {translate('getAssistancePage.description')}
@@ -100,16 +89,11 @@ function GetAssistancePage(props) { ); } -GetAssistancePage.propTypes = propTypes; -GetAssistancePage.defaultProps = defaultProps; GetAssistancePage.displayName = 'GetAssistancePage'; -export default compose( - withLocalize, - withOnyx({ - account: { - key: ONYXKEYS.ACCOUNT, - selector: (account) => account && {guideCalendarLink: account.guideCalendarLink}, - }, - }), -)(GetAssistancePage); +export default withOnyx({ + account: { + key: ONYXKEYS.ACCOUNT, + selector: (account) => account && {guideCalendarLink: account.guideCalendarLink}, + }, +})(GetAssistancePage); diff --git a/src/pages/KeyboardShortcutsPage.js b/src/pages/KeyboardShortcutsPage.tsx similarity index 76% rename from src/pages/KeyboardShortcutsPage.js rename to src/pages/KeyboardShortcutsPage.tsx index 809d2ce6dc07..9b70defbf8af 100644 --- a/src/pages/KeyboardShortcutsPage.js +++ b/src/pages/KeyboardShortcutsPage.tsx @@ -1,6 +1,5 @@ import React from 'react'; import {ScrollView, View} from 'react-native'; -import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -10,11 +9,15 @@ import useThemeStyles from '@hooks/useThemeStyles'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import CONST from '@src/CONST'; +type Shortcut = { + displayName: string; + descriptionKey: 'search' | 'newChat' | 'openShortcutDialog' | 'escape' | 'copy'; +}; + function KeyboardShortcutsPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); - const shortcuts = _.chain(CONST.KEYBOARD_SHORTCUTS) - .filter((shortcut) => !_.isEmpty(shortcut.descriptionKey)) + const shortcuts = Object.values(CONST.KEYBOARD_SHORTCUTS) .map((shortcut) => { const platformAdjustedModifiers = KeyboardShortcut.getPlatformEquivalentForKeys(shortcut.modifiers); return { @@ -22,16 +25,12 @@ function KeyboardShortcutsPage() { descriptionKey: shortcut.descriptionKey, }; }) - .value(); - + .filter((shortcut): shortcut is Shortcut => !!shortcut.descriptionKey); /** * Render the information of a single shortcut - * @param {Object} shortcut - * @param {String} shortcut.displayName - * @param {String} shortcut.descriptionKey - * @returns {React.Component} + * @param shortcut - The shortcut to render */ - const renderShortcut = (shortcut) => ( + const renderShortcut = (shortcut: Shortcut) => ( - {translate('keyboardShortcutsPage.subtitle')} - {_.map(shortcuts, renderShortcut)} + {translate('keyboardShortcutsPage.subtitle')} + {shortcuts.map(renderShortcut)}
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js index 81d4af93d86c..01ab3b849a0b 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js @@ -397,18 +397,6 @@ function ReimbursementAccountPage({reimbursementAccount, route, onfidoToken, pol } }; - if (_.isEmpty(policy) || !PolicyUtils.isPolicyAdmin(policy)) { - return ( - - Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} - subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'} - /> - - ); - } - const isLoading = (isLoadingApp || account.isLoading || reimbursementAccount.isLoading) && (!plaidCurrentEvent || plaidCurrentEvent === CONST.BANK_ACCOUNT.PLAID.EVENTS_NAME.EXIT); const shouldShowOfflineLoader = !( isOffline && _.contains([CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, CONST.BANK_ACCOUNT.STEP.COMPANY, CONST.BANK_ACCOUNT.STEP.REQUESTOR, CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT], currentStep) @@ -427,6 +415,18 @@ function ReimbursementAccountPage({reimbursementAccount, route, onfidoToken, pol ); } + if (!isLoading && (_.isEmpty(policy) || !PolicyUtils.isPolicyAdmin(policy))) { + return ( + + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} + subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'} + /> + + ); + } + let errorText; const userHasPhonePrimaryEmail = Str.endsWith(session.email, CONST.SMS.DOMAIN); const throttledDate = lodashGet(reimbursementAccount, 'throttledDate', ''); diff --git a/src/pages/RoomMembersPage.js b/src/pages/RoomMembersPage.js index a8c600a91845..30ffd60aa4ac 100644 --- a/src/pages/RoomMembersPage.js +++ b/src/pages/RoomMembersPage.js @@ -282,6 +282,7 @@ function RoomMembersPage(props) { canSelectMultiple sections={[{data, indexOffset: 0, isDisabled: false}]} textInputLabel={props.translate('optionsSelector.findMember')} + disableKeyboardShortcuts={removeMembersConfirmModalVisible} textInputValue={searchValue} onChangeText={setSearchValue} headerMessage={headerMessage} diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 9b2765718250..7cc1b6ce26dd 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -161,7 +161,7 @@ function HeaderView(props) { onSelected: join, }); } else if (canLeave) { - const isWorkspaceMemberLeavingWorkspaceRoom = lodashGet(props.report, 'visibility', '') === CONST.REPORT.VISIBILITY.RESTRICTED && isPolicyMember; + const isWorkspaceMemberLeavingWorkspaceRoom = !isChatThread && lodashGet(props.report, 'visibility', '') === CONST.REPORT.VISIBILITY.RESTRICTED && isPolicyMember; threeDotMenuItems.push({ icon: Expensicons.ChatBubbles, text: translate('common.leave'), diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index b35d9240f3f7..70a0f1a236bb 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -393,6 +393,11 @@ function ReportScreen({ Navigation.goBack(ROUTES.HOME, false, true); } if (prevReport.parentReportID) { + // Prevent navigation to the Money Request Report if it is pending deletion. + const parentReport = ReportUtils.getReport(prevReport.parentReportID); + if (ReportUtils.isMoneyRequestReportPendingDeletion(parentReport)) { + return; + } Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(prevReport.parentReportID)); return; } diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js deleted file mode 100755 index fc06176edd3b..000000000000 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js +++ /dev/null @@ -1,204 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {memo, useMemo, useRef, useState} from 'react'; -import {InteractionManager, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import ContextMenuItem from '@components/ContextMenuItem'; -import {withBetas} from '@components/OnyxProvider'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; -import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; -import useNetwork from '@hooks/useNetwork'; -import useStyleUtils from '@hooks/useStyleUtils'; -import compose from '@libs/compose'; -import * as Session from '@userActions/Session'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ContextMenuActions from './ContextMenuActions'; -import {defaultProps as GenericReportActionContextMenuDefaultProps, propTypes as genericReportActionContextMenuPropTypes} from './genericReportActionContextMenuPropTypes'; -import {hideContextMenu} from './ReportActionContextMenu'; - -const propTypes = { - /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ - type: PropTypes.string, - - /** Target node which is the target of ContentMenu */ - anchor: PropTypes.oneOfType([PropTypes.node, PropTypes.object]), - - /** Flag to check if the chat participant is Chronos */ - isChronosReport: PropTypes.bool, - - /** Whether the provided report is an archived room */ - isArchivedRoom: PropTypes.bool, - - contentRef: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.func]), - - ...genericReportActionContextMenuPropTypes, - ...withLocalizePropTypes, - ...windowDimensionsPropTypes, -}; - -const defaultProps = { - type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - anchor: null, - contentRef: null, - isChronosReport: false, - isArchivedRoom: false, - ...GenericReportActionContextMenuDefaultProps, -}; -function BaseReportActionContextMenu(props) { - const StyleUtils = useStyleUtils(); - const menuItemRefs = useRef({}); - const [shouldKeepOpen, setShouldKeepOpen] = useState(false); - const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(props.isMini, props.isSmallScreenWidth); - const {isOffline} = useNetwork(); - - const reportAction = useMemo(() => { - if (_.isEmpty(props.reportActions) || props.reportActionID === '0') { - return {}; - } - return props.reportActions[props.reportActionID] || {}; - }, [props.reportActions, props.reportActionID]); - - const shouldShowFilter = (contextAction) => - contextAction.shouldShow( - props.type, - reportAction, - props.isArchivedRoom, - props.betas, - props.anchor, - props.isChronosReport, - props.reportID, - props.isPinnedChat, - props.isUnreadChat, - isOffline, - ); - - const shouldEnableArrowNavigation = !props.isMini && (props.isVisible || shouldKeepOpen); - const filteredContextMenuActions = _.filter(ContextMenuActions, shouldShowFilter); - - // Context menu actions that are not rendered as menu items are excluded from arrow navigation - const nonMenuItemActionIndexes = _.map(filteredContextMenuActions, (contextAction, index) => (_.isFunction(contextAction.renderContent) ? index : undefined)); - const disabledIndexes = _.filter(nonMenuItemActionIndexes, (index) => !_.isUndefined(index)); - - const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ - initialFocusedIndex: -1, - disabledIndexes, - maxIndex: filteredContextMenuActions.length - 1, - isActive: shouldEnableArrowNavigation, - }); - - /** - * Checks if user is anonymous. If true and the action doesn't accept for anonymous user, hides the context menu and - * shows the sign in modal. Else, executes the callback. - * - * @param {Function} callback - * @param {Boolean} isAnonymousAction - */ - const interceptAnonymousUser = (callback, isAnonymousAction = false) => { - if (Session.isAnonymousUser() && !isAnonymousAction) { - hideContextMenu(false); - - InteractionManager.runAfterInteractions(() => { - Session.signOutAndRedirectToSignIn(); - }); - } else { - callback(); - } - }; - - useKeyboardShortcut( - CONST.KEYBOARD_SHORTCUTS.ENTER, - (event) => { - if (!menuItemRefs.current[focusedIndex]) { - return; - } - - // Ensures the event does not cause side-effects beyond the context menu, e.g. when an outside element is focused - if (event) { - event.stopPropagation(); - } - - menuItemRefs.current[focusedIndex].triggerPressAndUpdateSuccess(); - setFocusedIndex(-1); - }, - {isActive: shouldEnableArrowNavigation}, - ); - - return ( - (props.isVisible || shouldKeepOpen) && ( - - {_.map(filteredContextMenuActions, (contextAction, index) => { - const closePopup = !props.isMini; - const payload = { - reportAction, - reportID: props.reportID, - draftMessage: props.draftMessage, - selection: props.selection, - close: () => setShouldKeepOpen(false), - openContextMenu: () => setShouldKeepOpen(true), - interceptAnonymousUser, - }; - - if (contextAction.renderContent) { - // make sure that renderContent isn't mixed with unsupported props - if (__DEV__ && (contextAction.text != null || contextAction.icon != null)) { - throw new Error('Dev error: renderContent() and text/icon cannot be used together.'); - } - - return contextAction.renderContent(closePopup, payload); - } - - return ( - { - menuItemRefs.current[index] = ref; - }} - icon={contextAction.icon} - text={props.translate(contextAction.textTranslateKey, {action: reportAction})} - successIcon={contextAction.successIcon} - successText={contextAction.successTextTranslateKey ? props.translate(contextAction.successTextTranslateKey) : undefined} - isMini={props.isMini} - key={contextAction.textTranslateKey} - onPress={() => interceptAnonymousUser(() => contextAction.onPress(closePopup, payload), contextAction.isAnonymousAction)} - description={contextAction.getDescription(props.selection, props.isSmallScreenWidth)} - isAnonymousAction={contextAction.isAnonymousAction} - isFocused={focusedIndex === index} - /> - ); - })} - - ) - ); -} - -BaseReportActionContextMenu.propTypes = propTypes; -BaseReportActionContextMenu.defaultProps = defaultProps; - -export default compose( - withLocalize, - withBetas(), - withWindowDimensions, - withOnyx({ - reportActions: { - key: ({originalReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, - canEvict: false, - }, - }), -)( - memo(BaseReportActionContextMenu, (prevProps, nextProps) => { - const prevReportAction = lodashGet(prevProps.reportActions, prevProps.reportActionID, ''); - const nextReportAction = lodashGet(nextProps.reportActions, nextProps.reportActionID, ''); - - // We only want to re-render when the report action that is attached to is changed - if (prevReportAction !== nextReportAction) { - return false; - } - return _.isEqual(_.omit(prevProps, 'reportActions'), _.omit(nextProps, 'reportActions')); - }), -); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx new file mode 100755 index 000000000000..3eecb74a048a --- /dev/null +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -0,0 +1,254 @@ +import lodashIsEqual from 'lodash/isEqual'; +import type {MutableRefObject, RefObject} from 'react'; +import React, {memo, useMemo, useRef, useState} from 'react'; +import {InteractionManager, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import type {ContextMenuItemHandle} from '@components/ContextMenuItem'; +import ContextMenuItem from '@components/ContextMenuItem'; +import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as Session from '@userActions/Session'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Beta, ReportAction, ReportActions} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ContextMenuActionPayload} from './ContextMenuActions'; +import ContextMenuActions from './ContextMenuActions'; +import type {ContextMenuType} from './ReportActionContextMenu'; +import {hideContextMenu} from './ReportActionContextMenu'; + +type BaseReportActionContextMenuOnyxProps = { + /** Beta features list */ + betas: OnyxEntry; + + /** All of the actions of the report */ + reportActions: OnyxEntry; +}; + +type BaseReportActionContextMenuProps = BaseReportActionContextMenuOnyxProps & { + /** The ID of the report this report action is attached to. */ + reportID: string; + + /** The ID of the report action this context menu is attached to. */ + reportActionID: string; + + /** The ID of the original report from which the given reportAction is first created. */ + // originalReportID is used in withOnyx to get the reportActions for the original report + // eslint-disable-next-line react/no-unused-prop-types + originalReportID: string; + + /** + * If true, this component will be a small, row-oriented menu that displays icons but not text. + * If false, this component will be a larger, column-oriented menu that displays icons alongside text in each row. + */ + isMini?: boolean; + + /** Controls the visibility of this component. */ + isVisible?: boolean; + + /** The copy selection. */ + selection?: string; + + /** Draft message - if this is set the comment is in 'edit' mode */ + draftMessage?: string; + + /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ + type?: ContextMenuType; + + /** Target node which is the target of ContentMenu */ + anchor?: MutableRefObject; + + /** Flag to check if the chat participant is Chronos */ + isChronosReport?: boolean; + + /** Whether the provided report is an archived room */ + isArchivedRoom?: boolean; + + /** Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action */ + isPinnedChat?: boolean; + + /** Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action */ + isUnreadChat?: boolean; + + /** Content Ref */ + contentRef?: RefObject; + + checkIfContextMenuActive?: () => void; +}; + +type MenuItemRefs = Record; + +function BaseReportActionContextMenu({ + type = CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + anchor, + contentRef, + isChronosReport = false, + isArchivedRoom = false, + isMini = false, + isVisible = false, + isPinnedChat = false, + isUnreadChat = false, + selection = '', + draftMessage = '', + reportActionID, + reportID, + betas, + reportActions, + checkIfContextMenuActive, +}: BaseReportActionContextMenuProps) { + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); + const menuItemRefs = useRef({}); + const [shouldKeepOpen, setShouldKeepOpen] = useState(false); + const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(isMini, isSmallScreenWidth); + const {isOffline} = useNetwork(); + + const reportAction: OnyxEntry = useMemo(() => { + if (isEmptyObject(reportActions) || reportActionID === '0') { + return null; + } + return reportActions[reportActionID] ?? null; + }, [reportActions, reportActionID]); + + const shouldEnableArrowNavigation = !isMini && (isVisible || shouldKeepOpen); + let filteredContextMenuActions = ContextMenuActions.filter((contextAction) => + contextAction.shouldShow(type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat, !!isOffline, isMini), + ); + filteredContextMenuActions = + isMini && filteredContextMenuActions.length > CONST.MINI_CONTEXT_MENU_MAX_ITEMS + ? ([...filteredContextMenuActions.slice(0, CONST.MINI_CONTEXT_MENU_MAX_ITEMS - 1), filteredContextMenuActions.at(-1)] as typeof filteredContextMenuActions) + : filteredContextMenuActions; + + // Context menu actions that are not rendered as menu items are excluded from arrow navigation + const nonMenuItemActionIndexes = filteredContextMenuActions.map((contextAction, index) => + 'renderContent' in contextAction && typeof contextAction.renderContent === 'function' ? index : undefined, + ); + const disabledIndexes = nonMenuItemActionIndexes.filter((index): index is number => index !== undefined); + + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + initialFocusedIndex: -1, + disabledIndexes, + maxIndex: filteredContextMenuActions.length - 1, + isActive: shouldEnableArrowNavigation, + }); + + /** + * Checks if user is anonymous. If true and the action doesn't accept for anonymous user, hides the context menu and + * shows the sign in modal. Else, executes the callback. + */ + const interceptAnonymousUser = (callback: () => void, isAnonymousAction = false) => { + if (Session.isAnonymousUser() && !isAnonymousAction) { + hideContextMenu(false); + + InteractionManager.runAfterInteractions(() => { + Session.signOutAndRedirectToSignIn(); + }); + } else { + callback(); + } + }; + + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.ENTER, + (event) => { + if (!menuItemRefs.current[focusedIndex]) { + return; + } + + // Ensures the event does not cause side-effects beyond the context menu, e.g. when an outside element is focused + if (event) { + event.stopPropagation(); + } + + menuItemRefs.current[focusedIndex]?.triggerPressAndUpdateSuccess?.(); + setFocusedIndex(-1); + }, + {isActive: shouldEnableArrowNavigation}, + ); + + return ( + (isVisible || shouldKeepOpen) && ( + + {filteredContextMenuActions.map((contextAction, index) => { + const closePopup = !isMini; + const payload: ContextMenuActionPayload = { + reportAction: reportAction as ReportAction, + reportID, + draftMessage, + selection, + close: () => setShouldKeepOpen(false), + openContextMenu: () => setShouldKeepOpen(true), + interceptAnonymousUser, + anchor, + checkIfContextMenuActive, + }; + + if ('renderContent' in contextAction) { + return contextAction.renderContent(closePopup, payload); + } + + const {textTranslateKey} = contextAction; + const isKeyInActionUpdateKeys = + textTranslateKey === 'reportActionContextMenu.editAction' || + textTranslateKey === 'reportActionContextMenu.deleteAction' || + textTranslateKey === 'reportActionContextMenu.deleteConfirmation'; + const text = textTranslateKey && (isKeyInActionUpdateKeys ? translate(textTranslateKey, {action: reportAction}) : translate(textTranslateKey)); + + return ( + { + menuItemRefs.current[index] = ref; + }} + icon={contextAction.icon} + text={text ?? ''} + successIcon={contextAction.successIcon} + successText={contextAction.successTextTranslateKey ? translate(contextAction.successTextTranslateKey) : undefined} + isMini={isMini} + key={contextAction.textTranslateKey} + onPress={(event) => interceptAnonymousUser(() => contextAction.onPress?.(closePopup, {...payload, event}), contextAction.isAnonymousAction)} + description={contextAction.getDescription?.(selection) ?? ''} + isAnonymousAction={contextAction.isAnonymousAction} + isFocused={focusedIndex === index} + /> + ); + })} + + ) + ); +} + +export default withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + reportActions: { + key: ({originalReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, + canEvict: false, + }, +})( + memo(BaseReportActionContextMenu, (prevProps, nextProps) => { + const {reportActions: prevReportActions, ...prevPropsWithoutReportActions} = prevProps; + const {reportActions: nextReportActions, ...nextPropsWithoutReportActions} = nextProps; + + const prevReportAction = prevReportActions?.[prevProps.reportActionID] ?? ''; + const nextReportAction = nextReportActions?.[nextProps.reportActionID] ?? ''; + + // We only want to re-render when the report action that is attached to is changed + if (prevReportAction !== nextReportAction) { + return false; + } + + return lodashIsEqual(prevPropsWithoutReportActions, nextPropsWithoutReportActions); + }), +); + +export type {BaseReportActionContextMenuProps}; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx similarity index 67% rename from src/pages/home/report/ContextMenu/ContextMenuActions.js rename to src/pages/home/report/ContextMenu/ContextMenuActions.tsx index aa815b0b32dc..ea25a00ee1d3 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -1,7 +1,10 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; -import lodashGet from 'lodash/get'; +import type {MutableRefObject} from 'react'; import React from 'react'; -import _ from 'underscore'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, Text as RNText, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {Emoji} from '@assets/emojis/types'; import * as Expensicons from '@components/Icon/Expensicons'; import MiniQuickEmojiReactions from '@components/Reactions/MiniQuickEmojiReactions'; import QuickEmojiReactions from '@components/Reactions/QuickEmojiReactions'; @@ -22,24 +25,20 @@ import * as TaskUtils from '@libs/TaskUtils'; import * as Download from '@userActions/Download'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; -import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; +import type {Beta, ReportAction, ReportActionReactions} from '@src/types/onyx'; +import type IconAsset from '@src/types/utils/IconAsset'; +import {hideContextMenu, showContextMenu, showDeleteModal} from './ReportActionContextMenu'; -/** - * Gets the HTML version of the message in an action. - * @param {Object} reportAction - * @return {String} - */ -function getActionText(reportAction) { - const message = _.last(lodashGet(reportAction, 'message', null)); - return lodashGet(message, 'html', ''); +/** Gets the HTML version of the message in an action */ +function getActionText(reportAction: OnyxEntry): string { + const message = reportAction?.message?.at(-1) ?? null; + return message?.html ?? ''; } -/** - * Sets the HTML string to Clipboard. - * @param {String} content - */ -function setClipboardMessage(content) { +/** Sets the HTML string to Clipboard */ +function setClipboardMessage(content: string) { const parser = new ExpensiMark(); if (!Clipboard.canSetHtml()) { Clipboard.setString(parser.htmlToMarkdown(content)); @@ -49,16 +48,67 @@ function setClipboardMessage(content) { } } +type ShouldShow = ( + type: string, + reportAction: OnyxEntry, + isArchivedRoom: boolean, + betas: OnyxEntry, + menuTarget: MutableRefObject | undefined, + isChronosReport: boolean, + reportID: string, + isPinnedChat: boolean, + isUnreadChat: boolean, + isOffline: boolean, + isMini: boolean, +) => boolean; + +type ContextMenuActionPayload = { + reportAction: ReportAction; + reportID: string; + draftMessage: string; + selection: string; + close: () => void; + openContextMenu: () => void; + interceptAnonymousUser: (callback: () => void, isAnonymousAction?: boolean) => void; + anchor?: MutableRefObject; + checkIfContextMenuActive?: () => void; + event?: GestureResponderEvent | MouseEvent | KeyboardEvent; +}; + +type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string, draftMessage?: string) => void; + +type RenderContent = (closePopover: boolean, payload: ContextMenuActionPayload) => React.ReactElement; + +type GetDescription = (selection?: string) => string | void; + +type ContextMenuActionWithContent = { + renderContent: RenderContent; +}; + +type ContextMenuActionWithIcon = { + textTranslateKey: TranslationPaths; + icon: IconAsset; + successTextTranslateKey?: TranslationPaths; + successIcon?: IconAsset; + onPress: OnPress; + getDescription: GetDescription; +}; + +type ContextMenuAction = (ContextMenuActionWithContent | ContextMenuActionWithIcon) & { + isAnonymousAction: boolean; + shouldShow: ShouldShow; +}; + // A list of all the context actions in this menu. -export default [ +const ContextMenuActions: ContextMenuAction[] = [ { isAnonymousAction: false, - shouldKeepOpen: true, - shouldShow: (type, reportAction) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && _.has(reportAction, 'message') && !ReportActionsUtils.isMessageDeleted(reportAction), + shouldShow: (type, reportAction): reportAction is ReportAction => + type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !!reportAction && 'message' in reportAction && !ReportActionsUtils.isMessageDeleted(reportAction), renderContent: (closePopover, {reportID, reportAction, close: closeManually, openContextMenu}) => { const isMini = !closePopover; - const closeContextMenu = (onHideCallback) => { + const closeContextMenu = (onHideCallback?: () => void) => { if (isMini) { closeManually(); if (onHideCallback) { @@ -69,7 +119,7 @@ export default [ } }; - const toggleEmojiAndCloseMenu = (emoji, existingReactions) => { + const toggleEmojiAndCloseMenu = (emoji: Emoji, existingReactions: OnyxEntry) => { Report.toggleEmojiReaction(reportID, reportAction, emoji, existingReactions); closeContextMenu(); }; @@ -81,7 +131,7 @@ export default [ onEmojiSelected={toggleEmojiAndCloseMenu} onPressOpenPicker={openContextMenu} onEmojiPickerClosed={closeContextMenu} - reportActionID={reportAction.reportActionID} + reportActionID={reportAction?.reportActionID} reportAction={reportAction} /> ); @@ -92,7 +142,7 @@ export default [ key="BaseQuickEmojiReactions" closeContextMenu={closeContextMenu} onEmojiSelected={toggleEmojiAndCloseMenu} - reportActionID={reportAction.reportActionID} + reportActionID={reportAction?.reportActionID} reportAction={reportAction} /> ); @@ -104,20 +154,20 @@ export default [ icon: Expensicons.Download, successTextTranslateKey: 'common.download', successIcon: Expensicons.Download, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat, isOffline) => { + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat, isUnreadChat, isOffline): reportAction is ReportAction => { const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); - const messageHtml = lodashGet(reportAction, ['message', 0, 'html']); - return isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction) && !isOffline; + const messageHtml = reportAction?.message?.at(0)?.html; + return ( + isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && !!reportAction?.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction) && !isOffline + ); }, onPress: (closePopover, {reportAction}) => { - const message = _.last(lodashGet(reportAction, 'message', [{}])); - const html = lodashGet(message, 'html', ''); - const attachmentDetails = getAttachmentDetails(html); - const {originalFileName, sourceURL} = attachmentDetails; - const sourceURLWithAuth = addEncryptedAuthTokenToURL(sourceURL); - const sourceID = (sourceURL.match(CONST.REGEX.ATTACHMENT_ID) || [])[1]; + const html = getActionText(reportAction); + const {originalFileName, sourceURL} = getAttachmentDetails(html); + const sourceURLWithAuth = addEncryptedAuthTokenToURL(sourceURL ?? ''); + const sourceID = (sourceURL?.match(CONST.REGEX.ATTACHMENT_ID) ?? [])[1]; Download.setDownload(sourceID, true); - fileDownload(sourceURLWithAuth, originalFileName).then(() => Download.setDownload(sourceID, false)); + fileDownload(sourceURLWithAuth, originalFileName ?? '').then(() => Download.setDownload(sourceID, false)); if (closePopover) { hideContextMenu(true, ReportActionComposeFocusManager.focus); } @@ -127,10 +177,8 @@ export default [ { isAnonymousAction: false, textTranslateKey: 'reportActionContextMenu.replyInThread', - icon: Expensicons.ChatBubble, - successTextTranslateKey: '', - successIcon: null, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID) => { + icon: Expensicons.ChatBubbleAdd, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID) => { if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION) { return false; } @@ -140,29 +188,98 @@ export default [ if (closePopover) { hideContextMenu(false, () => { ReportActionComposeFocusManager.focus(); - Report.navigateToAndOpenChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID); + Report.navigateToAndOpenChildReport(reportAction?.childReportID ?? '0', reportAction, reportID); }); return; } - Report.navigateToAndOpenChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID); + Report.navigateToAndOpenChildReport(reportAction?.childReportID ?? '0', reportAction, reportID); + }, + getDescription: () => {}, + }, + { + isAnonymousAction: false, + textTranslateKey: 'reportActionContextMenu.editAction', + icon: Expensicons.Pencil, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport) => + type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canEditReportAction(reportAction) && !isArchivedRoom && !isChronosReport, + onPress: (closePopover, {reportID, reportAction, draftMessage}) => { + if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { + hideContextMenu(false); + const childReportID = reportAction?.childReportID ?? '0'; + if (!childReportID) { + const thread = ReportUtils.buildTransactionThread(reportAction, reportID); + const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs ?? []); + Report.openReport(thread.reportID, userLogins, thread, reportAction?.reportActionID); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(thread.reportID)); + return; + } + Report.openReport(childReportID); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); + return; + } + const editAction = () => { + if (!draftMessage) { + Report.saveReportActionDraft(reportID, reportAction, getActionText(reportAction)); + } else { + Report.deleteReportActionDraft(reportID, reportAction); + } + }; + + if (closePopover) { + // Hide popover, then call editAction + hideContextMenu(false, editAction); + return; + } + + // No popover to hide, call editAction immediately + editAction(); }, getDescription: () => {}, }, { isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.subscribeToThread', + textTranslateKey: 'reportActionContextMenu.markAsUnread', + icon: Expensicons.Mail, + successIcon: Expensicons.Checkmark, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat, isUnreadChat) => + type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || (type === CONST.CONTEXT_MENU_TYPES.REPORT && !isUnreadChat), + onPress: (closePopover, {reportAction, reportID}) => { + Report.markCommentAsUnread(reportID, reportAction?.created); + if (closePopover) { + hideContextMenu(true, ReportActionComposeFocusManager.focus); + } + }, + getDescription: () => {}, + }, + { + isAnonymousAction: false, + textTranslateKey: 'reportActionContextMenu.markAsRead', + icon: Expensicons.Mail, + successIcon: Expensicons.Checkmark, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat, isUnreadChat) => + type === CONST.CONTEXT_MENU_TYPES.REPORT && isUnreadChat, + onPress: (closePopover, {reportID}) => { + Report.readNewestAction(reportID); + if (closePopover) { + hideContextMenu(true, ReportActionComposeFocusManager.focus); + } + }, + getDescription: () => {}, + }, + { + isAnonymousAction: false, + textTranslateKey: 'reportActionContextMenu.joinThread', icon: Expensicons.Bell, - successTextTranslateKey: '', - successIcon: null, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID) => { + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID) => { const childReportNotificationPreference = ReportUtils.getChildReportNotificationPreference(reportAction); const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(reportAction, reportID); - const subscribed = childReportNotificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; - const isCommentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID); - const isReportPreviewAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; - const isIOUAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction); + const subscribed = childReportNotificationPreference !== 'hidden'; + const isCommentAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID); + const isReportPreviewAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; + const isIOUAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction); + const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction); return !subscribed && !isWhisperAction && (isCommentAction || isReportPreviewAction || isIOUAction) && (!isDeletedAction || shouldDisplayThreadReplies); }, @@ -171,23 +288,21 @@ export default [ if (closePopover) { hideContextMenu(false, () => { ReportActionComposeFocusManager.focus(); - Report.toggleSubscribeToChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID, childReportNotificationPreference); + Report.toggleSubscribeToChildReport(reportAction?.childReportID ?? '0', reportAction, reportID, childReportNotificationPreference); }); return; } ReportActionComposeFocusManager.focus(); - Report.toggleSubscribeToChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID, childReportNotificationPreference); + Report.toggleSubscribeToChildReport(reportAction?.childReportID ?? '0', reportAction, reportID, childReportNotificationPreference); }, getDescription: () => {}, }, { isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.unsubscribeFromThread', + textTranslateKey: 'reportActionContextMenu.leaveThread', icon: Expensicons.BellSlash, - successTextTranslateKey: '', - successIcon: null, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID) => { + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID) => { const childReportNotificationPreference = ReportUtils.getChildReportNotificationPreference(reportAction); const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(reportAction, reportID); @@ -195,9 +310,9 @@ export default [ if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION) { return false; } - const isCommentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID); - const isReportPreviewAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; - const isIOUAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction); + const isCommentAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID); + const isReportPreviewAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; + const isIOUAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction); return subscribed && (isCommentAction || isReportPreviewAction || isIOUAction) && (!isDeletedAction || shouldDisplayThreadReplies); }, onPress: (closePopover, {reportAction, reportID}) => { @@ -205,13 +320,13 @@ export default [ if (closePopover) { hideContextMenu(false, () => { ReportActionComposeFocusManager.focus(); - Report.toggleSubscribeToChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID, childReportNotificationPreference); + Report.toggleSubscribeToChildReport(reportAction?.childReportID ?? '0', reportAction, reportID, childReportNotificationPreference); }); return; } ReportActionComposeFocusManager.focus(); - Report.toggleSubscribeToChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID, childReportNotificationPreference); + Report.toggleSubscribeToChildReport(reportAction?.childReportID ?? '0', reportAction, reportID, childReportNotificationPreference); }, getDescription: () => {}, }, @@ -239,7 +354,7 @@ export default [ Clipboard.setString(EmailUtils.trimMailTo(selection)); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, - getDescription: (selection) => EmailUtils.prefixMailSeparatorsWithBreakOpportunities(EmailUtils.trimMailTo(selection)), + getDescription: (selection) => EmailUtils.prefixMailSeparatorsWithBreakOpportunities(EmailUtils.trimMailTo(selection ?? '')), }, { isAnonymousAction: true, @@ -256,8 +371,7 @@ export default [ onPress: (closePopover, {reportAction, selection}) => { const isTaskAction = ReportActionsUtils.isTaskAction(reportAction); const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); - const message = _.last(lodashGet(reportAction, 'message', [{}])); - const messageHtml = isTaskAction ? TaskUtils.getTaskReportActionMessage(reportAction.actionName) : lodashGet(message, 'html', ''); + const messageHtml = isTaskAction ? TaskUtils.getTaskReportActionMessage(reportAction?.actionName) : getActionText(reportAction); const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); if (!isAttachment) { @@ -269,11 +383,6 @@ export default [ } else if (ReportActionsUtils.isModifiedExpenseAction(reportAction)) { const modifyExpenseMessage = ModifiedExpenseMessage.getForReportAction(reportAction); Clipboard.setString(modifyExpenseMessage); - } else if (ReportActionsUtils.isReimbursementDeQueuedAction(reportAction)) { - const {expenseReportID} = reportAction.originalMessage; - const expenseReport = ReportUtils.getReport(expenseReportID); - const displayMessage = ReportUtils.getReimbursementDeQueuedActionMessage(reportAction, expenseReport); - Clipboard.setString(displayMessage); } else if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { const displayMessage = ReportUtils.getIOUReportActionDisplayMessage(reportAction); Clipboard.setString(displayMessage); @@ -281,11 +390,11 @@ export default [ const taskPreviewMessage = TaskUtils.getTaskCreatedMessage(reportAction); Clipboard.setString(taskPreviewMessage); } else if (ReportActionsUtils.isMemberChangeAction(reportAction)) { - const logMessage = ReportActionsUtils.getMemberChangeMessageFragment(reportAction).html; + const logMessage = ReportActionsUtils.getMemberChangeMessageFragment(reportAction).html ?? ''; setClipboardMessage(logMessage); } else if (ReportActionsUtils.isSubmittedExpenseAction(reportAction)) { - const submittedMessage = _.reduce(reportAction.message, (acc, curr) => `${acc}${curr.text}`, ''); - Clipboard.setString(submittedMessage); + const submittedMessage = reportAction?.message?.reduce((acc, curr) => `${acc}${curr.text}`, ''); + Clipboard.setString(submittedMessage ?? ''); } else if (content) { setClipboardMessage(content); } @@ -297,7 +406,6 @@ export default [ }, getDescription: () => {}, }, - { isAnonymousAction: true, textTranslateKey: 'reportActionContextMenu.copyLink', @@ -308,90 +416,18 @@ export default [ const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); // Only hide the copylink menu item when context menu is opened over img element. - const isAttachmentTarget = lodashGet(menuTarget, 'tagName') === 'IMG' && isAttachment; + const isAttachmentTarget = menuTarget?.current?.tagName === 'IMG' && isAttachment; return Permissions.canUseCommentLinking(betas) && type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !ReportActionsUtils.isMessageDeleted(reportAction); }, onPress: (closePopover, {reportAction, reportID}) => { Environment.getEnvironmentURL().then((environmentURL) => { - const reportActionID = lodashGet(reportAction, 'reportActionID'); + const reportActionID = reportAction?.reportActionID; Clipboard.setString(`${environmentURL}/r/${reportID}/${reportActionID}`); }); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, getDescription: () => {}, }, - - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.markAsUnread', - icon: Expensicons.Mail, - successIcon: Expensicons.Checkmark, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat) => - type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || (type === CONST.CONTEXT_MENU_TYPES.REPORT && !isUnreadChat), - onPress: (closePopover, {reportAction, reportID}) => { - Report.markCommentAsUnread(reportID, reportAction.created); - if (closePopover) { - hideContextMenu(true, ReportActionComposeFocusManager.focus); - } - }, - getDescription: () => {}, - }, - - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.markAsRead', - icon: Expensicons.Mail, - successIcon: Expensicons.Checkmark, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && isUnreadChat, - onPress: (closePopover, {reportID}) => { - Report.readNewestAction(reportID); - if (closePopover) { - hideContextMenu(true, ReportActionComposeFocusManager.focus); - } - }, - getDescription: () => {}, - }, - - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.editAction', - icon: Expensicons.Pencil, - shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport) => - type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canEditReportAction(reportAction) && !isArchivedRoom && !isChronosReport, - onPress: (closePopover, {reportID, reportAction, draftMessage}) => { - if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { - hideContextMenu(false); - const childReportID = lodashGet(reportAction, 'childReportID', 0); - if (!childReportID) { - const thread = ReportUtils.buildTransactionThread(reportAction, reportID); - const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); - Report.openReport(thread.reportID, userLogins, thread, reportAction.reportActionID); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(thread.reportID)); - return; - } - Report.openReport(childReportID); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); - return; - } - const editAction = () => { - if (_.isUndefined(draftMessage)) { - Report.saveReportActionDraft(reportID, reportAction, getActionText(reportAction)); - } else { - Report.deleteReportActionDraft(reportID, reportAction); - } - }; - - if (closePopover) { - // Hide popover, then call editAction - hideContextMenu(false, editAction); - return; - } - - // No popover to hide, call editAction immediately - editAction(); - }, - getDescription: () => {}, - }, { isAnonymousAction: false, textTranslateKey: 'reportActionContextMenu.deleteAction', @@ -419,7 +455,7 @@ export default [ isAnonymousAction: false, textTranslateKey: 'common.pin', icon: Expensicons.Pin, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && !isPinnedChat, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && !isPinnedChat, onPress: (closePopover, {reportID}) => { Report.togglePinnedState(reportID, false); if (closePopover) { @@ -432,7 +468,7 @@ export default [ isAnonymousAction: false, textTranslateKey: 'common.unPin', icon: Expensicons.Pin, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && isPinnedChat, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && isPinnedChat, onPress: (closePopover, {reportID}) => { Report.togglePinnedState(reportID, true); if (closePopover) { @@ -450,16 +486,43 @@ export default [ ReportUtils.canFlagReportAction(reportAction, reportID) && !isArchivedRoom && !isChronosReport && - !ReportUtils.isConciergeChatReport(reportID) && - reportAction.actorAccountID !== CONST.ACCOUNT_ID.CONCIERGE, + reportAction?.actorAccountID !== CONST.ACCOUNT_ID.CONCIERGE, onPress: (closePopover, {reportID, reportAction}) => { if (closePopover) { - hideContextMenu(false, () => Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction.reportActionID))); + hideContextMenu(false, () => Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction?.reportActionID))); return; } - Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction.reportActionID)); + Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction?.reportActionID)); + }, + getDescription: () => {}, + }, + { + isAnonymousAction: true, + textTranslateKey: 'reportActionContextMenu.menu', + icon: Expensicons.ThreeDots, + shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat, isOffline, isMini) => isMini, + onPress: (closePopover, {reportAction, reportID, event, anchor, selection, draftMessage, checkIfContextMenuActive}) => { + const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); + const originalReport = ReportUtils.getReport(originalReportID); + showContextMenu( + CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event as GestureResponderEvent | MouseEvent, + selection, + anchor?.current as View | RNText | null, + reportID, + reportAction.reportActionID, + originalReportID, + draftMessage, + checkIfContextMenuActive, + checkIfContextMenuActive, + ReportUtils.isArchivedRoom(originalReport), + ReportUtils.chatIncludesChronos(originalReport), + ); }, getDescription: () => {}, }, ]; + +export default ContextMenuActions; +export type {ContextMenuActionPayload}; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js deleted file mode 100644 index d858206cdfc3..000000000000 --- a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js +++ /dev/null @@ -1,47 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import useStyleUtils from '@hooks/useStyleUtils'; -import BaseReportActionContextMenu from '@pages/home/report/ContextMenu/BaseReportActionContextMenu'; -import { - defaultProps as GenericReportActionContextMenuDefaultProps, - propTypes as genericReportActionContextMenuPropTypes, -} from '@pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes'; -import CONST from '@src/CONST'; - -const propTypes = { - ..._.omit(genericReportActionContextMenuPropTypes, ['isMini']), - - /** Should the reportAction this menu is attached to have the appearance of being - * grouped with the previous reportAction? */ - displayAsGroup: PropTypes.bool, -}; - -const defaultProps = { - ..._.omit(GenericReportActionContextMenuDefaultProps, ['isMini']), - displayAsGroup: false, -}; - -function MiniReportActionContextMenu(props) { - const StyleUtils = useStyleUtils(); - - return ( - - - - ); -} - -MiniReportActionContextMenu.propTypes = propTypes; -MiniReportActionContextMenu.defaultProps = defaultProps; -MiniReportActionContextMenu.displayName = 'MiniReportActionContextMenu'; - -export default MiniReportActionContextMenu; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.js b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.js deleted file mode 100644 index 461f67a0a4bc..000000000000 --- a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.js +++ /dev/null @@ -1 +0,0 @@ -export default () => null; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx new file mode 100644 index 000000000000..7be6a850d51b --- /dev/null +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx @@ -0,0 +1,4 @@ +import type MiniReportActionContextMenuProps from './types'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default (props: MiniReportActionContextMenuProps) => null; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.tsx b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.tsx new file mode 100644 index 000000000000..df1226eed900 --- /dev/null +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import {View} from 'react-native'; +import useStyleUtils from '@hooks/useStyleUtils'; +import BaseReportActionContextMenu from '@pages/home/report/ContextMenu/BaseReportActionContextMenu'; +import CONST from '@src/CONST'; +import type MiniReportActionContextMenuProps from './types'; + +function MiniReportActionContextMenu({displayAsGroup = false, ...rest}: MiniReportActionContextMenuProps) { + const StyleUtils = useStyleUtils(); + + return ( + + + + ); +} + +MiniReportActionContextMenu.displayName = 'MiniReportActionContextMenu'; + +export default MiniReportActionContextMenu; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts new file mode 100644 index 000000000000..98b38dcb6968 --- /dev/null +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts @@ -0,0 +1,8 @@ +import type {BaseReportActionContextMenuProps} from '@pages/home/report/ContextMenu/BaseReportActionContextMenu'; + +type MiniReportActionContextMenuProps = Omit & { + /** Should the reportAction this menu is attached to have the appearance of being grouped with the previous reportAction? */ + displayAsGroup?: boolean; +}; + +export default MiniReportActionContextMenuProps; diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx similarity index 65% rename from src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js rename to src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 1c93c3bc90c7..46b783bca3f9 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -1,23 +1,45 @@ +import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import type {EmitterSubscription, GestureResponderEvent, NativeTouchEvent, View} from 'react-native'; import {Dimensions} from 'react-native'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import useLocalize from '@hooks/useLocalize'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as IOU from '@userActions/IOU'; import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; import BaseReportActionContextMenu from './BaseReportActionContextMenu'; +import type {ContextMenuType, ReportActionContextMenu} from './ReportActionContextMenu'; -function PopoverReportActionContextMenu(_props, ref) { +type ContextMenuAnchorCallback = (x: number, y: number) => void; + +type ContextMenuAnchor = {measureInWindow: (callback: ContextMenuAnchorCallback) => void}; + +type Location = { + x: number; + y: number; +}; + +function extractPointerEvent(event: GestureResponderEvent | MouseEvent): MouseEvent | NativeTouchEvent { + if ('nativeEvent' in event) { + return event.nativeEvent; + } + return event; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef) { const {translate} = useLocalize(); const reportIDRef = useRef('0'); - const typeRef = useRef(undefined); - const reportActionRef = useRef({}); + const typeRef = useRef(); + const reportActionRef = useRef>(null); const reportActionIDRef = useRef('0'); const originalReportIDRef = useRef('0'); const selectionRef = useRef(''); - const reportActionDraftMessageRef = useRef(undefined); + const reportActionDraftMessageRef = useRef(); const cursorRelativePosition = useRef({ horizontal: 0, @@ -41,11 +63,11 @@ function PopoverReportActionContextMenu(_props, ref) { const [isChatPinned, setIsChatPinned] = useState(false); const [hasUnreadMessages, setHasUnreadMessages] = useState(false); - const contentRef = useRef(null); - const anchorRef = useRef(null); - const dimensionsEventListener = useRef(null); - const contextMenuAnchorRef = useRef(null); - const contextMenuTargetNode = useRef(null); + const contentRef = useRef(null); + const anchorRef = useRef(null); + const dimensionsEventListener = useRef(null); + const contextMenuAnchorRef = useRef(null); + const contextMenuTargetNode = useRef(null); const onPopoverShow = useRef(() => {}); const onPopoverHide = useRef(() => {}); @@ -55,16 +77,11 @@ function PopoverReportActionContextMenu(_props, ref) { const onPopoverHideActionCallback = useRef(() => {}); const callbackWhenDeleteModalHide = useRef(() => {}); - /** - * Get the Context menu anchor position - * We calculate the achor coordinates from measureInWindow async method - * - * @returns {Promise} - */ + /** Get the Context menu anchor position. We calculate the anchor coordinates from measureInWindow async method */ const getContextMenuMeasuredLocation = useCallback( () => - new Promise((resolve) => { - if (contextMenuAnchorRef.current && _.isFunction(contextMenuAnchorRef.current.measureInWindow)) { + new Promise((resolve) => { + if (contextMenuAnchorRef.current && typeof contextMenuAnchorRef.current.measureInWindow === 'function') { contextMenuAnchorRef.current.measureInWindow((x, y) => resolve({x, y})); } else { resolve({x: 0, y: 0}); @@ -73,9 +90,7 @@ function PopoverReportActionContextMenu(_props, ref) { [], ); - /** - * This gets called on Dimensions change to find the anchor coordinates for the action context menu. - */ + /** This gets called on Dimensions change to find the anchor coordinates for the action context menu. */ const measureContextMenuAnchorPosition = useCallback(() => { if (!isPopoverVisible) { return; @@ -87,8 +102,8 @@ function PopoverReportActionContextMenu(_props, ref) { } popoverAnchorPosition.current = { - horizontal: cursorRelativePosition.horizontal + x, - vertical: cursorRelativePosition.vertical + y, + horizontal: cursorRelativePosition.current.horizontal + x, + vertical: cursorRelativePosition.current.vertical + y, }; }); }, [isPopoverVisible, getContextMenuMeasuredLocation]); @@ -104,38 +119,34 @@ function PopoverReportActionContextMenu(_props, ref) { }; }, [measureContextMenuAnchorPosition]); - /** - * Whether Context Menu is active for the Report Action. - * - * @param {Number|String} actionID - * @return {Boolean} - */ - const isActiveReportAction = (actionID) => Boolean(actionID) && (reportActionIDRef.current === actionID || reportActionRef.current.reportActionID === actionID); + /** Whether Context Menu is active for the Report Action. */ + const isActiveReportAction: ReportActionContextMenu['isActiveReportAction'] = (actionID) => + !!actionID && (reportActionIDRef.current === actionID || reportActionRef.current?.reportActionID === actionID); const clearActiveReportAction = () => { reportActionIDRef.current = '0'; - reportActionRef.current = {}; + reportActionRef.current = null; }; /** * Show the ReportActionContextMenu modal popover. * - * @param {string} type - context menu type [EMAIL, LINK, REPORT_ACTION] - * @param {Object} [event] - A press event. - * @param {String} [selection] - Copied content. - * @param {Element} contextMenuAnchor - popoverAnchor - * @param {String} reportID - Active Report Id - * @param {Object} reportActionID - ReportAction for ContextMenu - * @param {String} originalReportID - The currrent Report Id of the reportAction - * @param {String} draftMessage - ReportAction Draftmessage - * @param {Function} [onShow] - Run a callback when Menu is shown - * @param {Function} [onHide] - Run a callback when Menu is hidden - * @param {Boolean} isArchivedRoom - Whether the provided report is an archived room - * @param {Boolean} isChronosReport - Flag to check if the chat participant is Chronos - * @param {Boolean} isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action - * @param {Boolean} isUnreadChat - Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action + * @param type - context menu type [EMAIL, LINK, REPORT_ACTION] + * @param [event] - A press event. + * @param [selection] - Copied content. + * @param contextMenuAnchor - popoverAnchor + * @param reportID - Active Report Id + * @param reportActionID - ReportAction for ContextMenu + * @param originalReportID - The currrent Report Id of the reportAction + * @param draftMessage - ReportAction Draftmessage + * @param [onShow] - Run a callback when Menu is shown + * @param [onHide] - Run a callback when Menu is hidden + * @param isArchivedRoom - Whether the provided report is an archived room + * @param isChronosReport - Flag to check if the chat participant is Chronos + * @param isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action + * @param isUnreadChat - Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action */ - const showContextMenu = ( + const showContextMenu: ReportActionContextMenu['showContextMenu'] = ( type, event, selection, @@ -151,9 +162,9 @@ function PopoverReportActionContextMenu(_props, ref) { isPinnedChat = false, isUnreadChat = false, ) => { - const nativeEvent = event.nativeEvent || {}; + const {pageX = 0, pageY = 0} = extractPointerEvent(event); contextMenuAnchorRef.current = contextMenuAnchor; - contextMenuTargetNode.current = nativeEvent.target; + contextMenuTargetNode.current = event.target as HTMLElement; setInstanceID(Math.random().toString(36).substr(2, 5)); @@ -162,18 +173,18 @@ function PopoverReportActionContextMenu(_props, ref) { getContextMenuMeasuredLocation().then(({x, y}) => { popoverAnchorPosition.current = { - horizontal: nativeEvent.pageX - x, - vertical: nativeEvent.pageY - y, + horizontal: pageX - x, + vertical: pageY - y, }; popoverAnchorPosition.current = { - horizontal: nativeEvent.pageX, - vertical: nativeEvent.pageY, + horizontal: pageX, + vertical: pageY, }; typeRef.current = type; - reportIDRef.current = reportID; - reportActionIDRef.current = reportActionID; - originalReportIDRef.current = originalReportID; + reportIDRef.current = reportID ?? '0'; + reportActionIDRef.current = reportActionID ?? '0'; + originalReportIDRef.current = originalReportID ?? '0'; selectionRef.current = selection; setIsPopoverVisible(true); reportActionDraftMessageRef.current = draftMessage; @@ -184,9 +195,7 @@ function PopoverReportActionContextMenu(_props, ref) { }); }; - /** - * After Popover shows, call the registered onPopoverShow callback and reset it - */ + /** After Popover shows, call the registered onPopoverShow callback and reset it */ const runAndResetOnPopoverShow = () => { onPopoverShow.current(); @@ -194,19 +203,13 @@ function PopoverReportActionContextMenu(_props, ref) { onPopoverShow.current = () => {}; }; - /** - * Run the callback and return a noop function to reset it - * @param {Function} callback - * @returns {Function} - */ - const runAndResetCallback = (callback) => { + /** Run the callback and return a noop function to reset it */ + const runAndResetCallback = (callback: () => void) => { callback(); return () => {}; }; - /** - * After Popover hides, call the registered onPopoverHide & onPopoverHideActionCallback callback and reset it - */ + /** After Popover hides, call the registered onPopoverHide & onPopoverHideActionCallback callback and reset it */ const runAndResetOnPopoverHide = () => { reportIDRef.current = '0'; reportActionIDRef.current = '0'; @@ -218,10 +221,10 @@ function PopoverReportActionContextMenu(_props, ref) { /** * Hide the ReportActionContextMenu modal popover. - * @param {Function} onHideActionCallback Callback to be called after popover is completely hidden + * @param onHideActionCallback Callback to be called after popover is completely hidden */ - const hideContextMenu = (onHideActionCallback) => { - if (_.isFunction(onHideActionCallback)) { + const hideContextMenu: ReportActionContextMenu['hideContextMenu'] = (onHideActionCallback) => { + if (typeof onHideActionCallback === 'function') { onPopoverHideActionCallback.current = onHideActionCallback; } @@ -232,10 +235,11 @@ function PopoverReportActionContextMenu(_props, ref) { const confirmDeleteAndHideModal = useCallback(() => { callbackWhenDeleteModalHide.current = () => (onComfirmDeleteModal.current = runAndResetCallback(onComfirmDeleteModal.current)); - if (ReportActionsUtils.isMoneyRequestAction(reportActionRef.current)) { - IOU.deleteMoneyRequest(reportActionRef.current.originalMessage.IOUTransactionID, reportActionRef.current); - } else { - Report.deleteReportComment(reportIDRef.current, reportActionRef.current); + const reportAction = reportActionRef.current; + if (ReportActionsUtils.isMoneyRequestAction(reportAction) && reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) { + IOU.deleteMoneyRequest(reportAction?.originalMessage?.IOUTransactionID, reportAction); + } else if (reportAction) { + Report.deleteReportComment(reportIDRef.current, reportAction); } setIsDeleteCommentConfirmModalVisible(false); }, []); @@ -250,15 +254,8 @@ function PopoverReportActionContextMenu(_props, ref) { setHasUnreadMessages(false); }; - /** - * Opens the Confirm delete action modal - * @param {String} reportID - * @param {Object} reportAction - * @param {Boolean} [shouldSetModalVisibility] - * @param {Function} [onConfirm] - * @param {Function} [onCancel] - */ - const showDeleteModal = (reportID, reportAction, shouldSetModalVisibility = true, onConfirm = () => {}, onCancel = () => {}) => { + /** Opens the Confirm delete action modal */ + const showDeleteModal: ReportActionContextMenu['showDeleteModal'] = (reportID, reportAction, shouldSetModalVisibility = true, onConfirm = () => {}, onCancel = () => {}) => { onCancelDeleteModal.current = onCancel; onComfirmDeleteModal.current = onConfirm; diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts index 5b64d90da5da..13f21423c082 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts @@ -1,5 +1,6 @@ import React from 'react'; import type {RefObject} from 'react'; +// eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Text as RNText, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -33,7 +34,7 @@ type ShowContextMenu = ( type ReportActionContextMenu = { showContextMenu: ShowContextMenu; - hideContextMenu: (callback: OnHideCallback) => void; + hideContextMenu: (callback?: OnHideCallback) => void; showDeleteModal: (reportID: string, reportAction: OnyxEntry, shouldSetModalVisibility?: boolean, onConfirm?: OnConfirm, onCancel?: OnCancel) => void; hideDeleteModal: () => void; isActiveReportAction: (accountID: string | number) => boolean; @@ -100,7 +101,7 @@ function showContextMenu( reportID = '0', reportActionID = '0', originalReportID = '0', - draftMessage = undefined, + draftMessage: string | undefined = undefined, onShow = () => {}, onHide = () => {}, isArchivedRoom = false, @@ -175,3 +176,4 @@ function clearActiveReportAction() { } export {contextMenuRef, showContextMenu, hideContextMenu, isActiveReportAction, clearActiveReportAction, showDeleteModal, hideDeleteModal}; +export type {ContextMenuType, ShowContextMenu, ReportActionContextMenu}; diff --git a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js deleted file mode 100644 index b9f892c1b9ff..000000000000 --- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js +++ /dev/null @@ -1,34 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** The ID of the report this report action is attached to. */ - reportID: PropTypes.string.isRequired, - - /** The ID of the report action this context menu is attached to. */ - reportActionID: PropTypes.string.isRequired, - - /** The ID of the original report from which the given reportAction is first created. */ - originalReportID: PropTypes.string.isRequired, - - /** If true, this component will be a small, row-oriented menu that displays icons but not text. - If false, this component will be a larger, column-oriented menu that displays icons alongside text in each row. */ - isMini: PropTypes.bool, - - /** Controls the visibility of this component. */ - isVisible: PropTypes.bool, - - /** The copy selection. */ - selection: PropTypes.string, - - /** Draft message - if this is set the comment is in 'edit' mode */ - draftMessage: PropTypes.string, -}; - -const defaultProps = { - isMini: false, - isVisible: false, - selection: '', - draftMessage: undefined, -}; - -export {propTypes, defaultProps}; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 9573d4a4ff1a..34129a87d9b5 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -38,6 +38,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import ControlSelection from '@libs/ControlSelection'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import focusTextInputAfterAnimation from '@libs/focusTextInputAfterAnimation'; import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage'; @@ -439,7 +440,10 @@ function ReportActionItem(props) { ); } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTDEQUEUED) { - children = ; + const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(lodashGet(personalDetails, props.report.ownerAccountID)); + const amount = CurrencyUtils.convertToDisplayString(props.report.total, props.report.currency); + + children = ; } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) { children = ; } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED) { @@ -711,12 +715,14 @@ function ReportActionItem(props) { OptionsListUtils.getHeaderMessage( - newChatOptions.personalDetails.length + newChatOptions.recentReports.length !== 0, + _.get(newChatOptions, 'personalDetails.length', 0) + _.get(newChatOptions, 'recentReports.length', 0) !== 0, Boolean(newChatOptions.userToInvite), searchTerm.trim(), maxParticipantsReached, _.some(participants, (participant) => participant.searchText.toLowerCase().includes(searchTerm.trim().toLowerCase())), ), - [maxParticipantsReached, newChatOptions.personalDetails.length, newChatOptions.recentReports.length, newChatOptions.userToInvite, participants, searchTerm], + [maxParticipantsReached, newChatOptions, participants, searchTerm], ); // When search term updates we will fetch any reports diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index bbe703e50d18..9df2564ae38d 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -217,7 +217,6 @@ function IOURequestStepConfirmation({ // If we have a receipt let's start the split bill by creating only the action, the transaction, and the group DM if needed if (iouType === CONST.IOU.TYPE.SPLIT && receiptFile) { - const existingSplitChatReportID = CONST.REGEX.NUMBER.test(reportID) ? reportID : ''; IOU.startSplitBill( selectedParticipants, currentUserPersonalDetails.login, @@ -226,7 +225,7 @@ function IOURequestStepConfirmation({ transaction.category, transaction.tag, receiptFile, - existingSplitChatReportID, + report.reportID, ); return; } @@ -277,7 +276,7 @@ function IOURequestStepConfirmation({ requestMoney(selectedParticipants, trimmedComment); }, - [iouType, transaction, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, report, reportID, requestType, createDistanceRequest, requestMoney, receiptFile], + [iouType, transaction, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, report, requestType, createDistanceRequest, requestMoney, receiptFile], ); /** diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.js b/src/pages/iou/request/step/IOURequestStepScan/index.js index c0c96826d124..7c6efca4a32f 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.js +++ b/src/pages/iou/request/step/IOURequestStepScan/index.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import React, {useCallback, useContext, useReducer, useRef, useState} from 'react'; -import {ActivityIndicator, PanResponder, PixelRatio, Text, View} from 'react-native'; +import {ActivityIndicator, PanResponder, PixelRatio, View} from 'react-native'; import Hand from '@assets/images/hand.svg'; import ReceiptUpload from '@assets/images/receipt-upload.svg'; import Shutter from '@assets/images/shutter.svg'; @@ -12,6 +12,7 @@ import {DragAndDropContext} from '@components/DragAndDrop/Provider'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -135,13 +136,9 @@ function IOURequestStepScan({ const updateScanAndNavigate = useCallback( (file, source) => { IOU.replaceReceipt(transactionID, file, source); - if (backTo) { - Navigation.goBack(backTo); - return; - } Navigation.dismissModal(); }, - [backTo, transactionID], + [transactionID], ); /** diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js index 38bcd16faf39..23e30ce25711 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {ActivityIndicator, Alert, AppState, Text, View} from 'react-native'; +import {ActivityIndicator, Alert, AppState, View} from 'react-native'; import {RESULTS} from 'react-native-permissions'; import {useCameraDevices} from 'react-native-vision-camera'; import Hand from '@assets/images/hand.svg'; @@ -11,6 +11,7 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import ImageSVG from '@components/ImageSVG'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js index 27f9c0f04404..59c145f9e348 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js @@ -1,9 +1,10 @@ import React, {useState} from 'react'; -import {ScrollView, Text, View} from 'react-native'; +import {ScrollView, View} from 'react-native'; import ConfirmModal from '@components/ConfirmModal'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import Section from '@components/Section'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js index f1c3fbe90533..da77d1fa6a15 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef} from 'react'; -import {Text} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import FormProvider from '@components/Form/FormProvider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import * as FormActions from '@libs/actions/FormActions'; import * as Wallet from '@libs/actions/Wallet'; diff --git a/src/pages/settings/Wallet/ReportCardLostPage.js b/src/pages/settings/Wallet/ReportCardLostPage.js index b01dc99cb485..49b69188c377 100644 --- a/src/pages/settings/Wallet/ReportCardLostPage.js +++ b/src/pages/settings/Wallet/ReportCardLostPage.js @@ -23,14 +23,19 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import assignedCardPropTypes from './assignedCardPropTypes'; +const OPTIONS_KEYS = { + DAMAGED: 'damaged', + STOLEN: 'stolen', +}; + /** Options for reason selector */ const OPTIONS = [ { - key: 'damaged', + key: OPTIONS_KEYS.DAMAGED, label: 'reportCardLostOrDamaged.cardDamaged', }, { - key: 'stolen', + key: OPTIONS_KEYS.STOLEN, label: 'reportCardLostOrDamaged.cardLostOrStolen', }, ]; @@ -107,7 +112,7 @@ function ReportCardLostPage({ return; } - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain)); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain)); }, [domain, formData.isLoading, prevIsLoading, physicalCard.errors]); useEffect(() => { @@ -156,6 +161,8 @@ function ReportCardLostPage({ Navigation.goBack(ROUTES.SETTINGS_WALLET); }; + const isDamaged = reason && reason.key === OPTIONS_KEYS.DAMAGED; + return ( Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)} numberOfLinesTitle={2} /> - {translate('reportCardLostOrDamaged.currentCardInfo')} + {isDamaged ? ( + {translate('reportCardLostOrDamaged.cardDamagedInfo')} + ) : ( + {translate('reportCardLostOrDamaged.cardLostOrStolenInfo')} + )} ) : ( diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index fc42782cf562..92bc5ecc8e9c 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -480,6 +480,7 @@ function WorkspaceMembersPage(props) { SearchInputManager.searchInput = value; setSearchValue(value); }} + disableKeyboardShortcuts={removeMembersConfirmModalVisible} headerMessage={getHeaderMessage()} headerContent={getHeaderContent()} onSelectRow={(item) => toggleUser(item.accountID)} diff --git a/src/styles/index.ts b/src/styles/index.ts index 54326ec575df..aace13c34594 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -677,8 +677,11 @@ const styles = (theme: ThemeColors) => }, loadingVBAAnimation: { - width: 140, - height: 140, + width: '100%', + }, + + loadingVBAAnimationWeb: { + width: '100%', }, pickerSmall: (backgroundColor = theme.highlightBG) => @@ -2285,9 +2288,10 @@ const styles = (theme: ThemeColors) => }, reportActionContextMenuMiniButton: { - ...spacing.p1, - ...spacing.mv1, - ...spacing.mh1, + height: 28, + width: 28, + ...flex.alignItemsCenter, + ...flex.justifyContentCenter, ...{borderRadius: variables.buttonBorderRadius}, }, @@ -3322,8 +3326,8 @@ const styles = (theme: ThemeColors) => }, miniQuickEmojiReactionText: { - fontSize: 15, - lineHeight: 20, + fontSize: 18, + lineHeight: 22, verticalAlign: 'middle', }, diff --git a/src/styles/utils/generators/ReportActionContextMenuStyleUtils.ts b/src/styles/utils/generators/ReportActionContextMenuStyleUtils.ts index e45b5db98b53..e8761468f640 100644 --- a/src/styles/utils/generators/ReportActionContextMenuStyleUtils.ts +++ b/src/styles/utils/generators/ReportActionContextMenuStyleUtils.ts @@ -12,6 +12,10 @@ const getMiniWrapperStyle = (theme: ThemeColors, styles: ThemeStyles): ViewStyle styles.flexRow, getDefaultWrapperStyle(theme), { + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 4, + height: 36, borderRadius: variables.buttonBorderRadius, borderWidth: 1, borderColor: theme.border, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 08a89526e4c3..b11d48898af5 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -70,6 +70,7 @@ export default { iconSizeXXSmall: 8, iconSizeExtraSmall: 12, iconSizeSmall: 16, + iconSizeMedium: 18, iconSizeNormal: 20, iconSizeLarge: 24, iconSizeXLarge: 28, diff --git a/src/types/modules/react-native-google-places-autocomplete.d.ts b/src/types/modules/react-native-google-places-autocomplete.d.ts new file mode 100644 index 000000000000..442c941ed9cd --- /dev/null +++ b/src/types/modules/react-native-google-places-autocomplete.d.ts @@ -0,0 +1,15 @@ +import type {ViewProps} from 'react-native'; +import type {GooglePlacesAutocompleteProps as BaseGooglePlacesAutocompleteProps, Term} from 'react-native-google-places-autocomplete'; + +declare module 'react-native-google-places-autocomplete' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface GooglePlacesAutocompleteProps extends ViewProps, BaseGooglePlacesAutocompleteProps {} + + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface GooglePlaceData { + isPredefinedPlace: string; + name: string; + description: string; + terms?: Term[]; + } +} diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 09be2d9e04dd..f00fd8c4c972 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -37,7 +37,6 @@ type IOUMessage = { /** The ID of the iou transaction */ IOUTransactionID?: string; IOUReportID?: string; - expenseReportID?: string; amount: number; comment?: string; currency: string; @@ -45,15 +44,10 @@ type IOUMessage = { participantAccountIDs?: number[]; type: ValueOf; paymentType?: DeepValueOf; - cancellationReason?: string; /** Only exists when we are sending money */ IOUDetails?: IOUDetails; }; -type ReimbursementDeQueuedMessage = { - cancellationReason: string; -}; - type OriginalMessageIOU = { actionName: typeof CONST.REPORT.ACTIONS.TYPE.IOU; originalMessage: IOUMessage; @@ -241,7 +235,9 @@ type OriginalMessageReimbursementQueued = { type OriginalMessageReimbursementDequeued = { actionName: typeof CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTDEQUEUED; - originalMessage: unknown; + originalMessage: { + expenseReportID: string; + }; }; type OriginalMessageMoved = { @@ -280,11 +276,11 @@ export type { Reaction, ActionName, IOUMessage, - ReimbursementDeQueuedMessage, Closed, OriginalMessageActionName, ChangeLog, OriginalMessageIOU, OriginalMessageCreated, OriginalMessageAddComment, + OriginalMessageReimbursementDequeued, }; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index da4522487a7a..81e5b8392ee2 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -5,18 +5,25 @@ import type * as OnyxCommon from './OnyxCommon'; type Unit = 'mi' | 'km'; type Rate = { - name: string; - rate: number; - currency: string; + name?: string; + rate?: number; + currency?: string; + customUnitRateID?: string; + errors?: OnyxCommon.Errors; + pendingAction?: string; +}; + +type Attributes = { + unit: Unit; }; type CustomUnit = { - customUnitID?: string; - name?: string; - attributes: { - unit: Unit; - }; - rates?: Record; + name: string; + customUnitID: string; + attributes: Attributes; + rates: Record; + pendingAction?: string; + errors?: OnyxCommon.Errors; }; type Policy = { @@ -82,8 +89,23 @@ type Policy = { /** The employee list of the policy */ employeeList?: []; + + /** Whether to leave the calling account as an admin on the policy */ + makeMeAdmin?: boolean; + + /** Pending fields for the policy */ + pendingFields?: Record; + + /** Original file name which is used for the policy avatar */ + originalFileName?: string; + + /** Alert message for the policy */ + alertMessage?: string; + + /** Informative messages about which policy members were added with primary logins when invited with their secondary login */ + primaryLoginsInvited?: Record; }; export default Policy; -export type {Unit}; +export type {Unit, CustomUnit}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 7cc3c508d926..3c815bfa2a1f 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -116,6 +116,7 @@ type Report = { welcomeMessage?: string; lastActorAccountID?: number; ownerAccountID?: number; + ownerEmail?: string; participantAccountIDs?: number[]; visibleChatMemberAccountIDs?: number[]; total?: number; diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts index d5c0e19f9373..3dbd4c1e3667 100644 --- a/src/types/onyx/ReportAction.ts +++ b/src/types/onyx/ReportAction.ts @@ -54,12 +54,6 @@ type Message = { /** ID of a task report */ taskReportID?: string; - /** Reason of payment cancellation */ - cancellationReason?: string; - - /** ID of an expense report */ - expenseReportID?: string; - /** resolution for actionable mention whisper */ resolution?: ValueOf | null; }; diff --git a/src/types/onyx/WalletTerms.ts b/src/types/onyx/WalletTerms.ts index b8fcdfeabe3e..f0563310859a 100644 --- a/src/types/onyx/WalletTerms.ts +++ b/src/types/onyx/WalletTerms.ts @@ -1,3 +1,5 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; import type * as OnyxCommon from './OnyxCommon'; type WalletTerms = { @@ -7,8 +9,8 @@ type WalletTerms = { /** When the user accepts the Wallet's terms in order to pay an IOU, this is the ID of the chatReport the IOU is linked to */ chatReportID?: string; - /** Source that triggered the KYC wall */ - source?: string; + /** The source that triggered the KYC wall */ + source?: ValueOf; /** Loading state to provide feedback when we are waiting for a request to finish */ isLoading?: boolean; diff --git a/src/types/utils/textRef.ts b/src/types/utils/textRef.ts index 4e80f00b7bc6..668f54d59e36 100644 --- a/src/types/utils/textRef.ts +++ b/src/types/utils/textRef.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import type {Text} from 'react-native'; const textRef = (ref: React.RefObject) => ref as React.RefObject; diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js index 4a363d1de36b..ebffc71e4e0e 100644 --- a/tests/unit/MigrationTest.js +++ b/tests/unit/MigrationTest.js @@ -51,8 +51,8 @@ describe('Migrations', () => { it('Should remove any individual reportActions that have no data in Onyx', () => Onyx.multiSet({ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: {}, - 2: {}, + 1: null, + 2: null, }, }) .then(PersonalDetailsByAccountID) diff --git a/tests/unit/ReportActionsUtilsTest.js b/tests/unit/ReportActionsUtilsTest.js index 19a89d1c892c..b8b6eb5e7673 100644 --- a/tests/unit/ReportActionsUtilsTest.js +++ b/tests/unit/ReportActionsUtilsTest.js @@ -368,7 +368,7 @@ describe('ReportActionsUtils', () => { callback: () => { Onyx.disconnect(connectionID); const res = ReportActionsUtils.getLastVisibleAction(report.reportID); - expect(res).toEqual(action2); + expect(res).toBe(action2); resolve(); }, });