diff --git a/__mocks__/@react-native-community/netinfo.ts b/__mocks__/@react-native-community/netinfo.ts index 0b7bdc9010a3..db0d34e2276d 100644 --- a/__mocks__/@react-native-community/netinfo.ts +++ b/__mocks__/@react-native-community/netinfo.ts @@ -2,12 +2,12 @@ import {NetInfoCellularGeneration, NetInfoStateType} from '@react-native-communi import type {addEventListener, configure, fetch, NetInfoState, refresh, useNetInfo} from '@react-native-community/netinfo'; const defaultState: NetInfoState = { - type: NetInfoStateType.cellular, + type: NetInfoStateType?.cellular, isConnected: true, isInternetReachable: true, details: { isConnectionExpensive: true, - cellularGeneration: NetInfoCellularGeneration['3g'], + cellularGeneration: NetInfoCellularGeneration?.['3g'], carrier: 'T-Mobile', }, }; diff --git a/android/app/build.gradle b/android/app/build.gradle index 1ee3d764415e..fc376ad08862 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 1001044305 - versionName "1.4.43-5" + versionCode 1001044309 + versionName "1.4.43-9" } flavorDimensions "default" diff --git a/docs/articles/expensify-classic/getting-started/Invite-Members.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members.md similarity index 100% rename from docs/articles/expensify-classic/getting-started/Invite-Members.md rename to docs/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members.md diff --git a/docs/articles/expensify-classic/settings/account-settings/Manage-devices.md b/docs/articles/expensify-classic/settings/account-settings/Manage-devices.md new file mode 100644 index 000000000000..864c59a7472a --- /dev/null +++ b/docs/articles/expensify-classic/settings/account-settings/Manage-devices.md @@ -0,0 +1,18 @@ +--- +title: Manage devices +description: Control which devices can access your Expensify account +--- +
+ +You can see which devices have been used to access your Expensify account and even remove devices that you no longer want to have access to your account. + +{% include info.html %} +This process is currently not available from the mobile app and must be completed from the Expensify website. +{% include end-info.html %} + +1. Hover over Settings and click **Account**. +2. Under Account Details, scroll down to the Device Management section. +3. Click **Device Management** to expand the section. +4. Review the devices that have access to your account. To remove access for a specific device, click **Revoke** next to it. + +
diff --git a/docs/articles/expensify-classic/settings/account-settings/Set-notifications.md b/docs/articles/expensify-classic/settings/account-settings/Set-notifications.md new file mode 100644 index 000000000000..2d561ea598d9 --- /dev/null +++ b/docs/articles/expensify-classic/settings/account-settings/Set-notifications.md @@ -0,0 +1,15 @@ +--- +title: Set notifications +description: Select your Expensify notification preferences +--- +
+ +{% include info.html %} +This process is currently not available from the mobile app and must be completed from the Expensify website. +{% include end-info.html %} + +1. Hover over Settings and click **Account**. +2. Click the **Preferences** tab on the left. +3. Scroll down to the Contact Preferences section. +4. Select the checkbox for the types of notifications you wish to receive. +
diff --git a/docs/redirects.csv b/docs/redirects.csv index 76b7bac3fc99..8e160e3bcdf2 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -54,3 +54,4 @@ https://help.expensify.com/articles/expensify-classic/getting-started/Employees, https://help.expensify.com/articles/expensify-classic/getting-started/Using-The-App,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace https://help.expensify.com/articles/expensify-classic/getting-started/support/Expensify-Support,https://use.expensify.com/support https://help.expensify.com/articles/expensify-classic/getting-started/Plan-Types,https://use.expensify.com/ +https://help.expensify.com/articles/new-expensify/payments/Referral-Program,https://help.expensify.com/articles/expensify-classic/get-paid-back/Referral-Program diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 1eca5a5021f7..93faff6ab427 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.43.5 + 1.4.43.9 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index e4453165b29a..85d5f45e4184 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.43.5 + 1.4.43.9 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 1bb165389913..6b0cc0c08d14 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.43 CFBundleVersion - 1.4.43.5 + 1.4.43.9 NSExtension NSExtensionPointIdentifier diff --git a/jest.config.js b/jest.config.js index 95ecc350ed9f..441507af4228 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ module.exports = { `/?(*.)+(spec|test).${testFileExtension}`, ], transform: { - '^.+\\.jsx?$': 'babel-jest', + '^.+\\.[jt]sx?$': 'babel-jest', '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: ['/node_modules/(?!react-native)/'], diff --git a/package-lock.json b/package-lock.json index 93951ba0f241..c114de61408f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.43-5", + "version": "1.4.43-9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.43-5", + "version": "1.4.43-9", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 76e3f689ca39..66d60bcd87cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.43-5", + "version": "1.4.43-9", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -50,8 +50,8 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "test:e2e": "ts-node tests/e2e/testRunner.js --development --skipCheckout --skipInstallDeps --buildMode none", - "test:e2e:dev": "ts-node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps", + "test:e2e": "ts-node tests/e2e/testRunner.js --config ./config.local.ts", + "test:e2e:dev": "ts-node tests/e2e/testRunner.js --config ./config.dev.js", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.js", diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c5480d363019..c41ef521661c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -84,28 +84,28 @@ const ROUTES = { SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', SETTINGS_WALLET: 'settings/wallet', SETTINGS_WALLET_DOMAINCARD: { - route: '/settings/wallet/card/:domain', - getRoute: (domain: string) => `/settings/wallet/card/${domain}` as const, + route: 'settings/wallet/card/:domain', + getRoute: (domain: string) => `settings/wallet/card/${domain}` as const, }, SETTINGS_REPORT_FRAUD: { - route: '/settings/wallet/card/:domain/report-virtual-fraud', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud` as const, + route: 'settings/wallet/card/:domain/report-virtual-fraud', + getRoute: (domain: string) => `settings/wallet/card/${domain}/report-virtual-fraud` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: { - route: '/settings/wallet/card/:domain/get-physical/name', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name` as const, + route: 'settings/wallet/card/:domain/get-physical/name', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/name` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: { - route: '/settings/wallet/card/:domain/get-physical/phone', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone` as const, + route: 'settings/wallet/card/:domain/get-physical/phone', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/phone` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: { - route: '/settings/wallet/card/:domain/get-physical/address', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address` as const, + route: 'settings/wallet/card/:domain/get-physical/address', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/address` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: { - route: '/settings/wallet/card/:domain/get-physical/confirm', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm` as const, + route: 'settings/wallet/card/:domain/get-physical/confirm', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/confirm` as const, }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', @@ -117,8 +117,8 @@ const ROUTES = { SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED: { - route: '/settings/wallet/card/:domain/report-card-lost-or-damaged', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged` as const, + route: 'settings/wallet/card/:domain/report-card-lost-or-damaged', + getRoute: (domain: string) => `settings/wallet/card/${domain}/report-card-lost-or-damaged` as const, }, SETTINGS_WALLET_CARD_ACTIVATE: { route: 'settings/wallet/card/:domain/activate', @@ -219,6 +219,10 @@ const ROUTES = { route: 'r/:reportID/settings/who-can-post', getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post` as const, }, + REPORT_SETTINGS_VISIBILITY: { + route: 'r/:reportID/settings/visibility', + getRoute: (reportID: string) => `r/${reportID}/settings/visibility` as const, + }, SPLIT_BILL_DETAILS: { route: 'r/:reportID/split/:reportActionID', getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index ee3c64e8d804..18754a3513c1 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -167,6 +167,7 @@ const SCREENS = { ROOM_NAME: 'Report_Settings_Room_Name', NOTIFICATION_PREFERENCES: 'Report_Settings_Notification_Preferences', WRITE_CAPABILITY: 'Report_Settings_Write_Capability', + VISIBILITY: 'Report_Settings_Visibility', }, NEW_TASK: { diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 25dc99459064..5b5e99ac0621 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -1,4 +1,5 @@ -import React, {forwardRef, useEffect} from 'react'; +import {useIsFocused} from '@react-navigation/native'; +import React, {forwardRef, useEffect, useRef} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; @@ -23,15 +24,28 @@ type CountrySelectorProps = { /** inputID used by the Form component */ // eslint-disable-next-line react/no-unused-prop-types inputID: string; + + /** Callback to call when the picker modal is dismissed */ + onBlur?: () => void; }; -function CountrySelector({errorText = '', value: countryCode, onInputChange}: CountrySelectorProps, ref: ForwardedRef) { +function CountrySelector({errorText = '', value: countryCode, onInputChange, onBlur}: CountrySelectorProps, ref: ForwardedRef) { const styles = useThemeStyles(); const {translate} = useLocalize(); const title = countryCode ? translate(`allCountries.${countryCode}`) : ''; const countryTitleDescStyle = title.length === 0 ? styles.textNormal : null; + const didOpenContrySelector = useRef(false); + const isFocused = useIsFocused(); + useEffect(() => { + if (!isFocused || !didOpenContrySelector.current) { + return; + } + didOpenContrySelector.current = false; + onBlur?.(); + }, [isFocused, onBlur]); + useEffect(() => { // This will cause the form to revalidate and remove any error related to country name onInputChange(countryCode); @@ -48,6 +62,7 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange}: Co description={translate('common.country')} onPress={() => { const activeRoute = Navigation.getActiveRouteWithoutParams(); + didOpenContrySelector.current = true; Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); }} /> diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index 49915ebfbf1b..f8c4a12ec188 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -79,6 +80,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear showScrollIndicator shouldStopPropagation shouldUseDynamicMaxToRenderPerBatch + ListItem={RadioListItem} /> diff --git a/src/components/DistanceRequest/index.tsx b/src/components/DistanceRequest/index.tsx index 29987f716565..3f74c148de70 100644 --- a/src/components/DistanceRequest/index.tsx +++ b/src/components/DistanceRequest/index.tsx @@ -28,6 +28,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Report, Transaction} from '@src/types/onyx'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import DistanceRequestFooter from './DistanceRequestFooter'; import DistanceRequestRenderItem from './DistanceRequestRenderItem'; @@ -176,7 +177,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit ); }; - const getError = () => { + const getError = useCallback(() => { // Get route error if available else show the invalid number of waypoints error. if (hasRouteError) { return ErrorUtils.getLatestErrorField((transaction ?? {}) as Transaction, 'route'); @@ -186,8 +187,12 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit // eslint-disable-next-line @typescript-eslint/naming-convention return {0: 'iou.error.atLeastTwoDifferentWaypoints'}; } - return {}; - }; + + if (Object.keys(validatedWaypoints).length < Object.keys(waypoints).length) { + // eslint-disable-next-line @typescript-eslint/naming-convention + return {0: translate('iou.error.duplicateWaypointsErrorMessage')}; + } + }, [translate, transaction, hasRouteError, validatedWaypoints, waypoints]); const updateWaypoints = useCallback( ({data}: DraggableListData) => { @@ -211,7 +216,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit const submitWaypoints = useCallback(() => { // If there is any error or loading state, don't let user go to next page. - if (Object.keys(validatedWaypoints).length < 2 || hasRouteError || isLoadingRoute || (isLoading && !isOffline)) { + if (!isEmptyObject(getError()) || isLoadingRoute || (isLoading && !isOffline)) { setHasError(true); return; } @@ -221,7 +226,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit } onSubmit(waypoints); - }, [onSubmit, setHasError, hasRouteError, isLoadingRoute, isLoading, validatedWaypoints, waypoints, isEditingNewRequest, isEditingRequest, isOffline]); + }, [onSubmit, setHasError, getError, isLoadingRoute, isLoading, waypoints, isEditingNewRequest, isEditingRequest, isOffline]); const content = ( <> @@ -254,10 +259,10 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} - {((hasError && Object.keys(validatedWaypoints).length < 2) || hasRouteError) && ( + {((hasError && !isEmptyObject(getError())) || hasRouteError) && ( )} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index 863fe6fbabb1..465a4f747bcb 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -65,7 +65,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { // eslint-disable-next-line react/jsx-props-no-multi-spaces target={htmlAttribs.target || '_blank'} rel={htmlAttribs.rel || 'noopener noreferrer'} - style={[parentStyle, styles.textUnderlinePositionUnder, styles.textDecorationSkipInkNone, style]} + style={[style, parentStyle, styles.textUnderlinePositionUnder, styles.textDecorationSkipInkNone]} key={key} // Only pass the press handler for internal links. For public links or whitelisted internal links fallback to default link handling onPress={internalNewExpensifyPath || internalExpensifyPath ? () => Link.openLink(attrHref, environmentURL, isAttachment) : undefined} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 8e0ce759d021..f2e38ccb74af 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -10,7 +10,6 @@ import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; @@ -27,7 +26,6 @@ type MentionUserRendererProps = WithCurrentUserPersonalDetailsProps & CustomRend function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersonalDetails, ...defaultRendererProps}: MentionUserRendererProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); const htmlAttribAccountID = tnode.attributes.accountid; const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; @@ -39,7 +37,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona const user = personalDetails[htmlAttribAccountID]; accountID = parseInt(htmlAttribAccountID, 10); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || user?.displayName || translate('common.hidden'); + displayNameOrLogin = PersonalDetailsUtils.getDisplayNameOrDefault(user, LocalePhoneNumber.formatPhoneNumber(user?.login ?? '')); navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID); } else if ('data' in tnode && !isEmptyObject(tnode.data)) { // We need to remove the LTR unicode and leading @ from data as it is not part of the login diff --git a/src/components/Image/index.js b/src/components/Image/index.js index ef1a69e19c12..59fcde8273fd 100644 --- a/src/components/Image/index.js +++ b/src/components/Image/index.js @@ -3,12 +3,15 @@ import React, {useEffect, useMemo} from 'react'; import {Image as RNImage} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import useNetwork from '@hooks/useNetwork'; import ONYXKEYS from '@src/ONYXKEYS'; import {defaultProps, imagePropTypes} from './imagePropTypes'; import RESIZE_MODES from './resizeModes'; function Image(props) { const {source: propsSource, isAuthTokenRequired, onLoad, session} = props; + const {isOffline} = useNetwork(); + /** * Check if the image source is a URL - if so the `encryptedAuthToken` is appended * to the source. @@ -39,7 +42,7 @@ function Image(props) { RNImage.getSize(source.uri, (width, height) => { onLoad({nativeEvent: {width, height}}); }); - }, [onLoad, source]); + }, [onLoad, source, isOffline]); // Omit the props which the underlying RNImage won't use const forwardedProps = _.omit(props, ['source', 'onLoad', 'session', 'isAuthTokenRequired']); diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index b3fc1dc91c16..0ca4a0456e33 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -2,6 +2,7 @@ import delay from 'lodash/delay'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import type {ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import FullscreenLoadingIndicator from './FullscreenLoadingIndicator'; @@ -44,16 +45,27 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); + const {isOffline} = useNetwork(); const source = useMemo(() => ({uri: url}), [url]); const onError = () => { Log.hmmm('Unable to fetch image to calculate size', {url}); onLoadFailure?.(); + if (isLoadedRef.current) { + isLoadedRef.current = false; + setIsImageCached(false); + } + if (isOffline) { + return; + } + setIsLoading(false); }; const imageLoadedSuccessfully = (event: OnLoadNativeEvent) => { isLoadedRef.current = true; + setIsLoading(false); + setIsImageCached(true); onMeasure({ width: event.nativeEvent.width, height: event.nativeEvent.height, @@ -87,10 +99,6 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT } setIsLoading(true); }} - onLoadEnd={() => { - setIsLoading(false); - setIsImageCached(true); - }} onError={onError} onLoad={imageLoadedSuccessfully} /> diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index a3178f642852..77447f13644c 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -30,7 +30,21 @@ const MapView = forwardRef( const [isIdle, setIsIdle] = useState(false); const [currentPosition, setCurrentPosition] = useState(cachedUserLocation); const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); - const hasAskedForLocationPermission = useRef(false); + const shouldInitializeCurrentPosition = useRef(true); + + // Determines if map can be panned to user's detected + // location without bothering the user. It will return + // false if user has already started dragging the map or + // if there are one or more waypoints present. + const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + + const setCurrentPositionToInitialState = useCallback(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (cachedUserLocation || !initialState) { + return; + } + setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); + }, [initialState, cachedUserLocation]); useFocusEffect( useCallback(() => { @@ -38,34 +52,24 @@ const MapView = forwardRef( return; } - if (hasAskedForLocationPermission.current) { + if (!shouldInitializeCurrentPosition.current) { return; } - hasAskedForLocationPermission.current = true; - getCurrentPosition( - (params) => { - const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; - setCurrentPosition(currentCoords); - setUserLocation(currentCoords); - }, - () => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (cachedUserLocation || !initialState) { - return; - } - - setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); - }, - ); - }, [cachedUserLocation, initialState, isOffline]), - ); + shouldInitializeCurrentPosition.current = false; - // Determines if map can be panned to user's detected - // location without bothering the user. It will return - // false if user has already started dragging the map or - // if there are one or more waypoints present. - const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + if (!shouldPanMapToCurrentPosition()) { + setCurrentPositionToInitialState(); + return; + } + + getCurrentPosition((params) => { + const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; + setCurrentPosition(currentCoords); + setUserLocation(currentCoords); + }, setCurrentPositionToInitialState); + }, [isOffline, shouldPanMapToCurrentPosition, setCurrentPositionToInitialState]), + ); useEffect(() => { if (!currentPosition || !cameraRef.current) { diff --git a/src/components/MapView/MapView.website.tsx b/src/components/MapView/MapView.website.tsx index 289f7d0d62a8..05be6d6409e8 100644 --- a/src/components/MapView/MapView.website.tsx +++ b/src/components/MapView/MapView.website.tsx @@ -53,7 +53,21 @@ const MapView = forwardRef( const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); const [shouldResetBoundaries, setShouldResetBoundaries] = useState(false); const setRef = useCallback((newRef: MapRef | null) => setMapRef(newRef), []); - const hasAskedForLocationPermission = useRef(false); + const shouldInitializeCurrentPosition = useRef(true); + + // Determines if map can be panned to user's detected + // location without bothering the user. It will return + // false if user has already started dragging the map or + // if there are one or more waypoints present. + const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + + const setCurrentPositionToInitialState = useCallback(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (cachedUserLocation || !initialState) { + return; + } + setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); + }, [initialState, cachedUserLocation]); useFocusEffect( useCallback(() => { @@ -61,34 +75,24 @@ const MapView = forwardRef( return; } - if (hasAskedForLocationPermission.current) { + if (!shouldInitializeCurrentPosition.current) { return; } - hasAskedForLocationPermission.current = true; - getCurrentPosition( - (params) => { - const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; - setCurrentPosition(currentCoords); - setUserLocation(currentCoords); - }, - () => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (cachedUserLocation || !initialState) { - return; - } - - setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); - }, - ); - }, [cachedUserLocation, initialState, isOffline]), - ); + shouldInitializeCurrentPosition.current = false; - // Determines if map can be panned to user's detected - // location without bothering the user. It will return - // false if user has already started dragging the map or - // if there are one or more waypoints present. - const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + if (!shouldPanMapToCurrentPosition()) { + setCurrentPositionToInitialState(); + return; + } + + getCurrentPosition((params) => { + const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; + setCurrentPosition(currentCoords); + setUserLocation(currentCoords); + }, setCurrentPositionToInitialState); + }, [isOffline, shouldPanMapToCurrentPosition, setCurrentPositionToInitialState]), + ); useEffect(() => { if (!currentPosition || !mapRef) { diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 8fc9c62bfb38..1c2a8a3197fe 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -573,10 +573,12 @@ function MenuItem( {badgeText && ( diff --git a/src/components/QRShare/index.tsx b/src/components/QRShare/index.tsx index 45a4a4fd4964..c7e9e7637a6c 100644 --- a/src/components/QRShare/index.tsx +++ b/src/components/QRShare/index.tsx @@ -9,15 +9,12 @@ import QRCode from '@components/QRCode'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import variables from '@styles/variables'; -import CONST from '@src/CONST'; import type {QRShareHandle, QRShareProps} from './types'; function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRShareProps, ref: ForwardedRef) { const styles = useThemeStyles(); const theme = useTheme(); - const {isSmallScreenWidth} = useWindowDimensions(); const [qrCodeSize, setQrCodeSize] = useState(1); const svgRef = useRef(); @@ -32,11 +29,7 @@ function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRSha const onLayout = (event: LayoutChangeEvent) => { const containerWidth = event.nativeEvent.layout.width - variables.qrShareHorizontalPadding * 2 || 0; - if (isSmallScreenWidth) { - setQrCodeSize(Math.max(1, containerWidth)); - return; - } - setQrCodeSize(Math.max(1, Math.min(containerWidth, CONST.CENTRAL_PANE_ANIMATION_HEIGHT))); + setQrCodeSize(Math.max(1, containerWidth)); }; return ( diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index e2f7314afd73..4137b259f362 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -4,6 +4,7 @@ import lodashSortBy from 'lodash/sortBy'; import React from 'react'; import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; +import ConfirmedRoute from '@components/ConfirmedRoute'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestSkeletonView from '@components/MoneyRequestSkeletonView'; @@ -114,6 +115,9 @@ function MoneyRequestPreviewContent({ const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(transaction)] : []; + const hasPendingWaypoints = transaction?.pendingFields?.waypoints; + const showMapAsImage = isDistanceRequest && hasPendingWaypoints; + const getSettledMessage = (): string => { if (isCardTransaction) { return translate('common.done'); @@ -206,7 +210,12 @@ function MoneyRequestPreviewContent({ !onPreviewPressed ? [styles.moneyRequestPreviewBox, containerStyles] : {}, ]} > - {hasReceipt && ( + {showMapAsImage && ( + + + + )} + {!showMapAsImage && hasReceipt && ( - {hasReceipt && ( + {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} + {(showMapAsImage || hasReceipt) && ( - + {showMapAsImage ? ( + + ) : ( + + )} )} diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 9bfeacbc0ac2..c39d7a05a4f7 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -4,34 +4,31 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import RadioListItem from './RadioListItem'; import type {BaseListItemProps, ListItem} from './types'; -import UserListItem from './UserListItem'; function BaseListItem({ item, - isFocused = false, + wrapperStyle, + selectMultipleStyle, isDisabled = false, - showTooltip, shouldPreventDefaultFocusOnSelectRow = false, canSelectMultiple = false, onSelectRow, onDismissError = () => {}, rightHandSideComponent, keyForList, + errors, + pendingAction, + FooterComponent, + children, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - const isUserItem = 'icons' in item && item?.icons?.length && item.icons.length > 0; - const ListItem = isUserItem ? UserListItem : RadioListItem; const rightHandSideComponentRender = () => { if (canSelectMultiple || !rightHandSideComponent) { @@ -48,8 +45,8 @@ function BaseListItem({ return ( onDismissError(item)} - pendingAction={isUserItem ? item.pendingAction : undefined} - errors={isUserItem ? item.errors : undefined} + pendingAction={pendingAction} + errors={errors} errorRowStyles={styles.ph5} > ({ > {({hovered}) => ( <> - + {canSelectMultiple && ( - + {item.isSelected && ( ({ )} - onSelectRow(item)} - showTooltip={showTooltip} - isFocused={isFocused} - isHovered={hovered} - /> + {typeof children === 'function' ? children(hovered) : children} {!canSelectMultiple && item.isSelected && !rightHandSideComponent && ( ({ )} {rightHandSideComponentRender()} - {isUserItem && item.invitedSecondaryLogin && ( - - {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} - - )} + {FooterComponent} )} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 850874b7abc0..b0996a08895a 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -22,12 +22,12 @@ import Log from '@libs/Log'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import BaseListItem from './BaseListItem'; import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, Section, SectionListDataType} from './types'; function BaseSelectionList( { sections, + ListItem, canSelectMultiple = false, onSelectRow, onSelectAll, @@ -280,7 +280,7 @@ function BaseSelectionList( const showTooltip = shouldShowTooltips && normalizedIndex < 10; return ( - - - - {!!item.alternateText && ( + + - )} - + + {!!item.alternateText && ( + + )} + + ); } diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index 60e97d887b4d..759c29013b5d 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -2,61 +2,107 @@ import React from 'react'; import {View} from 'react-native'; import MultipleAvatars from '@components/MultipleAvatars'; import SubscriptAvatar from '@components/SubscriptAvatar'; +import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; +import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ListItemProps} from './types'; +import BaseListItem from './BaseListItem'; +import type {UserListItemProps} from './types'; -function UserListItem({item, textStyles, alternateTextStyles, showTooltip, style, isFocused, isHovered}: ListItemProps) { +function UserListItem({ + item, + isFocused, + showTooltip, + isDisabled, + canSelectMultiple, + onSelectRow, + onDismissError, + shouldPreventDefaultFocusOnSelectRow, + rightHandSideComponent, +}: UserListItemProps) { const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; const subscriptAvatarBorderColor = isFocused ? focusedBackgroundColor : theme.sidebar; const hoveredBackgroundColor = !!styles.sidebarLinkHover && 'backgroundColor' in styles.sidebarLinkHover ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; return ( - <> - {!!item.icons && ( + + {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} + + ) : undefined + } + keyForList={item.keyForList} + > + {(hovered) => ( <> - {item.shouldShowSubscript ? ( - - ) : ( - + {item.shouldShowSubscript ? ( + + ) : ( + + )} + + )} + + - )} + {!!item.alternateText && ( + + )} + + {!!item.rightElement && item.rightElement} )} - - - {!!item.alternateText && ( - - )} - - {!!item.rightElement && item.rightElement} - + ); } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 6ef0fb742dc3..403ccd91a26b 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -1,18 +1,15 @@ import type {ReactElement, ReactNode} from 'react'; import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import type RadioListItem from './RadioListItem'; +import type UserListItem from './UserListItem'; type CommonListItemProps = { /** Whether this item is focused (for arrow key controls) */ isFocused?: boolean; - /** Style to be applied to Text */ - textStyles?: StyleProp; - - /** Style to be applied on the alternate text */ - alternateTextStyles?: StyleProp; - /** Whether this item is disabled */ isDisabled?: boolean; @@ -30,6 +27,12 @@ type CommonListItemProps = { /** Component to display on the right side */ rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null; + + /** Styles for the wrapper view */ + wrapperStyle?: StyleProp; + + /** Styles for the checkbox wrapper view if select multiple option is on */ + selectMultipleStyle?: StyleProp; }; type ListItem = { @@ -87,14 +90,37 @@ type ListItemProps = CommonListItemProps & { /** Is item hovered */ isHovered?: boolean; + + /** Whether the default focus should be prevented on row selection */ + shouldPreventDefaultFocusOnSelectRow?: boolean; + + /** Key used internally by React */ + keyForList?: string; }; type BaseListItemProps = CommonListItemProps & { item: TItem; shouldPreventDefaultFocusOnSelectRow?: boolean; keyForList?: string; + errors?: Errors | ReceiptErrors | null; + pendingAction?: PendingAction | null; + FooterComponent?: ReactElement; + children?: ReactElement | ((hovered: boolean) => ReactElement); }; +type UserListItemProps = ListItemProps & { + /** Errors that this user may contain */ + errors?: Errors | ReceiptErrors | null; + + /** The type of action that's pending */ + pendingAction?: PendingAction | null; + + /** The React element that will be shown as a footer */ + FooterComponent?: ReactElement; +}; + +type RadioListItemProps = ListItemProps; + type Section = { /** Title of the section */ title?: string; @@ -116,6 +142,9 @@ type BaseSelectionListProps = Partial & { /** Sections for the section list */ sections: Array>>; + /** Default renderer for every item in the list */ + ListItem: typeof RadioListItem | typeof UserListItem; + /** Whether this is a multi-select list */ canSelectMultiple?: boolean; @@ -210,7 +239,7 @@ type BaseSelectionListProps = Partial & { shouldDelayFocus?: boolean; /** Component to display on the right side of each child */ - rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null; + rightHandSideComponent?: ((item: ListItem) => ReactElement) | ReactElement | null; /** Whether to show the loading indicator for new options */ isLoadingNewOptions?: boolean; @@ -241,6 +270,8 @@ export type { CommonListItemProps, Section, BaseListItemProps, + UserListItemProps, + RadioListItemProps, ListItem, ListItemProps, FlattenedSectionsReturn, diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 798d3be7a698..c09c7a25e375 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -4,6 +4,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchCountryOptions from '@libs/searchCountryOptions'; @@ -100,6 +101,7 @@ function StateSelectorModal({currentState, isVisible, onClose = () => {}, onStat initiallyFocusedOptionKey={currentState} shouldStopPropagation shouldUseDynamicMaxToRenderPerBatch + ListItem={RadioListItem} /> diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js index 94b91c66f154..341ea9cddae9 100644 --- a/src/components/TagPicker/index.js +++ b/src/components/TagPicker/index.js @@ -20,7 +20,7 @@ function TagPicker({selectedTag, tag, tagIndex, policyTags, policyRecentlyUsedTa const policyRecentlyUsedTagsList = lodashGet(policyRecentlyUsedTags, tag, []); const policyTagList = PolicyUtils.getTagList(policyTags, tagIndex); - const policyTagsCount = PolicyUtils.getCountOfEnabledTagsOfList(policyTagList); + const policyTagsCount = PolicyUtils.getCountOfEnabledTagsOfList(policyTagList.tags); const isTagsCountBelowThreshold = policyTagsCount < CONST.TAG_LIST_THRESHOLD; const shouldShowTextInput = !isTagsCountBelowThreshold; diff --git a/src/components/ValuePicker/ValueSelectorModal.tsx b/src/components/ValuePicker/ValueSelectorModal.tsx index 1e7c6088241d..fad59d4e48e4 100644 --- a/src/components/ValuePicker/ValueSelectorModal.tsx +++ b/src/components/ValuePicker/ValueSelectorModal.tsx @@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {ValueSelectorModalProps} from './types'; @@ -40,6 +41,7 @@ function ValueSelectorModal({items = [], selectedItem, label = '', isVisible, on initiallyFocusedOptionKey={selectedItem?.value} shouldStopPropagation shouldShowTooltips={shouldShowTooltips} + ListItem={RadioListItem} /> diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js index 9e6069e4d979..595442c317d5 100644 --- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js +++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js @@ -5,7 +5,11 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import Image from '@components/Image'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; import useThemeStyles from '@hooks/useThemeStyles'; +import ControlSelection from '@libs/ControlSelection'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as ReportUtils from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -35,22 +39,31 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}) { /> )} - - - - - + + {({anchor, report, action, checkIfContextMenuActive}) => ( + DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onLongPress={(event) => + showContextMenuForReport(event, anchor, (report && report.reportID) || '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report)) + } + > + + + + + )} + ); } diff --git a/src/components/__mocks__/ConfirmedRoute.tsx b/src/components/__mocks__/ConfirmedRoute.tsx new file mode 100644 index 000000000000..3c78e764ebea --- /dev/null +++ b/src/components/__mocks__/ConfirmedRoute.tsx @@ -0,0 +1,8 @@ +import {View} from 'react-native'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any +function ConfirmedRoute(props: any) { + return ; +} + +export default ConfirmedRoute; diff --git a/src/languages/en.ts b/src/languages/en.ts index da7a1d0b7586..0553d6470ddc 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -26,6 +26,7 @@ import type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, + HeldRequestParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, @@ -666,8 +667,10 @@ export default { waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`, enableWallet: 'Enable Wallet', hold: 'Hold', - holdRequest: 'Hold Request', - unholdRequest: 'Unhold Request', + holdRequest: 'Hold request', + unholdRequest: 'Unhold request', + heldRequest: ({comment}: HeldRequestParams) => `held this request with the comment: ${comment}`, + unheldRequest: 'unheld this request', explainHold: "Explain why you're holding this request.", reason: 'Reason', holdReasonRequired: 'A reason is required when holding.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 23cf8be8c30c..2a2eb96bd488 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -25,6 +25,7 @@ import type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, + HeldRequestParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, @@ -660,8 +661,10 @@ export default { }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `IniciΓ³ el pago, pero no se procesarΓ‘ hasta que ${submitterDisplayName} active su Billetera`, enableWallet: 'Habilitar Billetera', - holdRequest: 'Bloquear solicitud de dinero', - unholdRequest: 'Desbloquear solicitud de dinero', + holdRequest: 'Bloquear solicitud', + unholdRequest: 'Desbloquear solicitud', + heldRequest: ({comment}: HeldRequestParams) => `bloqueΓ³ esta solicitud con el comentario: ${comment}`, + unheldRequest: 'desbloqueΓ³ esta solicitud', explainHold: 'Explica la razΓ³n para bloquear esta solicitud.', reason: 'RazΓ³n', holdReasonRequired: 'Se requiere una razΓ³n para bloquear.', diff --git a/src/languages/types.ts b/src/languages/types.ts index f7e580819fdf..410c8e1c2085 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -105,7 +105,7 @@ type SettleExpensifyCardParams = { formattedAmount: string; }; -type RequestAmountParams = {amount: number}; +type RequestAmountParams = {amount: string}; type RequestedAmountMessageParams = {formattedAmount: string; comment?: string}; @@ -293,6 +293,8 @@ type ElectronicFundsParams = {percentage: string; amount: string}; type LogSizeParams = {size: number}; +type HeldRequestParams = {comment: string}; + export type { AdminCanceledRequestParams, ApprovedAmountParams, @@ -395,4 +397,5 @@ export type { WelcomeToRoomParams, ZipCodeExampleFormatParams, LogSizeParams, + HeldRequestParams, }; diff --git a/src/libs/API/parameters/UpdateRoomVisibilityParams.ts b/src/libs/API/parameters/UpdateRoomVisibilityParams.ts new file mode 100644 index 000000000000..a69559f0ce47 --- /dev/null +++ b/src/libs/API/parameters/UpdateRoomVisibilityParams.ts @@ -0,0 +1,8 @@ +import type {RoomVisibility} from '@src/types/onyx/Report'; + +type UpdateRoomVisibilityParams = { + reportID: string; + visibility: RoomVisibility; +}; + +export default UpdateRoomVisibilityParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 371fb8ddb404..2633d795b561 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -84,6 +84,7 @@ export type {default as DeleteCommentParams} from './DeleteCommentParams'; export type {default as UpdateCommentParams} from './UpdateCommentParams'; export type {default as UpdateReportNotificationPreferenceParams} from './UpdateReportNotificationPreferenceParams'; export type {default as UpdateRoomDescriptionParams} from './UpdateRoomDescriptionParams'; +export type {default as UpdateRoomVisibilityParams} from './UpdateRoomVisibilityParams'; export type {default as UpdateReportWriteCapabilityParams} from './UpdateReportWriteCapabilityParams'; export type {default as AddWorkspaceRoomParams} from './AddWorkspaceRoomParams'; export type {default as UpdatePolicyRoomNameParams} from './UpdatePolicyRoomNameParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 9c0d57b1cf14..35b03f21c841 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -85,6 +85,7 @@ const WRITE_COMMANDS = { DELETE_COMMENT: 'DeleteComment', UPDATE_COMMENT: 'UpdateComment', UPDATE_REPORT_NOTIFICATION_PREFERENCE: 'UpdateReportNotificationPreference', + UPDATE_ROOM_VISIBILITY: 'UpdateRoomVisibility', UPDATE_ROOM_DESCRIPTION: 'UpdateRoomDescription', UPDATE_REPORT_WRITE_CAPABILITY: 'UpdateReportWriteCapability', ADD_WORKSPACE_ROOM: 'AddWorkspaceRoom', @@ -226,6 +227,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DELETE_COMMENT]: Parameters.DeleteCommentParams; [WRITE_COMMANDS.UPDATE_COMMENT]: Parameters.UpdateCommentParams; [WRITE_COMMANDS.UPDATE_REPORT_NOTIFICATION_PREFERENCE]: Parameters.UpdateReportNotificationPreferenceParams; + [WRITE_COMMANDS.UPDATE_ROOM_VISIBILITY]: Parameters.UpdateRoomVisibilityParams; [WRITE_COMMANDS.UPDATE_ROOM_DESCRIPTION]: Parameters.UpdateRoomDescriptionParams; [WRITE_COMMANDS.UPDATE_REPORT_WRITE_CAPABILITY]: Parameters.UpdateReportWriteCapabilityParams; [WRITE_COMMANDS.ADD_WORKSPACE_ROOM]: Parameters.AddWorkspaceRoomParams; diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts index cf49ba03f287..24437da48953 100644 --- a/src/libs/GetPhysicalCardUtils.ts +++ b/src/libs/GetPhysicalCardUtils.ts @@ -3,17 +3,18 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; import type {LoginList, PrivatePersonalDetails} from '@src/types/onyx'; +import * as LoginUtils from './LoginUtils'; import Navigation from './Navigation/Navigation'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import * as UserUtils from './UserUtils'; -function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry): Route { +function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route { const {address, legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {}; if (!legalFirstName && !legalLastName) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain); } - if (!phoneNumber && !UserUtils.getSecondaryPhoneLogin(loginList)) { + if (!phoneNumber || !LoginUtils.validateNumber(phoneNumber)) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain); } if (!(address?.street && address?.city && address?.state && address?.country && address?.zip)) { @@ -23,8 +24,8 @@ function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry) { - Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails, loginList)); +function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: OnyxEntry) { + Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails)); } /** @@ -35,8 +36,8 @@ function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: OnyxE * @param loginList * @returns */ -function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry) { - const expectedRoute = getCurrentRoute(domain, privatePersonalDetails, loginList); +function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: OnyxEntry) { + const expectedRoute = getCurrentRoute(domain, privatePersonalDetails); // If the user is on the current route or the current route is confirmation, then he's allowed to stay on the current step if ([currentRoute, ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain)].includes(expectedRoute)) { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index d9835b01ceff..3af123a74910 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -133,6 +133,7 @@ const ReportSettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Report/RoomNamePage').default as React.ComponentType, [SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: () => require('../../../pages/settings/Report/NotificationPreferencePage').default as React.ComponentType, [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: () => require('../../../pages/settings/Report/WriteCapabilityPage').default as React.ComponentType, + [SCREENS.REPORT_SETTINGS.VISIBILITY]: () => require('../../../pages/settings/Report/VisibilityPage').default as React.ComponentType, }); const TaskModalStackNavigator = createModalStackNavigator({ @@ -191,7 +192,6 @@ const AccountSettingsModalStackNavigator = createModalStackNavigator( [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../pages/settings/Preferences/PreferencesPage').default as React.ComponentType, [SCREENS.SETTINGS.SECURITY]: () => require('../../../pages/settings/Security/SecuritySettingsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../pages/settings/Profile/ProfilePage').default as React.ComponentType, - [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../pages/settings/Wallet/WalletPage').default as React.ComponentType, [SCREENS.SETTINGS.ABOUT]: () => require('../../../pages/settings/AboutPage/AboutPage').default as React.ComponentType, }, @@ -203,6 +203,7 @@ const WorkspaceSwitcherModalStackNavigator = createModalStackNavigator({ + [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require('../../../pages/settings/Profile/PronounsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../pages/settings/Profile/DisplayNamePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.TIMEZONE]: () => require('../../../pages/settings/Profile/TimezoneInitialPage').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 74a00dec0a1f..2640025efa09 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -261,6 +261,9 @@ const config: LinkingOptions['config'] = { path: ROUTES.KEYBOARD_SHORTCUTS, }, [SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_PROFILE_NAME.route, + [SCREENS.SETTINGS.SHARE_CODE]: { + path: ROUTES.SETTINGS_SHARE_CODE, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { @@ -289,6 +292,9 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: { path: ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.route, }, + [SCREENS.REPORT_SETTINGS.VISIBILITY]: { + path: ROUTES.REPORT_SETTINGS_VISIBILITY.route, + }, }, }, [SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: { @@ -495,10 +501,6 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS_CENTRAL_PANE]: { screens: { - [SCREENS.SETTINGS.SHARE_CODE]: { - path: ROUTES.SETTINGS_SHARE_CODE, - exact: true, - }, [SCREENS.SETTINGS.PROFILE.ROOT]: { path: ROUTES.SETTINGS_PROFILE, exact: true, diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 8e246d82ff72..e7c5466852cf 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -70,14 +70,16 @@ function createCentralPaneNavigator(route: NavigationPartialRoute): NavigationPartialRoute { +function createFullScreenNavigator(route?: NavigationPartialRoute): NavigationPartialRoute { const routes = []; routes.push({name: SCREENS.SETTINGS.ROOT}); - routes.push({ - name: SCREENS.SETTINGS_CENTRAL_PANE, - state: getRoutesWithIndex([route]), - }); + if (route) { + routes.push({ + name: SCREENS.SETTINGS_CENTRAL_PANE, + state: getRoutesWithIndex([route]), + }); + } return { name: NAVIGATORS.FULL_SCREEN_NAVIGATOR, @@ -129,6 +131,11 @@ function getMatchingRootRouteForRHPRoute( return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: route.params}); } } + + // This screen is opened from the LHN of the FullStackNavigator, so in this case we shouldn't push any CentralPane screen + if (route.name === SCREENS.SETTINGS.SHARE_CODE) { + return createFullScreenNavigator(); + } } function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 04bc25736887..81229f353e52 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -203,6 +203,9 @@ type ReportSettingsNavigatorParamList = { [SCREENS.REPORT_SETTINGS.ROOM_NAME]: undefined; [SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: undefined; [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: undefined; + [SCREENS.REPORT_SETTINGS.VISIBILITY]: { + reportID: string; + }; }; type ReportDescriptionNavigatorParamList = { @@ -416,6 +419,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.NEW_CHAT]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.PROFILE]: NavigatorScreenParams; + [SCREENS.SETTINGS.SHARE_CODE]: undefined; [SCREENS.RIGHT_MODAL.REPORT_DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: NavigatorScreenParams; @@ -440,7 +444,6 @@ type RightModalNavigatorParamList = { }; type SettingsCentralPaneNavigatorParamList = { - [SCREENS.SETTINGS.SHARE_CODE]: undefined; [SCREENS.SETTINGS.PROFILE.ROOT]: undefined; [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; [SCREENS.SETTINGS.SECURITY]: undefined; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 80081061f340..97b4fc0144c8 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1494,6 +1494,10 @@ function getOptions( return; } + if (!accountIDs || accountIDs.length === 0) { + return; + } + // Save the report in the map if this is a single participant so we can associate the reportID with the // personal detail option later. Individuals should not be associated with single participant // policyExpenseChats or chatRooms since those are not people. diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 6abdd4348488..d9e7fb8e7e6b 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -62,6 +62,7 @@ import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import * as PolicyUtils from './PolicyUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; +import shouldAllowRawHTMLMessages from './shouldAllowRawHTMLMessages'; import * as TransactionUtils from './TransactionUtils'; import * as Url from './Url'; import * as UserUtils from './UserUtils'; @@ -2305,7 +2306,6 @@ function getReportPreviewMessage( isPreviewMessageForParentChatReport = false, policy: OnyxEntry = null, isForListPreview = false, - shouldHidePayer = false, ): string { const reportActionMessage = reportAction?.message?.[0].html ?? ''; @@ -2370,9 +2370,7 @@ function getReportPreviewMessage( if (isSettled(report.reportID) || (report.isWaitingOnBankAccount && isPreviewMessageForParentChatReport)) { // A settled report preview message can come in three formats "paid ... elsewhere" or "paid ... with Expensify" let translatePhraseKey: TranslationPaths = 'iou.paidElsewhereWithAmount'; - if (isPreviewMessageForParentChatReport) { - translatePhraseKey = 'iou.payerPaidAmount'; - } else if ( + if ( [CONST.IOU.PAYMENT_TYPE.VBBA, CONST.IOU.PAYMENT_TYPE.EXPENSIFY].some((paymentType) => paymentType === originalMessage?.paymentType) || !!reportActionMessage.match(/ (with Expensify|using Expensify)$/) || report.isWaitingOnBankAccount @@ -2380,7 +2378,7 @@ function getReportPreviewMessage( translatePhraseKey = 'iou.paidWithExpensifyWithAmount'; } - let actualPayerName = report.managerID === currentUserAccountID || shouldHidePayer ? '' : getDisplayNameForParticipant(report.managerID, true); + let actualPayerName = report.managerID === currentUserAccountID ? '' : getDisplayNameForParticipant(report.managerID, true); actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; @@ -2697,7 +2695,7 @@ function hasReportNameError(report: OnyxEntry): boolean { */ function getParsedComment(text: string): string { const parser = new ExpensiMark(); - return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : lodashEscape(text); + return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text, {shouldEscapeText: !shouldAllowRawHTMLMessages()}) : lodashEscape(text); } function getReportDescriptionText(report: Report): string { @@ -3529,7 +3527,7 @@ function buildOptimisticHoldReportAction(comment: string, created = DateUtils.ge { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'normal', - text: `held this money request with the comment: ${comment}`, + text: Localize.translateLocal('iou.heldRequest', {comment}), }, { type: CONST.REPORT.MESSAGE.TYPE.COMMENT, @@ -3564,7 +3562,7 @@ function buildOptimisticUnHoldReportAction(created = DateUtils.getDBTime()): Opt { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'normal', - text: `unheld this money request`, + text: Localize.translateLocal('iou.unheldRequest'), }, ], person: [ @@ -4527,6 +4525,13 @@ function canEditWriteCapability(report: OnyxEntry, policy: OnyxEntry, policy: OnyxEntry): boolean { + return PolicyUtils.isPolicyAdmin(policy) && !isArchivedRoom(report); +} + /** * Returns the onyx data needed for the task assignee chat */ @@ -4689,6 +4694,62 @@ function getVisibleMemberIDs(report: OnyxEntry): number[] { return visibleChatMemberAccountIDs; } +/** + * Return iou report action display message + */ +function getIOUReportActionDisplayMessage(reportAction: OnyxEntry): string { + if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { + return ''; + } + const originalMessage = reportAction.originalMessage; + const {IOUReportID} = originalMessage; + const iouReport = getReport(IOUReportID); + let translationKey: TranslationPaths; + if (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { + // The `REPORT_ACTION_TYPE.PAY` action type is used for both fulfilling existing requests and sending money. To + // differentiate between these two scenarios, we check if the `originalMessage` contains the `IOUDetails` + // property. If it does, it indicates that this is a 'Send money' action. + const {amount, currency} = originalMessage.IOUDetails ?? originalMessage; + const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(amount), currency) ?? ''; + const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport?.managerID, true); + + switch (originalMessage.paymentType) { + case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: + 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 ?? ''}); + } + + const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID ?? ''); + const transactionDetails = getTransactionDetails(!isEmptyObject(transaction) ? transaction : null); + const formattedAmount = CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency); + const isRequestSettled = isSettled(originalMessage.IOUReportID); + const isApproved = isReportApproved(iouReport); + if (isRequestSettled) { + return Localize.translateLocal('iou.payerSettled', { + amount: formattedAmount, + }); + } + if (isApproved) { + return Localize.translateLocal('iou.approvedAmount', { + amount: formattedAmount, + }); + } + translationKey = ReportActionsUtils.isSplitBillAction(reportAction) ? 'iou.didSplitAmount' : 'iou.requestedAmount'; + return Localize.translateLocal(translationKey, { + formattedAmount, + comment: transactionDetails?.comment ?? '', + }); +} + /** * Checks if a report is a group chat. * @@ -5126,6 +5187,7 @@ export { hasOnlyTransactionsWithPendingRoutes, hasNonReimbursableTransactions, hasMissingSmartscanFields, + getIOUReportActionDisplayMessage, isWaitingForAssigneeToCompleteTask, isGroupChat, isDraftExpenseReport, @@ -5160,6 +5222,7 @@ export { getAvailableReportFields, reportFieldsEnabled, getAllAncestorReportActionIDs, + canEditRoomVisibility, canEditPolicyDescription, getPolicyDescriptionText, }; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index d3eafc6554db..0a13d561891c 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -6,8 +6,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {RecentWaypoint, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates'; -import type {Comment, Receipt, TransactionChanges, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; +import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {isCorporateCard, isExpensifyCard} from './CardUtils'; import DateUtils from './DateUtils'; import * as NumberUtils from './NumberUtils'; @@ -94,6 +95,7 @@ function buildOptimisticTransaction( category = '', tag = '', billable = false, + pendingFields: Partial<{[K in TransactionPendingFieldsKey]: ValueOf}> | undefined = undefined, ): Transaction { // transactionIDs are random, positive, 64-bit numeric strings. // Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID) @@ -108,6 +110,7 @@ function buildOptimisticTransaction( } return { + ...(!isEmptyObject(pendingFields) ? {pendingFields} : {}), transactionID, amount, currency, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 47d10ddcef4b..39ce9dd6d2bb 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -223,8 +223,7 @@ Onyx.connect({ * @param reportID to attach the transaction to * @param iouRequestType one of manual/scan/distance */ -// eslint-disable-next-line @typescript-eslint/naming-convention -function startMoneyRequest_temporaryForRefactor(reportID: string, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) { +function initMoneyRequest(reportID: string, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) { // Generate a brand new transactionID const newTransactionID = CONST.IOU.OPTIMISTIC_TRANSACTION_ID; // Disabling this line since currentDate can be an empty string @@ -259,6 +258,12 @@ function clearMoneyRequest(transactionID: string) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); } +// eslint-disable-next-line @typescript-eslint/naming-convention +function startMoneyRequest_temporaryForRefactor(iouType: ValueOf, reportID: string) { + clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID)); +} + // eslint-disable-next-line @typescript-eslint/naming-convention function setMoneyRequestAmount_temporaryForRefactor(transactionID: string, amount: number, currency: string, removeOriginalCurrency = false) { if (removeOriginalCurrency) { @@ -789,6 +794,8 @@ function getMoneyRequestInformation( receiptObject.state = receipt.state ?? CONST.IOU.RECEIPT_STATE.SCANREADY; filename = receipt.name; } + const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; + const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; let optimisticTransaction = TransactionUtils.buildOptimisticTransaction( ReportUtils.isExpenseReport(iouReport) ? -amount : amount, currency, @@ -804,6 +811,7 @@ function getMoneyRequestInformation( category, tag, billable, + isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, ); const optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category); @@ -814,8 +822,7 @@ function getMoneyRequestInformation( // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 // to remind me to do this. - const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; - if (existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) { + if (isDistanceRequest) { optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false); } @@ -3293,9 +3300,10 @@ function getSendMoneyParams( } function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, recipient: Participant, paymentMethodType: PaymentMethodType): PayMoneyRequestData { + const total = iouReport.total ?? 0; const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.PAY, - -(iouReport.total ?? 0), + ReportUtils.isExpenseReport(iouReport) ? -total : total, iouReport.currency ?? '', '', [recipient], @@ -4095,11 +4103,11 @@ export { payMoneyRequest, sendMoneyWithWallet, startMoneyRequest, + initMoneyRequest, startMoneyRequest_temporaryForRefactor, resetMoneyRequestCategory, resetMoneyRequestCategory_temporaryForRefactor, resetMoneyRequestInfo, - clearMoneyRequest, setMoneyRequestAmount_temporaryForRefactor, setMoneyRequestBillable_temporaryForRefactor, setMoneyRequestCategory_temporaryForRefactor, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9134f0a89e61..6efe0860e9b5 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -40,6 +40,7 @@ import type { UpdateReportWriteCapabilityParams, UpdateRoomDescriptionParams, } from '@libs/API/parameters'; +import type UpdateRoomVisibilityParams from '@libs/API/parameters/UpdateRoomVisibilityParams'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CollectionUtils from '@libs/CollectionUtils'; import DateUtils from '@libs/DateUtils'; @@ -68,7 +69,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx'; import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; -import type {NotificationPreference, WriteCapability} from '@src/types/onyx/Report'; +import type {NotificationPreference, RoomVisibility, WriteCapability} from '@src/types/onyx/Report'; import type Report from '@src/types/onyx/Report'; import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; @@ -1442,6 +1443,38 @@ function updateNotificationPreference( } } +function updateRoomVisibility(reportID: string, previousValue: RoomVisibility | undefined, newValue: RoomVisibility, navigate: boolean, report: OnyxEntry | EmptyObject = {}) { + if (previousValue === newValue) { + if (navigate && !isEmptyObject(report) && report.reportID) { + ReportUtils.goBackToDetailsPage(report); + } + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {visibility: newValue}, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {visibility: previousValue}, + }, + ]; + + const parameters: UpdateRoomVisibilityParams = {reportID, visibility: newValue}; + + API.write(WRITE_COMMANDS.UPDATE_ROOM_VISIBILITY, parameters, {optimisticData, failureData}); + if (navigate && !isEmptyObject(report)) { + ReportUtils.goBackToDetailsPage(report); + } +} + /** * This will subscribe to an existing thread, or create a new one and then subsribe to it if necessary * @@ -1610,7 +1643,7 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre const parameters = { reportID, - reportFields: JSON.stringify({[reportField.fieldID]: reportField}), + reportFields: JSON.stringify({[`expensify_${reportField.fieldID}`]: reportField}), }; API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData}); @@ -2926,4 +2959,5 @@ export { updateReportField, updateReportName, resolveActionableMentionWhisper, + updateRoomVisibility, }; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 54efe4ba4d8e..f2507a28d576 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -526,7 +526,7 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { } // mention user - if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + if ('html' in message && typeof message.html === 'string' && message.html.includes(`@${currentEmail}`)) { return playSound(SOUNDS.ATTENTION); } diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.ts similarity index 71% rename from src/libs/migrateOnyx.js rename to src/libs/migrateOnyx.ts index 9b8b4056e3e5..1202275067a5 100644 --- a/src/libs/migrateOnyx.js +++ b/src/libs/migrateOnyx.ts @@ -1,31 +1,26 @@ -import _ from 'underscore'; import Log from './Log'; import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID'; -import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID'; import RemoveEmptyReportActionsDrafts from './migrations/RemoveEmptyReportActionsDrafts'; import RenameReceiptFilename from './migrations/RenameReceiptFilename'; import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection'; -export default function () { +export default function (): Promise { const startTime = Date.now(); Log.info('[Migrate Onyx] start'); return new Promise((resolve) => { // Add all migrations to an array so they are executed in order - const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts]; + const migrationPromises = [RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. /* eslint-disable arrow-body-style */ - _.reduce( - migrationPromises, - (previousPromise, migrationPromise) => { + migrationPromises + .reduce((previousPromise, migrationPromise) => { return previousPromise.then(() => { return migrationPromise(); }); - }, - Promise.resolve(), - ) + }, Promise.resolve()) // Once all migrations are done, resolve the main promise .then(() => { diff --git a/src/libs/migrations/PersonalDetailsByAccountID.js b/src/libs/migrations/PersonalDetailsByAccountID.js deleted file mode 100644 index 24aece8f5a97..000000000000 --- a/src/libs/migrations/PersonalDetailsByAccountID.js +++ /dev/null @@ -1,274 +0,0 @@ -import lodashHas from 'lodash/has'; -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; -import Log from '@libs/Log'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const DEPRECATED_ONYX_KEYS = { - // Deprecated personal details object which was keyed by login instead of accountID. - PERSONAL_DETAILS: 'personalDetails', -}; - -/** - * @returns {Promise} - */ -function getReportActionsFromOnyx() { - return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - return resolve(allReportActions); - }, - }); - }); -} - -/** - * @returns {Promise} - */ -function getReportsFromOnyx() { - return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connectionID); - return resolve(allReports); - }, - }); - }); -} - -/** - * We use the old personalDetails object becuase it is more efficient for this migration since it is keyed by email address. - * Also, if the old personalDetails object is not available, that likely means the migration has already run successfully before on this account. - * - * @returns {Promise} - */ -function getDeprecatedPersonalDetailsFromOnyx() { - return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS, - callback: (allPersonalDetails) => { - Onyx.disconnect(connectionID); - return resolve(allPersonalDetails); - }, - }); - }); -} - -/** - * @returns {Promise} - */ -function getDeprecatedPolicyMemberListFromOnyx() { - return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST, - waitForCollectionCallback: true, - callback: (allPolicyMembers) => { - Onyx.disconnect(connectionID); - return resolve(allPolicyMembers); - }, - }); - }); -} - -/** - * Migrate Onyx data for the email to accountID migration. - * - * - personalDetails -> personalDetailsList - * - policyMemberList_ -> policyMembers_ - * - reportAction_ - * - originalMessage.oldLogin -> originalMessage.oldAccountID - * - originalMessage.newLogin -> originalMessage.newAccountID - * - accountEmail -> accountID - * - actorEmail -> actorAccountID - * - childManagerEmail -> childManagerAccountID - * - whisperedTo -> whisperedToAccountIDs - * - childOldestFourEmails -> childOldestFourAccountIDs - * - originalMessage.participants -> originalMessage.participantAccountIDs - * - report_ - * - lastActorEmail -> lastActorAccountID - * - participants -> participantAccountIDs - * - * @returns {Promise} - */ -export default function () { - return Promise.all([getReportActionsFromOnyx(), getReportsFromOnyx(), getDeprecatedPersonalDetailsFromOnyx(), getDeprecatedPolicyMemberListFromOnyx()]).then( - ([oldReportActions, oldReports, oldPersonalDetails, oldPolicyMemberList]) => { - const onyxData = {}; - - // The personalDetails object has been replaced by personalDetailsList - // So if we find an instance of personalDetails we will clear it out - if (oldPersonalDetails) { - Log.info('[Migrate Onyx] PersonalDetailsByAccountID migration: removing personalDetails'); - onyxData[DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS] = null; - } - - // The policyMemberList_ collection has been replaced by policyMembers_ - // So if we find any instances of policyMemberList_ we will clear them out - _.each(oldPolicyMemberList, (_policyMembersForPolicy, policyKey) => { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing policyMemberList ${policyKey}`); - onyxData[policyKey] = null; - }); - - // We migrate reportActions to remove old email-based data. - // If we do not find the equivalent accountID-based data in the reportAction, we will just clear the reportAction - // and let it be fetched from the API next time they open the report and scroll to that action. - // We do this because we know the reportAction from the API will include the needed accountID data. - _.each(oldReportActions, (reportActionsForReport, onyxKey) => { - if (_.isEmpty(reportActionsForReport)) { - Log.info(`[Migrate Onyx] Skipped migration PersonalDetailsByAccountID for ${onyxKey} because there were no reportActions`); - return; - } - const newReportActionsForReport = {}; - let reportActionsWereModified = false; - _.each(reportActionsForReport, (reportAction, reportActionID) => { - if (_.isEmpty(reportAction)) { - reportActionsWereModified = true; - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because the reportAction was empty`); - return; - } - const newReportAction = reportAction; - - if (lodashHas(reportAction, ['originalMessage', 'oldLogin'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['originalMessage', 'oldAccountID'])) { - delete newReportAction.originalMessage.oldLogin; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because originalMessage.oldAccountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['originalMessage', 'newLogin'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['originalMessage', 'newAccountID'])) { - delete newReportAction.originalMessage.newLogin; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because originalMessage.newAccountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['accountEmail'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['accountID'])) { - delete newReportAction.accountEmail; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because accountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['actorEmail'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['actorAccountID'])) { - delete newReportAction.actorEmail; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because actorAccountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['childManagerEmail'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['childManagerAccountID'])) { - delete newReportAction.childManagerEmail; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because childManagerAccountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['whisperedTo'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['whisperedToAccountIDs'])) { - delete newReportAction.whisperedTo; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because whisperedToAccountIDs not found`); - return; - } - } - - if (lodashHas(reportAction, ['childOldestFourEmails'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['childOldestFourAccountIDs'])) { - delete newReportAction.childOldestFourEmails; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because childOldestFourAccountIDs not found`); - return; - } - } - - if (lodashHas(reportAction, ['originalMessage', 'participants'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['originalMessage', 'participantAccountIDs'])) { - delete newReportAction.originalMessage.participants; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because originalMessage.participantAccountIDs not found`); - return; - } - } - - newReportActionsForReport[reportActionID] = newReportAction; - }); - - // Only include the reportActions from this report if at least one reportAction in this report - // was modified in any way. - if (reportActionsWereModified) { - onyxData[onyxKey] = newReportActionsForReport; - } - }); - - // For the reports migration, we don't need to look up emails from accountIDs. Instead, - // we will just look for old email data and automatically remove it if it exists. The reason for - // this is that we already stopped sending email based data in reports, and from everywhere else - // in the app by this point (since this is the last data we migrated). - _.each(oldReports, (report, onyxKey) => { - const newReport = report; - - // Delete report key if it's empty - if (_.isEmpty(newReport)) { - onyxData[onyxKey] = null; - return; - } - - let reportWasModified = false; - if (lodashHas(newReport, ['lastActorEmail'])) { - reportWasModified = true; - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing lastActorEmail from report ${newReport.reportID}`); - delete newReport.lastActorEmail; - } - - if (lodashHas(newReport, ['ownerEmail'])) { - reportWasModified = true; - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing ownerEmail from report ${newReport.reportID}`); - delete newReport.ownerEmail; - } - - if (lodashHas(newReport, ['managerEmail'])) { - reportWasModified = true; - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing managerEmail from report ${newReport.reportID}`); - delete newReport.managerEmail; - } - - if (reportWasModified) { - onyxData[onyxKey] = newReport; - } - }); - - return Onyx.multiSet(onyxData); - }, - ); -} diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts index cd50938c70b9..68c750b05a5f 100644 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts +++ b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts @@ -1,5 +1,3 @@ import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder'; -// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event -// Because of that - on iOS we can use `keyboardWillHide` that is not available on android -export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardWillHide'); +export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardDidHide'); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts index 8d5ef578b66c..72df7a730e02 100644 --- a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts +++ b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts @@ -5,8 +5,6 @@ import * as Composer from '@userActions/Composer'; import type SetShouldShowComposeInputKeyboardAware from './types'; let keyboardEventListener: EmitterSubscription | null = null; -// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event -// Because of that - on iOS we can use `keyboardWillHide` that is not available on android const setShouldShowComposeInputKeyboardAwareBuilder: (keyboardEvent: KeyboardEventName) => SetShouldShowComposeInputKeyboardAware = (keyboardEvent: KeyboardEventName) => (shouldShow: boolean) => { diff --git a/src/libs/shouldAllowRawHTMLMessages/index.native.ts b/src/libs/shouldAllowRawHTMLMessages/index.native.ts new file mode 100644 index 000000000000..db886f7f6fe8 --- /dev/null +++ b/src/libs/shouldAllowRawHTMLMessages/index.native.ts @@ -0,0 +1,3 @@ +export default function () { + return false; +} diff --git a/src/libs/shouldAllowRawHTMLMessages/index.ts b/src/libs/shouldAllowRawHTMLMessages/index.ts new file mode 100644 index 000000000000..577dc3055441 --- /dev/null +++ b/src/libs/shouldAllowRawHTMLMessages/index.ts @@ -0,0 +1,5 @@ +window.shouldAllowRawHTMLMessages = false; + +export default function () { + return window.shouldAllowRawHTMLMessages; +} diff --git a/src/pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx b/src/pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx index 2b80b890e4bd..2db3a4fdf7ad 100644 --- a/src/pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx +++ b/src/pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx @@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -66,6 +67,7 @@ function BusinessTypeSelectorModal({isVisible, currentBusinessType, onBusinessTy onSelectRow={onBusinessTypeSelected} shouldStopPropagation shouldUseDynamicMaxToRenderPerBatch + ListItem={RadioListItem} /> diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index b560ce672ae4..15490455ce09 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -88,6 +88,7 @@ function ReportParticipantsPage({report, personalDetails}: ReportParticipantsPag {({safeAreaPaddingBottomStyle}) => ( Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)) : undefined} title={translate( ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || @@ -113,7 +114,7 @@ function ReportParticipantsPage({report, personalDetails}: ReportParticipantsPag if (!option.accountID) { return; } - Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID)); + Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, report ? ROUTES.REPORT_PARTICIPANTS.getRoute(report.reportID) : undefined)); }} hideSectionHeaders showTitleTooltip diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index 2c26d148f54c..40a1b009b38d 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -12,6 +12,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import type {Section} from '@components/SelectionList/types'; +import UserListItem from '@components/SelectionList/UserListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; @@ -225,6 +226,7 @@ function RoomInvitePage({betas, personalDetails, report, policies}: RoomInvitePa diff --git a/src/pages/SearchPage/index.js b/src/pages/SearchPage/index.js index 7c472296dfe1..0c17e58837c1 100644 --- a/src/pages/SearchPage/index.js +++ b/src/pages/SearchPage/index.js @@ -6,6 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/UserListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -143,6 +144,7 @@ function SearchPage({betas, reports, isSearchingForReports}) { includeSafeAreaPaddingBottom={false} testID={SearchPage.displayName} onEntryTransitionEnd={handleScreenTransitionEnd} + shouldEnableMaxHeight > {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( <> @@ -153,6 +155,7 @@ function SearchPage({betas, reports, isSearchingForReports}) { (null); - const {isSmallScreenWidth} = useWindowDimensions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isReport = !!report?.reportID; @@ -71,72 +67,52 @@ function ShareCodePage({report}: ShareCodePageProps) { const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; return ( - + Navigation.goBack(isReport ? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID) : undefined)} - shouldShowBackButton={isReport || isSmallScreenWidth} - icon={Illustrations.QRCode} + shouldShowBackButton /> - -
- - - + + + - - Clipboard.setString(url)} - shouldLimitWidth={false} - wrapperStyle={themeStyles.sectionMenuItemTopDescription} - /> + + Clipboard.setString(url)} + shouldLimitWidth={false} + /> - {isNative && ( - qrCodeRef.current?.download?.()} - wrapperStyle={themeStyles.sectionMenuItemTopDescription} - /> - )} + {isNative && ( + qrCodeRef.current?.download?.()} + /> + )} - - Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE, Navigation.getActiveRouteWithoutParams())) - } - wrapperStyle={themeStyles.sectionMenuItemTopDescription} - shouldShowRightIcon - /> - -
+ Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE, Navigation.getActiveRouteWithoutParams()))} + shouldShowRightIcon + />
diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index e024f1c3f7eb..faa70bb0633a 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -329,7 +329,7 @@ function HeaderView(props) { { if (ReportUtils.canEditPolicyDescription(props.policy)) { - Navigation.navigate(ROUTES.WORKSPACE_DESCRIPTION.getRoute(props.report.policyID)); + Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(props.report.policyID)); return; } Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.reportID)); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 213d94f51f81..52b62c2d15b3 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -208,6 +208,7 @@ function BaseReportActionContextMenu({ undefined, undefined, filteredContextMenuActions, + true, ); }; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index abd9d2a09fdf..51e6b25f1314 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -359,15 +359,7 @@ const ContextMenuActions: ContextMenuAction[] = [ const displayMessage = ReportUtils.getReimbursementDeQueuedActionMessage(reportAction, expenseReport); Clipboard.setString(displayMessage); } else if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { - const displayMessage = ReportUtils.getReportPreviewMessage( - ReportUtils.getReport(ReportUtils.getOriginalReportID(reportID, reportAction)), - reportAction, - false, - false, - null, - false, - true, - ); + const displayMessage = ReportUtils.getIOUReportActionDisplayMessage(reportAction); Clipboard.setString(displayMessage); } else if (ReportActionsUtils.isCreatedTaskReportAction(reportAction)) { const taskPreviewMessage = TaskUtils.getTaskCreatedMessage(reportAction); diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 9c8c6a8b37e7..0b4154a15e80 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -65,7 +65,7 @@ function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef([]); const contentRef = useRef(null); - const anchorRef = useRef(null); + const anchorRef = useRef(null); const dimensionsEventListener = useRef(null); const contextMenuAnchorRef = useRef(null); const contextMenuTargetNode = useRef(null); @@ -163,11 +163,16 @@ function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef { const {pageX = 0, pageY = 0} = extractPointerEvent(event); contextMenuAnchorRef.current = contextMenuAnchor; contextMenuTargetNode.current = event.target as HTMLElement; - + if (shouldCloseOnTarget) { + anchorRef.current = event.target as HTMLDivElement; + } else { + anchorRef.current = null; + } setInstanceID(Math.random().toString(36).substr(2, 5)); onPopoverShow.current = onShow; diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts index d8570bd14510..6664a38d2e19 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts @@ -34,6 +34,7 @@ type ShowContextMenu = ( isPinnedChat?: boolean, isUnreadChat?: boolean, disabledOptions?: ContextMenuAction[], + shouldCloseOnTarget?: boolean, ) => void; type ReportActionContextMenu = { @@ -113,6 +114,7 @@ function showContextMenu( isPinnedChat = false, isUnreadChat = false, disabledActions: ContextMenuAction[] = [], + shouldCloseOnTarget = false, ) { if (!contextMenuRef.current) { return; @@ -140,6 +142,7 @@ function showContextMenu( isPinnedChat, isUnreadChat, disabledActions, + shouldCloseOnTarget, ); } diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 62abfcf8545a..72727168cad6 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -18,14 +18,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; -import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import * as IOU from '@userActions/IOU'; import * as Report from '@userActions/Report'; import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; const propTypes = { /** The report currently being looked at */ @@ -145,12 +143,12 @@ function AttachmentPickerWithMenuItems({ [CONST.IOU.TYPE.SPLIT]: { icon: Expensicons.Receipt, text: translate('iou.splitBill'), - onSelected: () => Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.SPLIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, report.reportID)), + onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report.reportID), }, [CONST.IOU.TYPE.REQUEST]: { icon: Expensicons.MoneyCircle, text: translate('iou.requestMoney'), - onSelected: () => Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.REQUEST, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, report.reportID)), + onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report.reportID), }, [CONST.IOU.TYPE.SEND]: { icon: Expensicons.Send, diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 152ce54d5481..6bdea2cb4a27 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -203,7 +203,8 @@ function SuggestionMention({ suggestionEndIndex = indexOfFirstSpecialCharOrEmojiAfterTheCursor + selectionEnd; } - const leftString = value.substring(0, suggestionEndIndex); + const newLineIndex = value.lastIndexOf('\n', selectionEnd - 1); + const leftString = value.substring(newLineIndex + 1, suggestionEndIndex); const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI); const lastWord = _.last(words); const secondToLastWord = words[words.length - 3]; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index e676c83c8b9b..66394190fde6 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -466,6 +466,10 @@ function ReportActionItem(props) { children = ; } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED) { children = ; + } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { + children = ; + } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { + children = ; } else { const hasBeenFlagged = !_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], moderationDecision) && diff --git a/src/pages/home/report/ReportActionItemMessage.tsx b/src/pages/home/report/ReportActionItemMessage.tsx index becc8e094c97..0ec23e9aaf79 100644 --- a/src/pages/home/report/ReportActionItemMessage.tsx +++ b/src/pages/home/report/ReportActionItemMessage.tsx @@ -58,7 +58,7 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid const originalMessage = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? action.originalMessage : null; const iouReportID = originalMessage?.IOUReportID; if (iouReportID) { - iouMessage = ReportUtils.getReportPreviewMessage(ReportUtils.getReport(iouReportID), action, false, false, null, false, true); + iouMessage = ReportUtils.getIOUReportActionDisplayMessage(action); } } diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 427c6ccdbfc4..2c9a4cbd21e8 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -31,6 +31,7 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import setShouldShowComposeInputKeyboardAware from '@libs/setShouldShowComposeInputKeyboardAware'; +import * as ComposerActions from '@userActions/Composer'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import * as InputFocus from '@userActions/InputFocus'; import * as Report from '@userActions/Report'; @@ -211,6 +212,9 @@ function ReportActionItemMessageEdit( // eslint-disable-next-line react-hooks/exhaustive-deps -- this cleanup needs to be called only on unmount }, [action.reportActionID]); + // show the composer after editing is complete for devices that hide the composer during editing. + useEffect(() => () => ComposerActions.setShouldShowComposeInput(true), []); + /** * Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft * allows one to navigate somewhere else and come back to the comment and still have it in edit mode. diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 0df490fa4466..573cbe370aa7 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -166,10 +166,11 @@ function FloatingActionButtonAndPopover(props) { text: translate('iou.requestMoney'), onSelected: () => interceptAnonymousUser(() => - Navigation.navigate( + IOU.startMoneyRequest_temporaryForRefactor( + CONST.IOU.TYPE.REQUEST, // When starting to create a money request from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used // for all of the routes in the creation flow. - ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.REQUEST, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()), + ReportUtils.generateReportID(), ), ), }, diff --git a/src/pages/iou/HoldReasonPage.tsx b/src/pages/iou/HoldReasonPage.tsx index da3b98583630..25f318089c2e 100644 --- a/src/pages/iou/HoldReasonPage.tsx +++ b/src/pages/iou/HoldReasonPage.tsx @@ -84,7 +84,7 @@ function HoldReasonPage({route}: HoldReasonPageProps) { valueType="string" name="comment" defaultValue={undefined} - label="Reason" + label={translate('iou.reason')} accessibilityLabel={translate('iou.reason')} autoFocus /> diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index 2a48897bfc85..7495efb43171 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -9,6 +9,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {withNetwork} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; @@ -165,6 +166,7 @@ function IOUCurrencySelection(props) { /> () => { - IOU.clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); - }, - [reportID], - ); - useEffect(() => { const handler = (event) => { if (event.code !== CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { @@ -106,7 +98,7 @@ function IOURequestStartPage({ if (transaction.reportID === reportID) { return; } - IOU.startMoneyRequest_temporaryForRefactor(reportID, isFromGlobalCreate, transactionRequestType.current); + IOU.initMoneyRequest(reportID, isFromGlobalCreate, transactionRequestType.current); }, [transaction, reportID, iouType, isFromGlobalCreate]); const isExpenseChat = ReportUtils.isPolicyExpenseChat(report); @@ -125,7 +117,7 @@ function IOURequestStartPage({ if (newIouType === previousIOURequestType) { return; } - IOU.startMoneyRequest_temporaryForRefactor(reportID, isFromGlobalCreate, newIouType); + IOU.initMoneyRequest(reportID, isFromGlobalCreate, newIouType); transactionRequestType.current = newIouType; }, [previousIOURequestType, reportID, isFromGlobalCreate], diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js index d6c088c23e95..238b66c0e727 100644 --- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js +++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js @@ -11,6 +11,7 @@ import {PressableWithFeedback} from '@components/Pressable'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import SelectCircle from '@components/SelectCircle'; import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/UserListItem'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; @@ -337,6 +338,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ ( void; /** Fired when submit button pressed, saves the given amount and navigates to the next page */ - onSubmitButtonPress: PropTypes.func.isRequired, + onSubmitButtonPress: ({amount, currency}: {amount: string; currency: string}) => void; /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ - selectedTab: PropTypes.oneOf([CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN]), + selectedTab?: ValueOf; }; -const defaultProps = { - amount: 0, - taxAmount: 0, - currency: CONST.CURRENCY.USD, - forwardedRef: null, - isEditing: false, - selectedTab: CONST.TAB_REQUEST.MANUAL, +type Selection = { + start: number; + end: number; }; /** * Returns the new selection object based on the updated amount's length - * - * @param {Object} oldSelection - * @param {Number} prevLength - * @param {Number} newLength - * @returns {Object} */ -const getNewSelection = (oldSelection, prevLength, newLength) => { +const getNewSelection = (oldSelection: Selection, prevLength: number, newLength: number): Selection => { const cursorPosition = oldSelection.end + (newLength - prevLength); return {start: cursorPosition, end: cursorPosition}; }; -const isAmountInvalid = (amount) => !amount.length || parseFloat(amount) < 0.01; -const isTaxAmountInvalid = (currentAmount, taxAmount, isTaxAmountForm) => isTaxAmountForm && currentAmount > CurrencyUtils.convertToFrontendAmount(taxAmount); +const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01; +const isTaxAmountInvalid = (currentAmount: string, taxAmount: number, isTaxAmountForm: boolean) => + isTaxAmountForm && Number.parseFloat(currentAmount) > CurrencyUtils.convertToFrontendAmount(taxAmount); const AMOUNT_VIEW_ID = 'amountView'; const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; const NUM_PAD_VIEW_ID = 'numPadView'; -function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forwardedRef, onCurrencyButtonPress, onSubmitButtonPress, selectedTab}) { +function MoneyRequestAmountForm( + { + amount = 0, + taxAmount = 0, + currency = CONST.CURRENCY.USD, + isEditing = false, + onCurrencyButtonPress, + onSubmitButtonPress, + selectedTab = CONST.TAB_REQUEST.MANUAL, + }: MoneyRequestAmountFormProps, + forwardedRef: ForwardedRef, +) { const styles = useThemeStyles(); const {isExtraSmallScreenHeight} = useWindowDimensions(); const {translate, toLocaleDigit, numberFormat} = useLocalize(); - const textInput = useRef(null); + const textInput = useRef(null); const isTaxAmountForm = Navigation.getActiveRoute().includes('taxAmount'); const decimals = CurrencyUtils.getCurrencyDecimals(currency); const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString); - const [formError, setFormError] = useState(''); + const [formError, setFormError] = useState(''); const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); const [selection, setSelection] = useState({ @@ -100,15 +101,13 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward /** * Event occurs when a user presses a mouse button over an DOM element. - * - * @param {Event} event - * @param {Array} ids */ - const onMouseDown = (event, ids) => { - const relatedTargetId = lodashGet(event, 'nativeEvent.target.id'); - if (!_.contains(ids, relatedTargetId)) { + const onMouseDown = (event: React.MouseEvent, ids: string[]) => { + const relatedTargetId = (event.nativeEvent?.target as HTMLElement)?.id; + if (!ids.includes(relatedTargetId)) { return; } + event.preventDefault(); if (!textInput.current) { return; @@ -118,7 +117,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward } }; - const initializeAmount = useCallback((newAmount) => { + const initializeAmount = useCallback((newAmount: number) => { const frontendAmount = newAmount ? CurrencyUtils.convertToFrontendAmount(newAmount).toString() : ''; setCurrentAmount(frontendAmount); setSelection({ @@ -128,7 +127,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward }, []); useEffect(() => { - if (!currency || !_.isNumber(amount)) { + if (!currency || typeof amount !== 'number') { return; } initializeAmount(amount); @@ -141,7 +140,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward * @param {String} newAmount - Changed amount from user input */ const setNewAmount = useCallback( - (newAmount) => { + (newAmount: string) => { // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value // More info: https://github.com/Expensify/App/issues/16974 const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); @@ -151,7 +150,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward setSelection((prevSelection) => ({...prevSelection})); return; } - if (!_.isEmpty(formError)) { + if (formError) { setFormError(''); } @@ -188,13 +187,11 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward /** * Update amount with number or Backspace pressed for BigNumberPad. * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button - * - * @param {String} key */ const updateAmountNumberPad = useCallback( - (key) => { - if (shouldUpdateSelection && !textInput.current.isFocused()) { - textInput.current.focus(); + (key: string) => { + if (shouldUpdateSelection && !textInput.current?.isFocused()) { + textInput.current?.focus(); } // Backspace button is pressed if (key === '<' || key === 'Backspace') { @@ -214,12 +211,12 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward /** * Update long press value, to remove items pressing on < * - * @param {Boolean} value - Changed text from user input + * @param value - Changed text from user input */ - const updateLongPressHandlerState = useCallback((value) => { + const updateLongPressHandlerState = useCallback((value: boolean) => { setShouldUpdateSelection(!value); - if (!value && !textInput.current.isFocused()) { - textInput.current.focus(); + if (!value && !textInput.current?.isFocused()) { + textInput.current?.focus(); } }, []); @@ -248,8 +245,8 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward /** * Input handler to check for a forward-delete key (or keyboard shortcut) press. */ - const textInputKeyPress = ({nativeEvent}) => { - const key = nativeEvent.key.toLowerCase(); + const textInputKeyPress = ({nativeEvent}: NativeSyntheticEvent) => { + const key = nativeEvent?.key.toLowerCase(); if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { // Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being // used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press. @@ -258,7 +255,8 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward } // Control-D on Mac is a keyboard shortcut for forward-delete. See https://support.apple.com/en-us/HT201236 for Mac keyboard shortcuts. // Also check for the keyboard shortcut on iOS in cases where a hardware keyboard may be connected to the device. - forwardDeletePressedRef.current = key === 'delete' || (_.contains([CONST.OS.MAC_OS, CONST.OS.IOS], getOperatingSystem()) && nativeEvent.ctrlKey && key === 'd'); + const operatingSystem = getOperatingSystem(); + forwardDeletePressedRef.current = key === 'delete' || ((operatingSystem === CONST.OS.MAC_OS || operatingSystem === CONST.OS.IOS) && nativeEvent?.ctrlKey && key === 'd'); }; const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); @@ -284,7 +282,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward ref={(ref) => { if (typeof forwardedRef === 'function') { forwardedRef(ref); - } else if (forwardedRef && _.has(forwardedRef, 'current')) { + } else if (forwardedRef?.current) { // eslint-disable-next-line no-param-reassign forwardedRef.current = ref; } @@ -292,7 +290,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward }} selectedCurrencyCode={currency} selection={selection} - onSelectionChange={(e) => { + onSelectionChange={(e: NativeSyntheticEvent) => { if (!shouldUpdateSelection) { return; } @@ -302,8 +300,9 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward setSelection({start, end}); }} onKeyPress={textInputKeyPress} + isCurrencyPressable /> - {!_.isEmpty(formError) && ( + {!!formError && ( ( - -)); - -MoneyRequestAmountFormWithRef.displayName = 'MoneyRequestAmountFormWithRef'; - -export default MoneyRequestAmountFormWithRef; +export default React.forwardRef(MoneyRequestAmountForm); diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 7006c2703b13..3fde970327d7 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -11,6 +11,7 @@ import {PressableWithFeedback} from '@components/Pressable'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import SelectCircle from '@components/SelectCircle'; import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/UserListItem'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; @@ -348,6 +349,7 @@ function MoneyRequestParticipantsSelector({ ) : ( <> + + + Navigation.navigate(ROUTES.SETTINGS_SHARE_CODE)} + > + + + + + + + Navigation.navigate(ROUTES.SETTINGS_STATUS)} + > + + {emojiCode ? ( + {emojiCode} + ) : ( + + )} + + + + App.setLocaleAndNavigate(language.value)} initiallyFocusedOptionKey={_.find(localesToLanguages, (locale) => locale.isSelected).keyForList} /> diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js index 983e3cb26746..05c0546c2e41 100644 --- a/src/pages/settings/Preferences/PriorityModePage.js +++ b/src/pages/settings/Preferences/PriorityModePage.js @@ -5,6 +5,7 @@ import _, {compose} from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -57,6 +58,7 @@ function PriorityModePage(props) { {props.translate('priorityModePage.explainerText')} mode.isSelected).keyForList} /> diff --git a/src/pages/settings/Preferences/ThemePage.js b/src/pages/settings/Preferences/ThemePage.js index 4907056be761..0724eb286620 100644 --- a/src/pages/settings/Preferences/ThemePage.js +++ b/src/pages/settings/Preferences/ThemePage.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -48,6 +49,7 @@ function ThemePage(props) { User.updateTheme(theme.value)} initiallyFocusedOptionKey={_.find(localesToThemes, (theme) => theme.isSelected).keyForList} /> diff --git a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js index 61208447495d..290d6431492d 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js +++ b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js @@ -8,7 +8,7 @@ import FormProvider from '@components/Form/FormProvider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; -import BaseListItem from '@components/SelectionList/BaseListItem'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps} from '@components/withCurrentUserPersonalDetails'; import withLocalize from '@components/withLocalize'; @@ -156,10 +156,9 @@ function StatusClearAfterPage({currentUserPersonalDetails, customStatus}) { const timePeriodOptions = useCallback( () => - _.map(statusType, (item, index) => ( - ( + updateMode(item)} showTooltip={false} isFocused={item.isSelected} diff --git a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js index 38c4d1eac449..d8327041538d 100644 --- a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js +++ b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import searchCountryOptions from '@libs/searchCountryOptions'; @@ -93,6 +94,7 @@ function CountrySelectionPage({route, navigation}) { textInputLabel={translate('common.country')} textInputValue={searchValue} sections={[{data: searchResults, indexOffset: 0}]} + ListItem={RadioListItem} onSelectRow={selectCountry} onChangeText={setSearchValue} initiallyFocusedOptionKey={currentCountry} diff --git a/src/pages/settings/Profile/PronounsPage.js b/src/pages/settings/Profile/PronounsPage.js index 5bb528373e8f..1d4675a42b8a 100644 --- a/src/pages/settings/Profile/PronounsPage.js +++ b/src/pages/settings/Profile/PronounsPage.js @@ -7,6 +7,7 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; @@ -100,6 +101,7 @@ function PronounsPage({currentUserPersonalDetails, isLoadingApp}) { textInputPlaceholder={translate('pronounsPage.placeholderText')} textInputValue={searchValue} sections={[{data: filteredPronounsList, indexOffset: 0}]} + ListItem={RadioListItem} onSelectRow={updatePronouns} onChangeText={setSearchValue} initiallyFocusedOptionKey={currentPronounsKey} diff --git a/src/pages/settings/Profile/TimezoneSelectPage.js b/src/pages/settings/Profile/TimezoneSelectPage.js index 8280d9b5c604..b6c8a5967abc 100644 --- a/src/pages/settings/Profile/TimezoneSelectPage.js +++ b/src/pages/settings/Profile/TimezoneSelectPage.js @@ -4,6 +4,7 @@ import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; @@ -97,6 +98,7 @@ function TimezoneSelectPage(props) { initiallyFocusedOptionKey={_.get(_.filter(timezoneOptions, (tz) => tz.text === timezone.selected)[0], 'keyForList')} showScrollIndicator shouldShowTooltips={false} + ListItem={RadioListItem} />
); diff --git a/src/pages/settings/Report/NotificationPreferencePage.tsx b/src/pages/settings/Report/NotificationPreferencePage.tsx index 05f3483f7ce8..3977bdd0233d 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.tsx +++ b/src/pages/settings/Report/NotificationPreferencePage.tsx @@ -4,6 +4,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import * as ReportUtils from '@libs/ReportUtils'; import type {ReportSettingsNavigatorParamList} from '@navigation/types'; @@ -39,6 +40,7 @@ function NotificationPreferencePage({report}: NotificationPreferencePageProps) { /> report && ReportActions.updateNotificationPreference(report.reportID, report.notificationPreference, option.value, true, undefined, undefined, report) } diff --git a/src/pages/settings/Report/ReportSettingsPage.tsx b/src/pages/settings/Report/ReportSettingsPage.tsx index 613dcd460e26..d738fc7ac3cf 100644 --- a/src/pages/settings/Report/ReportSettingsPage.tsx +++ b/src/pages/settings/Report/ReportSettingsPage.tsx @@ -43,6 +43,7 @@ function ReportSettingsPage({report, policies}: ReportSettingsPageProps) { const writeCapabilityText = translate(`writeCapabilityPage.writeCapability.${writeCapability}`); const shouldAllowWriteCapabilityEditing = useMemo(() => ReportUtils.canEditWriteCapability(report, linkedWorkspace), [report, linkedWorkspace]); + const shouldAllowChangeVisibility = useMemo(() => ReportUtils.canEditRoomVisibility(report, linkedWorkspace), [report, linkedWorkspace]); const shouldShowNotificationPref = !isMoneyRequestReport && report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const roomNameLabel = translate(isMoneyRequestReport ? 'workspace.editor.nameInputLabel' : 'newRoomPage.roomName'); @@ -141,8 +142,17 @@ function ReportSettingsPage({report, policies}: ReportSettingsPageProps) { />
)} - {report?.visibility !== undefined && ( - + + {report?.visibility !== undefined && + (shouldAllowChangeVisibility ? ( + Navigation.navigate(ROUTES.REPORT_SETTINGS_VISIBILITY.getRoute(report.reportID))} + /> + ) : ( + {translate(`newRoomPage.${report.visibility}Description`)} - )} - + ))}
diff --git a/src/pages/settings/Report/VisibilityPage.tsx b/src/pages/settings/Report/VisibilityPage.tsx new file mode 100644 index 000000000000..a03068832637 --- /dev/null +++ b/src/pages/settings/Report/VisibilityPage.tsx @@ -0,0 +1,96 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useMemo, useState} from 'react'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import useLocalize from '@hooks/useLocalize'; +import type {ReportSettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as ReportUtils from '@libs/ReportUtils'; +import type {WithReportOrNotFoundProps} from '@pages/home/report/withReportOrNotFound'; +import withReportOrNotFound from '@pages/home/report/withReportOrNotFound'; +import * as ReportActions from '@userActions/Report'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import type {RoomVisibility} from '@src/types/onyx/Report'; + +type VisibilityProps = WithReportOrNotFoundProps & StackScreenProps; + +function VisibilityPage({report}: VisibilityProps) { + const [showConfirmModal, setShowConfirmModal] = useState(false); + + const shouldDisableVisibility = ReportUtils.isArchivedRoom(report); + const {translate} = useLocalize(); + + const visibilityOptions = useMemo( + () => + Object.values(CONST.REPORT.VISIBILITY) + .filter((visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE) + .map((visibilityOption) => ({ + text: translate(`newRoomPage.visibilityOptions.${visibilityOption}`), + value: visibilityOption, + alternateText: translate(`newRoomPage.${visibilityOption}Description`), + keyForList: visibilityOption, + isSelected: visibilityOption === report?.visibility, + })), + [translate, report?.visibility], + ); + + const changeVisibility = useCallback( + (newVisibility: RoomVisibility) => { + if (!report) { + return; + } + ReportActions.updateRoomVisibility(report.reportID, report.visibility, newVisibility, true, report); + }, + [report], + ); + + const hideModal = useCallback(() => { + setShowConfirmModal(false); + }, []); + + return ( + + + ReportUtils.goBackToDetailsPage(report)} + /> + { + if (option.value === CONST.REPORT.VISIBILITY.PUBLIC) { + setShowConfirmModal(true); + return; + } + changeVisibility(option.value); + }} + initiallyFocusedOptionKey={visibilityOptions.find((visibility) => visibility.isSelected)?.keyForList} + /> + { + changeVisibility(CONST.REPORT.VISIBILITY.PUBLIC); + hideModal(); + }} + onCancel={hideModal} + title={translate('common.areYouSure')} + prompt={translate('newRoomPage.publicDescription')} + confirmText={translate('common.yes')} + cancelText={translate('common.no')} + danger + /> + + + ); +} + +VisibilityPage.displayName = 'VisibilityPage'; + +export default withReportOrNotFound()(VisibilityPage); diff --git a/src/pages/settings/Report/WriteCapabilityPage.tsx b/src/pages/settings/Report/WriteCapabilityPage.tsx index 5f5fe73e5199..1f991ef87c9a 100644 --- a/src/pages/settings/Report/WriteCapabilityPage.tsx +++ b/src/pages/settings/Report/WriteCapabilityPage.tsx @@ -6,6 +6,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; @@ -52,6 +53,7 @@ function WriteCapabilityPage({report, policy}: WriteCapabilityPageProps) { /> report && ReportActions.updateWriteCapabilityAndNavigate(report, option.value)} initiallyFocusedOptionKey={writeCapabilityOptions.find((locale) => locale.isSelected)?.keyForList} /> diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index 06d68ff575c2..bcd1c1096b0e 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -139,8 +139,7 @@ function BaseGetPhysicalCard({ } // Redirect user to previous steps of the flow if he hasn't finished them yet - const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues); - GetPhysicalCardUtils.setCurrentRoute(currentRoute, domain, updatedPrivatePersonalDetails, loginList); + GetPhysicalCardUtils.setCurrentRoute(currentRoute, domain, GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues)); isRouteSet.current = true; }, [cardList, currentRoute, domain, draftValues, loginList, privatePersonalDetails]); @@ -159,8 +158,8 @@ function BaseGetPhysicalCard({ Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain)); return; } - GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails, loginList); - }, [cardList, domain, draftValues, isConfirmation, loginList, session?.authToken]); + GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails); + }, [cardList, domain, draftValues, isConfirmation, session?.authToken]); return ( { - const updatedDraftValues = GetPhysicalCardUtils.getUpdatedDraftValues(draftValues, privatePersonalDetails, loginList); + let updatedDraftValues = draftValues; + + if (!draftValues) { + updatedDraftValues = GetPhysicalCardUtils.getUpdatedDraftValues(null, privatePersonalDetails, loginList); + // Form draft data needs to be initialized with the private personal details + // If no draft data exists + FormActions.setDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM, updatedDraftValues); + } - GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(updatedDraftValues), loginList); + GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(updatedDraftValues)); }; const hasDetectedDomainFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN); diff --git a/src/pages/tasks/TaskAssigneeSelectorModal.js b/src/pages/tasks/TaskAssigneeSelectorModal.js index 14d2867aa1f4..0e1e64dfa415 100644 --- a/src/pages/tasks/TaskAssigneeSelectorModal.js +++ b/src/pages/tasks/TaskAssigneeSelectorModal.js @@ -11,6 +11,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useBetas, usePersonalDetails, useSession} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/UserListItem'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; @@ -203,6 +204,7 @@ function TaskAssigneeSelectorModal({reports, task, rootParentReportPolicy}) { { diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index c88b3d56cb20..62b96943453c 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -16,6 +16,7 @@ import networkPropTypes from '@components/networkPropTypes'; import {withNetwork} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/UserListItem'; import Text from '@components/Text'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; @@ -488,6 +489,7 @@ function WorkspaceMembersPage(props) { { diff --git a/src/pages/workspace/WorkspaceProfileCurrencyPage.js b/src/pages/workspace/WorkspaceProfileCurrencyPage.js index 31b88c7c487b..bd13ce4687f5 100644 --- a/src/pages/workspace/WorkspaceProfileCurrencyPage.js +++ b/src/pages/workspace/WorkspaceProfileCurrencyPage.js @@ -6,6 +6,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; @@ -90,6 +91,7 @@ function WorkspaceSettingsCurrencyPage({currencyList, policy, isLoadingReportDat updateUnit(unit.value)} initiallyFocusedOptionKey={unitOptions.find((unit) => unit.isSelected)?.keyForList} /> diff --git a/src/stories/SelectionList.stories.js b/src/stories/SelectionList.stories.js index 835bf67fbfd7..dcd639119886 100644 --- a/src/stories/SelectionList.stories.js +++ b/src/stories/SelectionList.stories.js @@ -2,6 +2,7 @@ import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import _ from 'underscore'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; // eslint-disable-next-line no-restricted-imports import {defaultStyles} from '@styles/index'; @@ -89,6 +90,7 @@ function Default(args) { // eslint-disable-next-line react/jsx-props-no-spreading {...args} sections={sections} + ListItem={RadioListItem} onSelectRow={onSelectRow} /> ); @@ -137,6 +139,7 @@ function WithTextInput(args) { // eslint-disable-next-line react/jsx-props-no-spreading {...args} sections={sections} + ListItem={RadioListItem} textInputValue={searchText} onChangeText={setSearchText} onSelectRow={onSelectRow} @@ -260,6 +263,7 @@ function MultipleSelection(args) { // eslint-disable-next-line react/jsx-props-no-spreading {...args} sections={memo.sections} + ListItem={RadioListItem} onSelectRow={onSelectRow} onSelectAll={onSelectAll} /> @@ -322,6 +326,7 @@ function WithSectionHeader(args) { // eslint-disable-next-line react/jsx-props-no-spreading {...args} sections={memo.sections} + ListItem={RadioListItem} onSelectRow={onSelectRow} onSelectAll={onSelectAll} /> @@ -382,6 +387,7 @@ function WithConfirmButton(args) { // eslint-disable-next-line react/jsx-props-no-spreading {...args} sections={memo.sections} + ListItem={RadioListItem} onSelectRow={onSelectRow} onSelectAll={onSelectAll} /> diff --git a/src/styles/index.ts b/src/styles/index.ts index 856f8ece917c..13b2015d2c9c 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -179,10 +179,9 @@ const webViewStyles = (theme: ThemeColors) => pre: { ...baseCodeTagStyles(theme), - paddingTop: 12, - paddingBottom: 12, - paddingRight: 8, - paddingLeft: 8, + paddingVertical: 8, + paddingHorizontal: 12, + fontSize: 13, fontFamily: FontUtils.fontFamily.platform.MONOSPACE, marginTop: 0, marginBottom: 0, @@ -827,6 +826,8 @@ const styles = (theme: ThemeColors) => borderWidth: 1, borderRadius: variables.componentBorderRadiusSmall, borderColor: theme.border, + paddingHorizontal: 12, + minHeight: 28, }, badgeText: { @@ -836,6 +837,10 @@ const styles = (theme: ThemeColors) => ...whiteSpace.noWrap, }, + activeItemBadge: { + borderColor: theme.buttonHoveredBG, + }, + border: { borderWidth: 1, borderRadius: variables.componentBorderRadius, @@ -3062,6 +3067,20 @@ const styles = (theme: ThemeColors) => bottom: -8, }, + primaryMediumIcon: { + alignItems: 'center', + backgroundColor: theme.buttonDefaultBG, + borderRadius: 20, + color: theme.textReversed, + height: 40, + width: 40, + justifyContent: 'center', + }, + + primaryMediumText: { + fontSize: variables.iconSizeNormal, + }, + workspaceOwnerAvatarWrapper: { margin: 6, }, diff --git a/src/styles/utils/autoCompleteSuggestion/index.android.ts b/src/styles/utils/autoCompleteSuggestion/index.android.ts new file mode 100644 index 000000000000..88b7a7c84297 --- /dev/null +++ b/src/styles/utils/autoCompleteSuggestion/index.android.ts @@ -0,0 +1,5 @@ +import type ShouldPreventScrollOnAutoCompleteSuggestion from './types'; + +const shouldPreventScrollOnAutoCompleteSuggestion: ShouldPreventScrollOnAutoCompleteSuggestion = () => false; + +export default shouldPreventScrollOnAutoCompleteSuggestion; diff --git a/src/styles/utils/autoCompleteSuggestion/index.ts b/src/styles/utils/autoCompleteSuggestion/index.ts new file mode 100644 index 000000000000..e756e7178c57 --- /dev/null +++ b/src/styles/utils/autoCompleteSuggestion/index.ts @@ -0,0 +1,5 @@ +import type ShouldPreventScrollOnAutoCompleteSuggestion from './types'; + +const shouldPreventScrollOnAutoCompleteSuggestion: ShouldPreventScrollOnAutoCompleteSuggestion = () => true; + +export default shouldPreventScrollOnAutoCompleteSuggestion; diff --git a/src/styles/utils/autoCompleteSuggestion/index.website.ts b/src/styles/utils/autoCompleteSuggestion/index.website.ts new file mode 100644 index 000000000000..badec5dfc774 --- /dev/null +++ b/src/styles/utils/autoCompleteSuggestion/index.website.ts @@ -0,0 +1,8 @@ +import * as Browser from '@libs/Browser'; +import type ShouldPreventScrollOnAutoCompleteSuggestion from './types'; + +const isMobileSafari = Browser.isMobileSafari(); + +const shouldPreventScrollOnAutoCompleteSuggestion: ShouldPreventScrollOnAutoCompleteSuggestion = () => !isMobileSafari; + +export default shouldPreventScrollOnAutoCompleteSuggestion; diff --git a/src/styles/utils/autoCompleteSuggestion/types.ts b/src/styles/utils/autoCompleteSuggestion/types.ts new file mode 100644 index 000000000000..563d305eb236 --- /dev/null +++ b/src/styles/utils/autoCompleteSuggestion/types.ts @@ -0,0 +1,3 @@ +type ShouldPreventScrollOnAutoCompleteSuggestion = () => boolean; + +export default ShouldPreventScrollOnAutoCompleteSuggestion; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index a0edb7fd4e23..69e74bb54e63 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -14,6 +14,7 @@ import CONST from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; import {defaultStyles} from '..'; import type {ThemeStyles} from '..'; +import shouldPreventScrollOnAutoCompleteSuggestion from './autoCompleteSuggestion'; import getCardStyles from './cardStyles'; import containerComposeStyles from './containerComposeStyles'; import FontUtils from './FontUtils'; @@ -790,6 +791,8 @@ function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}: GetB }; } +const shouldPreventScroll = shouldPreventScrollOnAutoCompleteSuggestion(); + /** * Gets the correct position for auto complete suggestion container */ @@ -797,13 +800,13 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle 'worklet'; const borderWidth = 2; - const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING; + const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + (shouldPreventScroll ? borderWidth : 0); // The suggester is positioned absolutely within the component that includes the input and RecipientLocalTime view (for non-expanded mode only). To position it correctly, // we need to shift it by the suggester's height plus its padding and, if applicable, the height of the RecipientLocalTime view. return { overflow: 'hidden', - top: -(height + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + borderWidth), + top: -(height + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + (shouldPreventScroll ? 0 : borderWidth)), height, minHeight: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, }; @@ -1474,6 +1477,14 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ }, getFullscreenCenteredContentStyles: () => [StyleSheet.absoluteFill, styles.justifyContentCenter, styles.alignItemsCenter], + + getMultiselectListStyles: (isSelected: boolean, isDisabled: boolean): ViewStyle => ({ + ...styles.mr3, + ...(isSelected && styles.checkedContainer), + ...(isSelected && styles.borderColorFocus), + ...(isDisabled && styles.cursorDisabled), + ...(isDisabled && styles.buttonOpacityDisabled), + }), }); type StyleUtilsType = ReturnType; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 03446e813949..911f1ea5f281 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -30,4 +30,5 @@ declare module '*.lottie' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { setSupportToken: (token: string, email: string, accountID: number) => void; + shouldAllowRawHTMLMessages: boolean; } diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 7d4c08374b81..ad3235d4eea4 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -11,6 +11,7 @@ type Rate = { customUnitRateID?: string; errors?: OnyxCommon.Errors; pendingAction?: OnyxCommon.PendingAction; + enabled?: boolean; }; type Attributes = { @@ -22,6 +23,8 @@ type CustomUnit = { customUnitID: string; attributes: Attributes; rates: Record; + defaultCategory?: string; + enabled?: boolean; pendingAction?: OnyxCommon.PendingAction; errors?: OnyxCommon.Errors; }; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index fbd61a9c5365..f5c4606fd335 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -8,6 +8,8 @@ type NotificationPreference = ValueOf; +type RoomVisibility = ValueOf; + type Note = { note: string; errors?: OnyxCommon.Errors; @@ -110,7 +112,7 @@ type Report = { openOnAdminRoom?: boolean; /** The report visibility */ - visibility?: ValueOf; + visibility?: RoomVisibility; /** Report cached total */ cachedTotal?: string; @@ -178,4 +180,4 @@ type Report = { export default Report; -export type {NotificationPreference, WriteCapability, Note}; +export type {NotificationPreference, RoomVisibility, WriteCapability, Note}; diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index cb31afbf8f8f..c43837fbfd34 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -1742,7 +1742,7 @@ describe('actions/IOU', () => { }), ]), originalMessage: expect.objectContaining({ - amount: -amount, + amount, paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, type: 'pay', }), diff --git a/tests/e2e/ADDING_TESTS.md b/tests/e2e/ADDING_TESTS.md index 8d4683636e70..f262a5ed9a0a 100644 --- a/tests/e2e/ADDING_TESTS.md +++ b/tests/e2e/ADDING_TESTS.md @@ -1,51 +1,5 @@ # Adding new E2E Tests -## Running your new test in development mode - -Typically you'd run all the tests with `npm run test:e2e` on your machine. -This will run the tests with some local settings, however that is not -optimal when you add a new test for which you want to quickly test if it works, as the prior command -still runs the release version of the app, which is hard to debug. - -I recommend doing the following. - -1. We need to compile a android development app version that has capturing metrics enabled: -```bash -# Make sure that your .env file is the one we need for e2e testing: -cp ./tests/e2e/.env.e2e .env - -# Build the android app like you normally would with -npm run android -``` -2. Rename `./index.js` to `./appIndex.js` -3. Create a new `./index.js` with the following content: -```js -require('./src/libs/E2E/reactNativeLaunchingTest'); -``` -4. In `./src/libs/E2E/reactNativeLaunchingTest.ts` change the main app import to the new `./appIndex.js` file: -```diff -- import '../../../index'; -+ import '../../../appIndex'; -``` - -> [!WARNING] -> Make sure to not commit these changes to the repository! - -Now you can start the metro bundler in e2e mode with: - -```bash -CAPTURE_METRICS=true E2E_TESTING=true npm start -- --reset-cache -``` - -Then we can execute our test with: - -``` -npm run test:e2e:dev -- --includes "My new test name" -``` - -> - `--includes "MyTestName"` will only run the test with the name "MyTestName", but is optional - - ## Creating a new test Tests are executed on device, inside the app code. @@ -144,8 +98,13 @@ Done! When you now start the test runner, your new test will be executed as well ## Quickly test your test -To check your new test you can simply run `npm run test:e2e`, which uses the -`--development` flag. This will run the tests on the branch you are currently on, runs fewer iterations and most importantly, it tries to reuse the existing APK and just patch into the new app bundle, instead of rebuilding the release app from scratch. +> [!TIP] +> You can only run a specific test by specifying the `--includes` flag: +> ```sh +> npm run test:e2e:dev -- --includes "My new test name" +> ``` + +It is recommended to run a debug build of the e2e tests first to iterate quickly on your test. Follow the explanation in the [README](./README.md) to create a debug build. ## Debugging your test diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 64d11d3b2ca4..5f124f20e872 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -6,20 +6,6 @@ run the actual app on a real device (physical or emulated). ![Example of a e2e test run](https://raw.githubusercontent.com/hannojg/expensify-app/5f945c25e2a0650753f47f3f541b984f4d114f6d/e2e/example.gif) -To run the e2e tests: - -1. Connect an android device. The tests are currently designed to run only on android. It can be - a physical device or an emulator. - -2. Make sure Fastlane was initialized by running `bundle install` - -3. Run the tests with `npm run test:e2e`. - > πŸ’‘ Tip: To run the tests locally faster, and you are only making changes to JS, it's recommended to - build the app once with `npm run android-build-e2e` and from then on run the tests with - `npm run test:e2e -- --buildMode js-only`. This will only rebuild the JS code, and not the - whole native app! - -Ideally you want to run these tests on your branch before you want to merge your new feature to `main`. ## Available CLI options @@ -27,23 +13,78 @@ The tests can be run with the following CLI options: - `--config`: Extend/Overwrite the default config with your values, e.g. `--config config.local.ts` - `--includes`: Expects a string/regexp to filter the tests to run, e.g. `--includes "login|signup"` -- `--skipInstallDeps`: Skips the `npm install` step, useful during development -- `--development`: Applies some default configurations: - - Sets the config to `config.local.ts`, which executes the tests with fewer iterations - - Runs the tests only on the current branch -- `--buildMode`: There are three build modes, the default is `full`: - 1. **full**: rebuilds the full native app in (e2e) release mode - 2. **js-only**: only rebuilds the js bundle, and then re-packages - the existing native app with the new package. If there - is no existing native app, it will fallback to mode "full" - 3. **skip**: does not rebuild anything, and just runs the existing native app -- `--skipCheckout`: Won't checkout any baseline or comparison branch, and will just run the tests - -## Available environment variables - -The tests can be run with the following environment variables: - -- `baseline`: Change the baseline to run the tests again (default is `main`). + +## Running the tests on your machine + +You have two options when running the e2e tests: + +1. Run a debug build of the app (useful when developing a test) +2. Run two (e2e) release builds against each other (useful to test performance regression and the suite as a whole) + +### Running a debug build + +1. You need to create a debug build of the app that's configured with some build flags to enable e2e testing. +The build flags should be specified in your `./.env` file. You can use the `./tests/e2e/.env.e2e` file as a template: + +```sh +cp ./tests/e2e/.env.e2e .env +``` + +> [!IMPORTANT] +> There are some non-public environment variables that you still have to add to the `.env` file. Ask on slack for the values (cc @vit, @andrew, @hanno gΓΆdecke). + +2. Create a new android build like you usually would: + +```sh +npm run android +``` + +3. We need to modify the app entry to point to the one for the tests. Therefore rename `./index.js` to `./appIndex.js` temporarily. + +4. Create a new `./index.js` with the following content: +```js +require('./src/libs/E2E/reactNativeLaunchingTest'); +``` + +5. In `./src/libs/E2E/reactNativeLaunchingTest.ts` change the main app import to the new `./appIndex.js` file: +```diff +- import '../../../index'; ++ import '../../../appIndex'; +``` + +6. You can now run the tests. This command will invoke the test runner: + +```sh +npm run test:e2e:dev +``` + +### Running two release builds + +The e2e tests are meant to detect performance regressions. For that we need to compare two builds against each other. On the CI system this is e.g. the latest release build (baseline) VS the currently merged PR (compare). + +You need to build the two apps first. Note that the two apps will be installed on the same device at the same time, so both apps have a different package name. Therefor, we have special build types for the e2e tests. + +1. Create a new android build for the baseline: + +> [!IMPORTANT] +> There are some non-public environment variables that you still have to add to the `./tests/e2e/.env.e2e` and `./tests/e2e/.env.e2edelta` file. Ask on slack for the values (cc @vit, @andrew, @hanno gΓΆdecke). + +```sh +npm run android-build-e2e +``` + +2. Create a new android build for the compare: + +```sh +npm run android-build-e2edelta +``` + +3. Run the tests: + +```sh +npm run test:e2e +``` + ## Performance regression testing diff --git a/tests/e2e/TestSpec.yml b/tests/e2e/TestSpec.yml index acc5926e93a5..e0dcd2b9b66d 100644 --- a/tests/e2e/TestSpec.yml +++ b/tests/e2e/TestSpec.yml @@ -22,7 +22,7 @@ phases: commands: - cd zip - npm install underscore ts-node typescript - - npx ts-node e2e/testRunner.js -- --skipInstallDeps --buildMode "skip" --skipCheckout --mainAppPath app-e2eRelease.apk --deltaAppPath app-e2edeltaRelease.apk + - npx ts-node e2e/testRunner.js -- --mainAppPath app-e2eRelease.apk --deltaAppPath app-e2edeltaRelease.apk artifacts: - $WORKING_DIRECTORY diff --git a/tests/e2e/config.dev.js b/tests/e2e/config.dev.js index 8d12fb5ce007..0e5d3dc01a95 100644 --- a/tests/e2e/config.dev.js +++ b/tests/e2e/config.dev.js @@ -7,4 +7,5 @@ export default { MAIN_APP_PATH: appPath, DELTA_APP_PATH: appPath, RUNS: 8, + BOOT_COOL_DOWN: 5 * 1000, }; diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js index cbdb22de6c63..77b2033dfece 100644 --- a/tests/e2e/testRunner.js +++ b/tests/e2e/testRunner.js @@ -21,7 +21,6 @@ import compare from './compare/compare'; import defaultConfig from './config'; import createServerInstance from './server'; import reversePort from './utils/androidReversePort'; -import execAsync from './utils/execAsync'; import installApp from './utils/installApp'; import killApp from './utils/killApp'; import launchApp from './utils/launchApp'; @@ -39,16 +38,6 @@ const getArg = (argName) => { return args[argIndex + 1]; }; -let branch = 'main'; -if (args.includes('--branch')) { - branch = getArg('--branch'); -} - -let label = branch; -if (args.includes('--label')) { - label = getArg('--label'); -} - let config = defaultConfig; const setConfigPath = (configPathParam) => { let configPath = configPathParam; @@ -59,164 +48,40 @@ const setConfigPath = (configPathParam) => { config = _.extend(defaultConfig, customConfig); }; -const skipCheckout = args.includes('--skipCheckout'); - -const skipInstallDeps = args.includes('--skipInstallDeps'); - -// There are three build modes: -// 1. full: rebuilds the full native app in (e2e) release mode -// 2. js-only: only rebuilds the js bundle, and then re-packages -// the existing native app with the new bundle. If there -// is no existing native app, it will fallback to mode "full" -// 3. skip: does not rebuild anything, and just runs the existing native app -let buildMode = 'full'; - -// When we are in dev mode we want to apply certain default params and configs -const isDevMode = args.includes('--development'); -if (isDevMode) { - setConfigPath('config.local.ts'); - buildMode = 'js-only'; -} - -if (args.includes('--buildMode')) { - buildMode = getArg('--buildMode'); -} - if (args.includes('--config')) { const configPath = getArg('--config'); setConfigPath(configPath); } -// Important set app path after correct config file has been set -let mainAppPath = getArg('--mainAppPath') || config.MAIN_APP_PATH; -let deltaAppPath = getArg('--deltaAppPath') || config.DELTA_APP_PATH; - -// Create some variables after the correct config file has been loaded -const OUTPUT_FILE = `${config.OUTPUT_DIR}/${label}.json`; - -if (isDevMode) { - Logger.note(`🟠 Running in development mode.`); +// Important: set app path only after correct config file has been loaded +const mainAppPath = getArg('--mainAppPath') || config.MAIN_APP_PATH; +const deltaAppPath = getArg('--deltaAppPath') || config.DELTA_APP_PATH; +// Check if files exists: +if (!fs.existsSync(mainAppPath)) { + throw new Error(`Main app path does not exist: ${mainAppPath}`); +} +if (!fs.existsSync(deltaAppPath)) { + throw new Error(`Delta app path does not exist: ${deltaAppPath}`); } -if (isDevMode) { - // On dev mode only delete any existing output file but keep the folder - if (fs.existsSync(OUTPUT_FILE)) { - fs.rmSync(OUTPUT_FILE); - } -} else { - // On CI it is important to re-create the output dir, it has a different owner - // therefore this process cannot write to it - try { - fs.rmSync(config.OUTPUT_DIR, {recursive: true, force: true}); +// On CI it is important to re-create the output dir, it has a different owner +// therefore this process cannot write to it +try { + fs.rmSync(config.OUTPUT_DIR, {recursive: true, force: true}); - fs.mkdirSync(config.OUTPUT_DIR); - } catch (error) { - // Do nothing - console.error(error); - } + fs.mkdirSync(config.OUTPUT_DIR); +} catch (error) { + // Do nothing + console.error(error); } // START OF TEST CODE const runTests = async () => { - // check if using buildMode "js-only" or "none" is possible - if (buildMode !== 'full') { - const mainAppExists = fs.existsSync(mainAppPath); - const deltaAppExists = fs.existsSync(deltaAppPath); - if (!mainAppExists || !deltaAppExists) { - Logger.warn(`Build mode "${buildMode}" is not possible, because the app does not exist. Falling back to build mode "full".`); - Logger.note(`App path: ${mainAppPath}`); - - buildMode = 'full'; - } - } - - // Build app - if (buildMode === 'full') { - Logger.log(`Test setup - building main branch`); - - if (!skipCheckout) { - // Switch branch - Logger.log(`Test setup - checkout main`); - await execAsync(`git checkout main`); - } - - if (!skipInstallDeps) { - Logger.log(`Test setup - npm install`); - await execAsync('npm i'); - } - - await execAsync('npm run android-build-e2e'); - - if (branch != null && !skipCheckout) { - // Switch branch - Logger.log(`Test setup - checkout branch '${branch}'`); - await execAsync(`git checkout ${branch}`); - } - - if (!skipInstallDeps) { - Logger.log(`Test setup - npm install`); - await execAsync('npm i'); - } - - Logger.log(`Test setup '${branch}' - building delta branch`); - await execAsync('npm run android-build-e2edelta'); - } else if (buildMode === 'js-only') { - Logger.log(`Test setup '${branch}' - building js bundle`); - - if (!skipInstallDeps) { - Logger.log(`Test setup '${branch}' - npm install`); - await execAsync('npm i'); - } - - // Build a new JS bundle - if (!skipCheckout) { - // Switch branch - Logger.log(`Test setup - checkout main`); - await execAsync(`git checkout main`); - } - - if (!skipInstallDeps) { - Logger.log(`Test setup - npm install`); - await execAsync('npm i'); - } - - const tempDir = `${config.OUTPUT_DIR}/temp`; - let tempBundlePath = `${tempDir}/index.android.bundle`; - await execAsync(`rm -rf ${tempDir} && mkdir ${tempDir}`); - await execAsync(`npx react-native bundle --platform android --dev false --entry-file ${config.ENTRY_FILE} --bundle-output ${tempBundlePath}`, {E2E_TESTING: 'true'}); - // Repackage the existing native app with the new bundle - let tempApkPath = `${tempDir}/app-release.apk`; - await execAsync(`./scripts/android-repackage-app-bundle-and-sign.sh ${mainAppPath} ${tempBundlePath} ${tempApkPath}`); - mainAppPath = tempApkPath; - - // Build a new JS bundle - if (!skipCheckout) { - // Switch branch - Logger.log(`Test setup - checkout main`); - await execAsync(`git checkout ${branch}`); - } - - if (!skipInstallDeps) { - Logger.log(`Test setup - npm install`); - await execAsync('npm i'); - } - - tempBundlePath = `${tempDir}/index.android.bundle`; - await execAsync(`rm -rf ${tempDir} && mkdir ${tempDir}`); - await execAsync(`npx react-native bundle --platform android --dev false --entry-file ${config.ENTRY_FILE} --bundle-output ${tempBundlePath}`, {E2E_TESTING: 'true'}); - // Repackage the existing native app with the new bundle - tempApkPath = `${tempDir}/app-release.apk`; - await execAsync(`./scripts/android-repackage-app-bundle-and-sign.sh ${deltaAppPath} ${tempBundlePath} ${tempApkPath}`); - deltaAppPath = tempApkPath; - } - - let progressLog = Logger.progressInfo('Installing apps and reversing port'); - + Logger.info('Installing apps and reversing port'); await installApp('android', config.MAIN_APP_PACKAGE, mainAppPath); await installApp('android', config.DELTA_APP_PACKAGE, deltaAppPath); await reversePort(); - progressLog.done(); // Start the HTTP server const server = createServerInstance(); @@ -251,162 +116,107 @@ const runTests = async () => { results[testResult.branch][testResult.name] = (results[testResult.branch][testResult.name] || []).concat(result); }); + // Function to run a single test iteration + async function runTestIteration(appPackage, iterationText, launchArgs) { + Logger.info(iterationText); + + // Making sure the app is really killed (e.g. if a prior test run crashed) + Logger.log('Killing', appPackage); + await killApp('android', appPackage); + + Logger.log('Launching', appPackage); + await launchApp('android', appPackage, config.ACTIVITY_PATH, launchArgs); + + await withFailTimeout( + new Promise((resolve) => { + const cleanup = server.addTestDoneListener(() => { + Logger.success(iterationText); + cleanup(); + resolve(); + }); + }), + iterationText, + ); + + Logger.log('Killing', appPackage); + await killApp('android', appPackage); + } + // Run the tests - const suites = _.values(config.TESTS_CONFIG); - for (let suiteIndex = 0; suiteIndex < suites.length; suiteIndex++) { - const suite = _.values(config.TESTS_CONFIG)[suiteIndex]; + const tests = _.values(config.TESTS_CONFIG); + for (let testIndex = 0; testIndex < tests.length; testIndex++) { + const test = _.values(config.TESTS_CONFIG)[testIndex]; // check if we want to skip the test if (args.includes('--includes')) { const includes = args[args.indexOf('--includes') + 1]; // assume that "includes" is a regexp - if (!suite.name.match(includes)) { + if (!test.name.match(includes)) { // eslint-disable-next-line no-continue continue; } } - const coolDownLogs = Logger.progressInfo(`Cooling down for ${config.BOOT_COOL_DOWN / 1000}s`); - coolDownLogs.updateText(`Cooling down for ${config.BOOT_COOL_DOWN / 1000}s`); - - // Having the cooldown right at the beginning should hopefully lower the chances of heat + // Having the cooldown right at the beginning lowers the chances of heat // throttling from the previous run (which we have no control over and will be a - // completely different AWS DF customer/app). It also gives the time to cool down between test suites. + // completely different AWS DF customer/app). It also gives the time to cool down between tests. + Logger.info(`Cooling down for ${config.BOOT_COOL_DOWN / 1000}s`); await sleep(config.BOOT_COOL_DOWN); - coolDownLogs.done(); - - server.setTestConfig(suite); - - const warmupLogs = Logger.progressInfo(`Running warmup '${suite.name}'`); - - let progressText = `Warmup for suite '${suite.name}' [${suiteIndex + 1}/${suites.length}]\n`; - warmupLogs.updateText(progressText); - - Logger.log('Killing main app'); - await killApp('android', config.MAIN_APP_PACKAGE); - Logger.log('Launching main app'); - await launchApp('android', config.MAIN_APP_PACKAGE); - - await withFailTimeout( - new Promise((resolve) => { - const cleanup = server.addTestDoneListener(() => { - Logger.log('Main warm up ready βœ…'); - cleanup(); - resolve(); - }); - }), - progressText, - ); - - Logger.log('Killing main app'); - await killApp('android', config.MAIN_APP_PACKAGE); - Logger.log('Killing delta app'); - await killApp('android', config.DELTA_APP_PACKAGE); - Logger.log('Launching delta app'); - await launchApp('android', config.DELTA_APP_PACKAGE); + server.setTestConfig(test); - await withFailTimeout( - new Promise((resolve) => { - const cleanup = server.addTestDoneListener(() => { - Logger.log('Delta warm up ready βœ…'); - cleanup(); - resolve(); - }); - }), - progressText, - ); + const warmupText = `Warmup for test '${test.name}' [${testIndex + 1}/${tests.length}]`; - Logger.log('Killing delta app'); - await killApp('android', config.DELTA_APP_PACKAGE); + // Warmup the main app: + await runTestIteration(config.MAIN_APP_PACKAGE, `[MAIN] ${warmupText}`); - warmupLogs.done(); + // Warmup the delta app: + await runTestIteration(config.DELTA_APP_PACKAGE, `[DELTA] ${warmupText}`); - // We run each test multiple time to average out the results - const testLog = Logger.progressInfo(''); // For each test case we allow the test to fail three times before we stop the test run: const errorCountRef = { errorCount: 0, allowedExceptions: 3, }; - for (let i = 0; i < config.RUNS; i++) { - progressText = `Suite '${suite.name}' [${suiteIndex + 1}/${suites.length}], iteration [${i + 1}/${config.RUNS}]\n`; - testLog.updateText(progressText); - - Logger.log('Killing delta app'); - await killApp('android', config.DELTA_APP_PACKAGE); - - Logger.log('Killing main app'); - await killApp('android', config.MAIN_APP_PACKAGE); - - Logger.log('Starting main app'); - await launchApp('android', config.MAIN_APP_PACKAGE, config.ACTIVITY_PATH, { - mockNetwork: true, - }); + // We run each test multiple time to average out the results + for (let testIteration = 0; testIteration < config.RUNS; testIteration++) { const onError = (e) => { - testLog.done(); errorCountRef.errorCount += 1; - if (i === 0 || errorCountRef.errorCount === errorCountRef.allowedExceptions) { + if (testIteration === 0 || errorCountRef.errorCount === errorCountRef.allowedExceptions) { + Logger.error("There was an error running the test and we've reached the maximum number of allowed exceptions. Stopping the test run."); // If the error happened on the first test run, the test is broken // and we should not continue running it. Or if we have reached the // maximum number of allowed exceptions, we should stop the test run. throw e; } - console.error(e); + Logger.warn(`There was an error running the test. Continuing the test run. Error: ${e}`); }; - // Wait for a test to finish by waiting on its done call to the http server - try { - await withFailTimeout( - new Promise((resolve) => { - const cleanup = server.addTestDoneListener(() => { - Logger.log(`Test iteration ${i + 1} done!`); - cleanup(); - resolve(); - }); - }), - progressText, - ); - } catch (e) { - onError(e); - } - - Logger.log('Killing main app'); - await killApp('android', config.MAIN_APP_PACKAGE); - - Logger.log('Starting delta app'); - await launchApp('android', config.DELTA_APP_PACKAGE, config.ACTIVITY_PATH, { + const launchArgs = { mockNetwork: true, - }); + }; - // Wait for a test to finish by waiting on its done call to the http server + const iterationText = `Test '${test.name}' [${testIndex + 1}/${tests.length}], iteration [${testIteration + 1}/${config.RUNS}]`; + const mainIterationText = `[MAIN] ${iterationText}`; + const deltaIterationText = `[DELTA] ${iterationText}`; try { - await withFailTimeout( - new Promise((resolve) => { - const cleanup = server.addTestDoneListener(() => { - Logger.log(`Test iteration ${i + 1} done!`); - cleanup(); - resolve(); - }); - }), - progressText, - ); + // Run the test on the main app: + await runTestIteration(config.MAIN_APP_PACKAGE, mainIterationText, launchArgs); + + // Run the test on the delta app: + await runTestIteration(config.DELTA_APP_PACKAGE, deltaIterationText, launchArgs); } catch (e) { onError(e); } } - testLog.done(); } // Calculate statistics and write them to our work file - progressLog = Logger.progressInfo('Calculating statics and writing results'); - + Logger.info('Calculating statics and writing results'); compare(results.main, results.delta, `${config.OUTPUT_DIR}/output.md`); - progressLog.done(); - await server.stop(); }; diff --git a/tests/e2e/utils/execAsync.js b/tests/e2e/utils/execAsync.js index 9abc41105f7e..b7dd93d8963e 100644 --- a/tests/e2e/utils/execAsync.js +++ b/tests/e2e/utils/execAsync.js @@ -16,7 +16,7 @@ export default (command, env = {}) => { ...env, }; - Logger.important(command); + Logger.note(command); childProcess = exec( command, @@ -33,7 +33,7 @@ export default (command, env = {}) => { reject(error); } } else { - Logger.note(stdout); + Logger.writeToLogFile(stdout); resolve(stdout); } }, diff --git a/tests/e2e/utils/logger.js b/tests/e2e/utils/logger.js index 5a7046d8c8b0..d0770b7aa8e4 100644 --- a/tests/e2e/utils/logger.js +++ b/tests/e2e/utils/logger.js @@ -1,28 +1,17 @@ import fs from 'fs'; import path from 'path'; +import _ from 'underscore'; import CONFIG from '../config'; -let isVerbose = true; -const setLogLevelVerbose = (value) => { - isVerbose = value; -}; - -// On CI systems when using .progressInfo, the current line won't reset but a new line gets added -// Which can flood the logs. You can increase this rate to mitigate this effect. -const LOGGER_PROGRESS_REFRESH_RATE = process.env.LOGGER_PROGRESS_REFRESH_RATE || 250; const COLOR_DIM = '\x1b[2m'; const COLOR_RESET = '\x1b[0m'; const COLOR_YELLOW = '\x1b[33m'; const COLOR_RED = '\x1b[31m'; -const COLOR_BLUE = '\x1b[34m'; const COLOR_GREEN = '\x1b[32m'; -const log = (...args) => { - if (isVerbose) { - console.debug(...args); - } +const getDateString = () => `[${Date()}] `; - // Write to log file +const writeToLogFile = (...args) => { if (!fs.existsSync(CONFIG.LOG_FILE)) { // Check that the directory exists const logDir = path.dirname(CONFIG.LOG_FILE); @@ -32,71 +21,57 @@ const log = (...args) => { fs.writeFileSync(CONFIG.LOG_FILE, ''); } - const time = new Date(); - const timeStr = `${time.getHours()}:${time.getMinutes()}:${time.getSeconds()} ${time.getMilliseconds()}`; - fs.appendFileSync(CONFIG.LOG_FILE, `[${timeStr}] ${args.join(' ')}\n`); + fs.appendFileSync( + CONFIG.LOG_FILE, + `${_.map(args, (arg) => { + if (typeof arg === 'string') { + // Remove color codes from arg, because they are not supported in log files + // eslint-disable-next-line no-control-regex + return arg.replace(/\x1b\[\d+m/g, ''); + } + return arg; + }) + .join(' ') + .trim()}\n`, + ); }; -const info = (...args) => { - log('> ', ...args); +const log = (...args) => { + const argsWithTime = [getDateString(), ...args]; + console.debug(...argsWithTime); + writeToLogFile(...argsWithTime); }; -const important = (...args) => { - const lines = [`🟦 ${COLOR_BLUE}`, ...args, `${COLOR_RESET}\n`]; - log(...lines); +const info = (...args) => { + log('▢️', ...args); }; const success = (...args) => { - const lines = [`🟦 ${COLOR_GREEN}`, ...args, `${COLOR_RESET}\n`]; + const lines = ['βœ…', COLOR_GREEN, ...args, COLOR_RESET]; log(...lines); }; const warn = (...args) => { - const lines = [`\n${COLOR_YELLOW}⚠️`, ...args, `${COLOR_RESET}\n`]; + const lines = ['⚠️', COLOR_YELLOW, ...args, COLOR_RESET]; log(...lines); }; const note = (...args) => { - const lines = [`${COLOR_DIM}`, ...args, `${COLOR_RESET}\n`]; + const lines = [COLOR_DIM, ...args, COLOR_RESET]; log(...lines); }; const error = (...args) => { - const lines = [`\nπŸ”΄ ${COLOR_RED}`, ...args, `${COLOR_RESET}\n`]; + const lines = ['πŸ”΄', COLOR_RED, ...args, COLOR_RESET]; log(...lines); }; -const progressInfo = (textParam) => { - let text = textParam || ''; - const getTexts = () => [`πŸ•› ${text}`, `πŸ•” ${text}`, `πŸ•— ${text}`, `πŸ•™ ${text}`]; - log(textParam); - - const startTime = Date.now(); - let i = 0; - const timer = setInterval(() => { - process.stdout.write(`\r${getTexts()[i++]}`); - // eslint-disable-next-line no-bitwise - i &= 3; - }, Number(LOGGER_PROGRESS_REFRESH_RATE)); - - const getTimeText = () => { - const timeInSeconds = Math.round((Date.now() - startTime) / 1000).toFixed(0); - return `(${COLOR_DIM}took: ${timeInSeconds}s${COLOR_RESET})`; - }; - return { - updateText: (newText) => { - text = newText; - log(newText); - }, - done: () => { - clearInterval(timer); - success(`\rβœ… ${text} ${getTimeText()}\n`); - }, - error: () => { - clearInterval(timer); - error(`\r❌ ${text} ${getTimeText()}\n`); - }, - }; +module.exports = { + log, + info, + warn, + note, + error, + success, + writeToLogFile, }; - -export {log, info, warn, note, error, success, important, progressInfo, setLogLevelVerbose}; diff --git a/tests/e2e/utils/withFailTimeout.js b/tests/e2e/utils/withFailTimeout.js index 3a314cd23562..d7ac50a64e00 100644 --- a/tests/e2e/utils/withFailTimeout.js +++ b/tests/e2e/utils/withFailTimeout.js @@ -5,7 +5,7 @@ const TIMEOUT = process.env.INTERACTION_TIMEOUT || CONFIG.INTERACTION_TIMEOUT; const withFailTimeout = (promise, name) => new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { - reject(new Error(`[${name}] Interaction timed out after ${(TIMEOUT / 1000).toFixed(0)}s`)); + reject(new Error(`"${name}": Interaction timed out after ${(TIMEOUT / 1000).toFixed(0)}s`)); }, Number(TIMEOUT)); promise diff --git a/tests/perf-test/ReportActionsList.perf-test.js b/tests/perf-test/ReportActionsList.perf-test.js index 34b127c217e4..c760b81b2373 100644 --- a/tests/perf-test/ReportActionsList.perf-test.js +++ b/tests/perf-test/ReportActionsList.perf-test.js @@ -43,6 +43,8 @@ jest.mock('@react-navigation/native', () => { }; }); +jest.mock('../../src/components/ConfirmedRoute.tsx'); + beforeAll(() => Onyx.init({ keys: ONYXKEYS, diff --git a/tests/perf-test/ReportScreen.perf-test.js b/tests/perf-test/ReportScreen.perf-test.js index e86d0bf4fa09..bc127ff8a1f1 100644 --- a/tests/perf-test/ReportScreen.perf-test.js +++ b/tests/perf-test/ReportScreen.perf-test.js @@ -31,6 +31,8 @@ jest.mock('react-native-reanimated', () => ({ useAnimatedRef: jest.fn, })); +jest.mock('../../src/components/ConfirmedRoute.tsx'); + jest.mock('../../src/components/withNavigationFocus', () => (Component) => { function WithNavigationFocus(props) { return ( diff --git a/tests/perf-test/ReportUtils.perf-test.ts b/tests/perf-test/ReportUtils.perf-test.ts index ee3c54608436..ae3429bb9c01 100644 --- a/tests/perf-test/ReportUtils.perf-test.ts +++ b/tests/perf-test/ReportUtils.perf-test.ts @@ -184,4 +184,23 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); await measureFunction(() => ReportUtils.getTransactionDetails(transaction, 'yyyy-MM-dd')); }); + + test('[ReportUtils] getIOUReportActionDisplayMessage on 1k policies', async () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + IOUReportID: '1', + IOUTransactionID: '1', + amount: 100, + participantAccountID: 1, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: CONST.IOU.PAYMENT_TYPE.EXPENSIFY, + }, + }; + + await waitForBatchedUpdates(); + await measureFunction(() => ReportUtils.getIOUReportActionDisplayMessage(reportAction)); + }); }); diff --git a/tests/perf-test/SelectionList.perf-test.js b/tests/perf-test/SelectionList.perf-test.js index 9decc4361612..a109f92a1501 100644 --- a/tests/perf-test/SelectionList.perf-test.js +++ b/tests/perf-test/SelectionList.perf-test.js @@ -2,6 +2,7 @@ import {fireEvent} from '@testing-library/react-native'; import React, {useState} from 'react'; import {measurePerformance} from 'reassure'; import _ from 'underscore'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import SelectionList from '../../src/components/SelectionList'; import variables from '../../src/styles/variables'; @@ -86,6 +87,7 @@ function SelectionListWrapper(args) { sections={sections} onSelectRow={onSelectRow} initiallyFocusedOptionKey="item-0" + ListItem={RadioListItem} // eslint-disable-next-line react/jsx-props-no-spreading {...args} /> diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js index 6a57218fab23..07100d7a5f0f 100644 --- a/tests/ui/UnreadIndicatorsTest.js +++ b/tests/ui/UnreadIndicatorsTest.js @@ -30,6 +30,7 @@ jest.setTimeout(30000); jest.mock('../../src/libs/Notification/LocalNotification'); jest.mock('../../src/components/Icon/Expensicons'); +jest.mock('../../src/components/ConfirmedRoute.tsx'); // Needed for: https://stackoverflow.com/questions/76903168/mocking-libraries-in-jest jest.mock('react-native/Libraries/LogBox/LogBox', () => ({ diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js index f5012162c59e..65ab921ac9e1 100644 --- a/tests/unit/MigrationTest.js +++ b/tests/unit/MigrationTest.js @@ -1,9 +1,7 @@ import Onyx from 'react-native-onyx'; -import _ from 'underscore'; import Log from '../../src/libs/Log'; import CheckForPreviousReportActionID from '../../src/libs/migrations/CheckForPreviousReportActionID'; import KeyReportActionsDraftByReportActionID from '../../src/libs/migrations/KeyReportActionsDraftByReportActionID'; -import PersonalDetailsByAccountID from '../../src/libs/migrations/PersonalDetailsByAccountID'; import ONYXKEYS from '../../src/ONYXKEYS'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -25,460 +23,6 @@ describe('Migrations', () => { return waitForBatchedUpdates(); }); - describe('PersonalDetailsByAccountID', () => { - const DEPRECATED_ONYX_KEYS = { - // Deprecated personal details object which was keyed by login instead of accountID. - PERSONAL_DETAILS: 'personalDetails', - }; - - it('Should skip any zombie reportAction collections that have no reportAction data in Onyx', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: null, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: null, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - _.each(allReportActions, (reportActionsForReport) => expect(reportActionsForReport).toBeUndefined()); - }, - }); - })); - - it('Should remove any individual reportActions that have no data in Onyx', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: {}, - 2: {}, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 1 because the reportAction was empty'); - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 2 because the reportAction was empty'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - _.each(allReportActions, (reportActionsForReport) => expect(reportActionsForReport).toMatchObject({})); - }, - }); - })); - - it('Should remove any individual reportActions that have originalMessage.oldLogin but not originalMessage.oldAccountID', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: { - originalMessage: { - oldLogin: 'test1@account.com', - oldAccountID: 100, - }, - }, - 2: { - originalMessage: { - oldLogin: 'test1@account.com', - }, - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 2 because originalMessage.oldAccountID not found'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toHaveProperty('1'); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).not.toHaveProperty('2'); - }, - }); - })); - - it('Should remove any individual reportActions that have originalMessage.newLogin but not originalMessage.newAccountID', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: { - originalMessage: { - newLogin: 'test2@account.com', - newAccountID: 101, - }, - }, - 2: { - originalMessage: { - newLogin: 'test2@account.com', - }, - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 2 because originalMessage.newAccountID not found'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toHaveProperty('1'); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).not.toHaveProperty('2'); - }, - }); - })); - - it('Should remove any individual reportActions that have accountEmail but not accountID', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: { - accountEmail: 'test2@account.com', - accountID: 101, - }, - 2: { - accountEmail: 'test2@account.com', - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 2 because accountID not found'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toHaveProperty('1'); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).not.toHaveProperty('2'); - }, - }); - })); - - it('Should remove any individual reportActions that have actorEmail but not actorAccountID', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: { - actorEmail: 'test2@account.com', - actorAccountID: 101, - }, - 2: { - actorEmail: 'test2@account.com', - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 2 because actorAccountID not found'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toHaveProperty('1'); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).not.toHaveProperty('2'); - }, - }); - })); - - it('Should remove any individual reportActions that have childManagerEmail but not childManagerAccountID', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: { - childManagerEmail: 'test2@account.com', - childManagerAccountID: 101, - }, - 2: { - childManagerEmail: 'test2@account.com', - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 2 because childManagerAccountID not found'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toHaveProperty('1'); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).not.toHaveProperty('2'); - }, - }); - })); - - it('Should remove any individual reportActions that have whisperedTo but not whisperedToAccountIDs', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: { - whisperedTo: ['test1@account.com', 'test2@account.com'], - whisperedToAccountIDs: [100, 101], - }, - 2: { - whisperedTo: ['test1@account.com', 'test2@account.com'], - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 2 because whisperedToAccountIDs not found'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toHaveProperty('1'); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).not.toHaveProperty('2'); - }, - }); - })); - - it('Should remove any individual reportActions that have childOldestFourEmails but not childOldestFourAccountIDs', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: { - childOldestFourEmails: 'test1@account.com, test2@account.com', - childOldestFourAccountIDs: '100,101', - }, - 2: { - childOldestFourEmails: 'test1@account.com, test2@account.com', - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 2 because childOldestFourAccountIDs not found'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toHaveProperty('1'); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).not.toHaveProperty('2'); - }, - }); - })); - - it('Should remove any individual reportActions that have originalMessage.participants but not originalMessage.participantAccountIDs', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: { - originalMessage: { - participants: ['test1@account.com', 'test2@account.com'], - participantAccountIDs: [100, 101], - }, - }, - 2: { - originalMessage: { - participants: ['test1@account.com', 'test2@account.com'], - }, - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith( - '[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction 2 because originalMessage.participantAccountIDs not found', - ); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).toHaveProperty('1'); - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]).not.toHaveProperty('2'); - }, - }); - })); - - it('Should succeed in removing all email data when equivalent accountID data exists', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: { - originalMessage: { - oldLogin: 'test1@account.com', - oldAccountID: 100, - newLogin: 'test2@account.com', - newAccountID: 101, - participants: ['test1@account.com', 'test2@account.com'], - participantAccountIDs: [100, 101], - }, - actorEmail: 'test2@account.com', - actorAccountID: 101, - accountEmail: 'test2@account.com', - accountID: 101, - childManagerEmail: 'test2@account.com', - childManagerAccountID: 101, - whisperedTo: ['test1@account.com', 'test2@account.com'], - whisperedToAccountIDs: [100, 101], - childOldestFourEmails: 'test1@account.com, test2@account.com', - childOldestFourAccountIDs: '100,101', - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - const expectedReportAction = { - originalMessage: { - oldAccountID: 100, - newAccountID: 101, - participantAccountIDs: [100, 101], - }, - actorAccountID: 101, - accountID: 101, - childManagerAccountID: 101, - whisperedToAccountIDs: [100, 101], - childOldestFourAccountIDs: '100,101', - }; - expect(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`][1]).toMatchObject(expectedReportAction); - }, - }); - })); - - it('Should succeed in removing any policyMemberList objects it finds in Onyx', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST}1`]: { - 'admin@company1.com': { - role: 'admin', - }, - 'employee@company1.com': { - role: 'user', - }, - }, - [`${ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST}2`]: { - 'admin@company2.com': { - role: 'admin', - }, - 'employee@company2.com': { - role: 'user', - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith( - `[Migrate Onyx] PersonalDetailsByAccountID migration: removing policyMemberList ${ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST}1`, - ); - expect(LogSpy).toHaveBeenCalledWith( - `[Migrate Onyx] PersonalDetailsByAccountID migration: removing policyMemberList ${ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST}2`, - ); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST, - waitForCollectionCallback: true, - callback: (allPolicyMemberLists) => { - Onyx.disconnect(connectionID); - - expect(allPolicyMemberLists).toBeFalsy(); - }, - }); - })); - - it('Should succeed in removing the personalDetails object if found in Onyx', () => - Onyx.multiSet({ - [`${DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS}`]: { - 'test1@account.com': { - accountID: 100, - login: 'test1@account.com', - }, - 'test2@account.com': { - accountID: 101, - login: 'test2@account.com', - }, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing personalDetails'); - const connectionID = Onyx.connect({ - key: DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS, - callback: (allPersonalDetails) => { - Onyx.disconnect(connectionID); - expect(allPersonalDetails).toBeNull(); - }, - }); - })); - - it('Should remove any instances of lastActorEmail found in a report', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT}1`]: { - reportID: 1, - lastActorEmail: 'fake@test.com', - lastActorAccountID: 5, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing lastActorEmail from report 1'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connectionID); - const expectedReport = { - reportID: 1, - lastActorAccountID: 5, - }; - expect(allReports[`${ONYXKEYS.COLLECTION.REPORT}1`]).toMatchObject(expectedReport); - }, - }); - })); - - it('Should remove any instances of ownerEmail found in a report', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT}1`]: { - reportID: 1, - ownerEmail: 'fake@test.com', - ownerAccountID: 5, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing ownerEmail from report 1'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connectionID); - const expectedReport = { - reportID: 1, - ownerAccountID: 5, - }; - expect(allReports[`${ONYXKEYS.COLLECTION.REPORT}1`]).toMatchObject(expectedReport); - }, - }); - })); - - it('Should remove any instances of managerEmail found in a report', () => - Onyx.multiSet({ - [`${ONYXKEYS.COLLECTION.REPORT}1`]: { - reportID: 1, - managerEmail: 'fake@test.com', - managerID: 5, - }, - }) - .then(PersonalDetailsByAccountID) - .then(() => { - expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing managerEmail from report 1'); - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connectionID); - const expectedReport = { - reportID: 1, - managerID: 5, - }; - expect(allReports[`${ONYXKEYS.COLLECTION.REPORT}1`]).toMatchObject(expectedReport); - }, - }); - })); - }); - describe('CheckForPreviousReportActionID', () => { it("Should work even if there's no reportAction data in Onyx", () => CheckForPreviousReportActionID().then(() => diff --git a/tests/unit/TrieTest.js b/tests/unit/TrieTest.ts similarity index 91% rename from tests/unit/TrieTest.js rename to tests/unit/TrieTest.ts index ad9958990ae4..d08fa8d7d520 100644 --- a/tests/unit/TrieTest.js +++ b/tests/unit/TrieTest.ts @@ -1,4 +1,4 @@ -import Trie from '../../src/libs/Trie'; +import Trie from '@src/libs/Trie'; describe('Trie', () => { it('Test if a node can be found in the Trie', () => { @@ -8,8 +8,8 @@ describe('Trie', () => { wordTrie.add('joy', {code: 'πŸ˜‚'}); wordTrie.add('rofl', {code: '🀣'}); expect(wordTrie.search('eyes')).toBeNull(); - expect(wordTrie.search('joy').metaData).toEqual({code: 'πŸ˜‚'}); - expect(wordTrie.search('gRiN').metaData).toEqual({code: '😁'}); + expect(wordTrie.search('joy')?.metaData).toEqual({code: 'πŸ˜‚'}); + expect(wordTrie.search('gRiN')?.metaData).toEqual({code: '😁'}); }); it('Test finding all leaf nodes starting with a substring', () => { @@ -65,13 +65,13 @@ describe('Trie', () => { const wordTrie = new Trie(); wordTrie.add('John', {code: 'πŸ‘¨πŸΌ'}); wordTrie.update('John', {code: 'πŸ‘¨πŸ»'}); - expect(wordTrie.search('John').metaData).toEqual({code: 'πŸ‘¨πŸ»'}); + expect(wordTrie.search('John')?.metaData).toEqual({code: 'πŸ‘¨πŸ»'}); }); it('Test throwing an error when try to update a word that does not exist in the Trie.', () => { const wordTrie = new Trie(); expect(() => { - wordTrie.update('smile'); + wordTrie.update('smile', {}); }).toThrow('Word does not exist in the Trie'); }); }); diff --git a/tests/unit/getStyledArratTest.js b/tests/unit/getStyledArrayTest.ts similarity index 100% rename from tests/unit/getStyledArratTest.js rename to tests/unit/getStyledArrayTest.ts