diff --git a/android/app/build.gradle b/android/app/build.gradle
index 9563e410dcb6..0cfee206602a 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 1001044407
- versionName "1.4.44-7"
+ versionCode 1001044500
+ versionName "1.4.45-0"
}
flavorDimensions "default"
diff --git a/assets/images/workspace-profile-light.png b/assets/images/workspace-profile-light.png
new file mode 100644
index 000000000000..7e82c98656d2
Binary files /dev/null and b/assets/images/workspace-profile-light.png differ
diff --git a/assets/images/workspace-profile.png b/assets/images/workspace-profile.png
index 72112566e35f..df1f6f9fd645 100644
Binary files a/assets/images/workspace-profile.png and b/assets/images/workspace-profile.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index a8f8937da93e..9d5fb4c58a75 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.4.44
+ 1.4.45
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.44.7
+ 1.4.45.0
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 95afa8d35c0a..40a633aa93d3 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.4.44
+ 1.4.45
CFBundleSignature
????
CFBundleVersion
- 1.4.44.7
+ 1.4.45.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 31de98f3dcc2..69058d2349dc 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 1.4.44
+ 1.4.45
CFBundleVersion
- 1.4.44.7
+ 1.4.45.0
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index dc8eb94eeb3f..12c0c99c0d9a 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1432,7 +1432,7 @@ PODS:
- React-Core
- RNReactNativeHapticFeedback (2.2.0):
- React-Core
- - RNReanimated (3.6.1):
+ - RNReanimated (3.7.1):
- glog
- RCT-Folly (= 2022.05.16.00)
- React-Core
@@ -1986,7 +1986,7 @@ SPEC CHECKSUMS:
rnmapbox-maps: fcf7f1cbdc8bd7569c267d07284e8a5c7bee06ed
RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa
RNReactNativeHapticFeedback: ec56a5f81c3941206fd85625fa669ffc7b4545f9
- RNReanimated: 57f436e7aa3d277fbfed05e003230b43428157c0
+ RNReanimated: beb07f7f900543928467da8107c175d1e57a1049
RNScreens: b582cb834dc4133307562e930e8fa914b8c04ef2
RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852
RNSVG: ba3e7232f45e34b7b47e74472386cf4e1a676d0a
diff --git a/package-lock.json b/package-lock.json
index dd072352d464..4823fb44b7ff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.44-7",
+ "version": "1.4.45-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.44-7",
+ "version": "1.4.45-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -106,7 +106,7 @@
"react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
- "react-native-reanimated": "^3.6.1",
+ "react-native-reanimated": "^3.7.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
@@ -44630,9 +44630,9 @@
}
},
"node_modules/react-native-reanimated": {
- "version": "3.6.1",
- "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.6.1.tgz",
- "integrity": "sha512-F4vG9Yf9PKmE3GaWtVGUpzj3SM6YY2cx1yRHCwiMd1uY7W0gU017LfcVUorboJnj0y5QZqEriEK1Usq2Y8YZqg==",
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.7.1.tgz",
+ "integrity": "sha512-bapCxhnS58+GZynQmA/f5U8vRlmhXlI/WhYg0dqnNAGXHNIc+38ahRWcG8iK8e0R2v9M8Ky2ZWObEC6bmweofg==",
"dependencies": {
"@babel/plugin-transform-object-assign": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
diff --git a/package.json b/package.json
index 335c28a21586..d75ecb9ad3e1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.44-7",
+ "version": "1.4.45-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -154,7 +154,7 @@
"react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
- "react-native-reanimated": "^3.6.1",
+ "react-native-reanimated": "^3.7.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
diff --git a/patches/react-native-reanimated+3.6.1+001+fix-boost-dependency.patch b/patches/react-native-reanimated+3.7.1+001+fix-boost-dependency.patch
similarity index 100%
rename from patches/react-native-reanimated+3.6.1+001+fix-boost-dependency.patch
rename to patches/react-native-reanimated+3.7.1+001+fix-boost-dependency.patch
diff --git a/patches/react-native-reanimated+3.6.1.patch b/patches/react-native-reanimated+3.7.1.patch
similarity index 100%
rename from patches/react-native-reanimated+3.6.1.patch
rename to patches/react-native-reanimated+3.7.1.patch
diff --git a/src/CONST.ts b/src/CONST.ts
index da2c99476d11..ffffb3c7f339 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -178,6 +178,7 @@ const CONST = {
DATE: {
SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss',
FNS_FORMAT_STRING: 'yyyy-MM-dd',
+ FNS_DATE_TIME_FORMAT_STRING: 'yyyy-MM-dd HH:mm:ss',
LOCAL_TIME_FORMAT: 'h:mm a',
YEAR_MONTH_FORMAT: 'yyyyMM',
MONTH_FORMAT: 'MMMM',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index f2d606bd62a6..d4a0b8a21d66 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -272,6 +272,9 @@ const ONYXKEYS = {
/** Indicates whether we should store logs or not */
SHOULD_STORE_LOGS: 'shouldStoreLogs',
+ // Paths of PDF file that has been cached during one session
+ CACHED_PDF_PATHS: 'cachedPDFPaths',
+
/** Collection Keys */
COLLECTION: {
DOWNLOAD: 'download_',
@@ -564,6 +567,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.PLAID_CURRENT_EVENT]: string;
[ONYXKEYS.LOGS]: Record;
[ONYXKEYS.SHOULD_STORE_LOGS]: boolean;
+ [ONYXKEYS.CACHED_PDF_PATHS]: Record;
};
type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping;
@@ -582,4 +586,4 @@ type MissingOnyxKeysError = `Error: Types don't match, OnyxKey type is missing:
type AssertOnyxKeys = AssertTypesEqual;
export default ONYXKEYS;
-export type {OnyxValues, OnyxKey, OnyxCollectionKey, OnyxValue, OnyxValueKey, OnyxFormKey, OnyxFormValuesMapping, OnyxFormDraftKey};
+export type {OnyxValues, OnyxKey, OnyxCollectionKey, OnyxValue, OnyxValueKey, OnyxFormKey, OnyxFormValuesMapping, OnyxFormDraftKey, OnyxCollectionValuesMapping};
diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx
index ab39e5379230..7f0178863fc9 100755
--- a/src/components/AttachmentModal.tsx
+++ b/src/components/AttachmentModal.tsx
@@ -89,7 +89,7 @@ type AttachmentModalProps = AttachmentModalOnyxProps & {
source?: AvatarSource;
/** Optional callback to fire when we want to preview an image and approve it for use. */
- onConfirm?: ((file: Partial) => void) | null;
+ onConfirm?: ((file: FileObject) => void) | null;
/** Whether the modal should be open by default */
defaultOpen?: boolean;
@@ -264,7 +264,7 @@ function AttachmentModal({
}
if (onConfirm) {
- onConfirm(Object.assign(file ?? {}, {source: sourceState}));
+ onConfirm(Object.assign(file ?? {}, {source: sourceState} as FileObject));
}
setIsModalOpen(false);
@@ -318,7 +318,7 @@ function AttachmentModal({
const validateAndDisplayFileToUpload = useCallback(
(data: FileObject) => {
- if (!isDirectoryCheck(data)) {
+ if (!data || !isDirectoryCheck(data)) {
return;
}
let fileObject = data;
@@ -617,4 +617,4 @@ export default withOnyx({
},
})(memo(AttachmentModal));
-export type {Attachment};
+export type {Attachment, FileObject};
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js
index edc8ab11fd27..b2c9fed64467 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js
@@ -105,6 +105,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}) {
isAuthTokenRequired={item.isAuthTokenRequired}
onPress={onPress}
transactionID={item.transactionID}
+ reportActionID={item.reportActionID}
isHovered={isModalHovered}
isFocused={isFocused}
optionalVideoDuration={item.duration}
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index c871628f65e7..56425f64a51c 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -17,6 +17,7 @@ import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as CachedPDFPaths from '@libs/actions/CachedPDFPaths';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
import compose from '@libs/compose';
import * as TransactionUtils from '@libs/TransactionUtils';
@@ -57,6 +58,9 @@ const propTypes = {
// eslint-disable-next-line react/no-unused-prop-types
transactionID: PropTypes.string,
+ /** The id of the report action related to the attachment */
+ reportActionID: PropTypes.string,
+
isHovered: PropTypes.bool,
optionalVideoDuration: PropTypes.number,
@@ -71,6 +75,7 @@ const defaultProps = {
isWorkspaceAvatar: false,
maybeIcon: false,
transactionID: '',
+ reportActionID: '',
isHovered: false,
optionalVideoDuration: 0,
};
@@ -92,6 +97,7 @@ function AttachmentView({
maybeIcon,
fallbackSource,
transaction,
+ reportActionID,
isHovered,
optionalVideoDuration,
}) {
@@ -153,6 +159,15 @@ function AttachmentView({
if ((_.isString(source) && Str.isPDF(source)) || (file && Str.isPDF(file.name || translate('attachmentView.unknownFilename')))) {
const encryptedSourceUrl = isAuthTokenRequired ? addEncryptedAuthTokenToURL(source) : source;
+ const onPDFLoadComplete = (path) => {
+ if (path && (transaction.transactionID || reportActionID)) {
+ CachedPDFPaths.add(transaction.transactionID || reportActionID, path);
+ }
+ if (!loadComplete) {
+ setLoadComplete(true);
+ }
+ };
+
// We need the following View component on android native
// So that the event will propagate properly and
// the Password protected preview will be shown for pdf attachement we are about to send.
@@ -166,7 +181,7 @@ function AttachmentView({
encryptedSourceUrl={encryptedSourceUrl}
onPress={onPress}
onToggleKeyboard={onToggleKeyboard}
- onLoadComplete={() => !loadComplete && setLoadComplete(true)}
+ onLoadComplete={onPDFLoadComplete}
errorLabelStyles={isUsedInAttachmentModal ? [styles.textLabel, styles.textLarge] : [styles.cursorAuto]}
style={isUsedInAttachmentModal ? styles.imageModalPDF : styles.flex1}
isUsedInCarousel={isUsedInCarousel}
diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx
index fa8a6d71516f..4388ebb8f815 100644
--- a/src/components/AvatarWithImagePicker.tsx
+++ b/src/components/AvatarWithImagePicker.tsx
@@ -220,7 +220,7 @@ function AvatarWithImagePicker({
setError(null, {});
setIsMenuVisible(false);
setImageData({
- uri: image.uri,
+ uri: image.uri ?? '',
name: image.name,
type: image.type,
});
diff --git a/src/components/ButtonWithDropdownMenu.tsx b/src/components/ButtonWithDropdownMenu.tsx
index 9466da601825..8aa3a5f0b9f0 100644
--- a/src/components/ButtonWithDropdownMenu.tsx
+++ b/src/components/ButtonWithDropdownMenu.tsx
@@ -10,33 +10,30 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import type {AnchorPosition} from '@styles/index';
import CONST from '@src/CONST';
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
-import type DeepValueOf from '@src/types/utils/DeepValueOf';
import type IconAsset from '@src/types/utils/IconAsset';
import Button from './Button';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import PopoverMenu from './PopoverMenu';
-type PaymentType = DeepValueOf;
-
-type DropdownOption = {
- value: PaymentType;
+type DropdownOption = {
+ value: T;
text: string;
- icon: IconAsset;
+ icon?: IconAsset;
iconWidth?: number;
iconHeight?: number;
iconDescription?: string;
};
-type ButtonWithDropdownMenuProps = {
+type ButtonWithDropdownMenuProps = {
/** Text to display for the menu header */
menuHeaderText?: string;
/** Callback to execute when the main button is pressed */
- onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: PaymentType) => void;
+ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: T) => void;
/** Callback to execute when a dropdown option is selected */
- onOptionSelected?: (option: DropdownOption) => void;
+ onOptionSelected?: (option: DropdownOption) => void;
/** Call the onPress function on main button when Enter key is pressed */
pressOnEnter?: boolean;
@@ -55,19 +52,19 @@ type ButtonWithDropdownMenuProps = {
/** Menu options to display */
/** e.g. [{text: 'Pay with Expensify', icon: Wallet}] */
- options: DropdownOption[];
+ options: Array>;
/** The anchor alignment of the popover menu */
anchorAlignment?: AnchorAlignment;
/* ref for the button */
- buttonRef: RefObject;
+ buttonRef?: RefObject;
/** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */
enterKeyEventListenerPriority?: number;
};
-function ButtonWithDropdownMenu({
+function ButtonWithDropdownMenu({
isLoading = false,
isDisabled = false,
pressOnEnter = false,
@@ -83,7 +80,7 @@ function ButtonWithDropdownMenu({
options,
onOptionSelected,
enterKeyEventListenerPriority = 0,
-}: ButtonWithDropdownMenuProps) {
+}: ButtonWithDropdownMenuProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js
index 2374fc9e5d0c..89312a7ca614 100644
--- a/src/components/CategoryPicker/index.js
+++ b/src/components/CategoryPicker/index.js
@@ -70,6 +70,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC
onSelectRow={onSubmit}
ListItem={RadioListItem}
initiallyFocusedOptionKey={selectedOptionKey}
+ isRowMultilineSupported
/>
);
}
diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx
index 516de55c73ba..b6443f3ca385 100755
--- a/src/components/Composer/index.tsx
+++ b/src/components/Composer/index.tsx
@@ -75,7 +75,7 @@ function Composer(
shouldContainScroll = false,
...props
}: ComposerProps,
- ref: ForwardedRef,
+ ref: ForwardedRef,
) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -278,6 +278,7 @@ function Composer(
if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) {
return;
}
+
onKeyPress(e);
},
[onKeyPress],
diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts
index d8d88970ea78..6bc44aba69cd 100644
--- a/src/components/Composer/types.ts
+++ b/src/components/Composer/types.ts
@@ -1,11 +1,11 @@
-import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native';
+import type {NativeSyntheticEvent, StyleProp, TextInputProps, TextInputSelectionChangeEventData, TextStyle} from 'react-native';
type TextSelection = {
start: number;
end?: number;
};
-type ComposerProps = {
+type ComposerProps = TextInputProps & {
/** identify id in the text input */
id?: string;
@@ -31,7 +31,7 @@ type ComposerProps = {
onNumberOfLinesChange?: (numberOfLines: number) => void;
/** Callback method to handle pasting a file */
- onPasteFile?: (file?: File) => void;
+ onPasteFile?: (file: File) => void;
/** General styles to apply to the text input */
// eslint-disable-next-line react/forbid-prop-types
@@ -74,12 +74,6 @@ type ComposerProps = {
/** Whether the sull composer is open */
isComposerFullSize?: boolean;
- onKeyPress?: (event: NativeSyntheticEvent) => void;
-
- onFocus?: (event: NativeSyntheticEvent) => void;
-
- onBlur?: (event: NativeSyntheticEvent) => void;
-
/** Should make the input only scroll inside the element avoid scroll out to parent */
shouldContainScroll?: boolean;
};
diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx
index 7f05b45bca30..da8c0ed86a84 100644
--- a/src/components/ConfirmedRoute.tsx
+++ b/src/components/ConfirmedRoute.tsx
@@ -25,13 +25,13 @@ type ConfirmedRoutePropsOnyxProps = {
type ConfirmedRouteProps = ConfirmedRoutePropsOnyxProps & {
/** Transaction that stores the distance request data */
- transaction: Transaction;
+ transaction: Transaction | undefined;
};
function ConfirmedRoute({mapboxAccessToken, transaction}: ConfirmedRouteProps) {
const {isOffline} = useNetwork();
- const {route0: route} = transaction.routes ?? {};
- const waypoints = transaction.comment?.waypoints ?? {};
+ const {route0: route} = transaction?.routes ?? {};
+ const waypoints = transaction?.comment?.waypoints ?? {};
const coordinates = route?.geometry?.coordinates ?? [];
const theme = useTheme();
const styles = useThemeStyles();
diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx
index 4535acc734af..356fbd3726a3 100644
--- a/src/components/CustomStatusBarAndBackground/index.tsx
+++ b/src/components/CustomStatusBarAndBackground/index.tsx
@@ -1,8 +1,10 @@
import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
import {interpolateColor, runOnJS, useAnimatedReaction, useSharedValue, withDelay, withTiming} from 'react-native-reanimated';
+import usePrevious from '@hooks/usePrevious';
import useTheme from '@hooks/useTheme';
import {navigationRef} from '@libs/Navigation/Navigation';
import StatusBar from '@libs/StatusBar';
+import type {StatusBarStyle} from '@styles/index';
import CustomStatusBarAndBackgroundContext from './CustomStatusBarAndBackgroundContext';
import updateGlobalBackgroundColor from './updateGlobalBackgroundColor';
import updateStatusBarAppearance from './updateStatusBarAppearance';
@@ -16,7 +18,7 @@ type CustomStatusBarAndBackgroundProps = {
function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBackgroundProps) {
const {isRootStatusBarEnabled, setRootStatusBarEnabled} = useContext(CustomStatusBarAndBackgroundContext);
const theme = useTheme();
- const [statusBarStyle, setStatusBarStyle] = useState(theme.statusBarStyle);
+ const [statusBarStyle, setStatusBarStyle] = useState();
const isDisabled = !isNested && !isRootStatusBarEnabled;
@@ -34,6 +36,8 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack
};
}, [isNested, setRootStatusBarEnabled]);
+ const didForceUpdateStatusBarRef = useRef(false);
+ const prevIsRootStatusBarEnabled = usePrevious(isRootStatusBarEnabled);
// The prev and current status bar background color refs are initialized with the splash screen background color so the status bar color is changed from the splash screen color to the expected color atleast once on first render - https://github.com/Expensify/App/issues/34154
const prevStatusBarBackgroundColor = useRef(theme.splashBG);
const statusBarBackgroundColor = useRef(theme.splashBG);
@@ -57,11 +61,11 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack
// Updates the status bar style and background color depending on the current route and theme
// This callback is triggered everytime the route changes or the theme changes
const updateStatusBarStyle = useCallback(
- (listenerId?: number) => {
+ (listenerID?: number) => {
// Check if this function is either called through the current navigation listener
// react-navigation library has a bug internally, where it can't keep track of the listeners, therefore, sometimes when the useEffect would re-render and we run navigationRef.removeListener the listener isn't removed and we end up with two or more listeners.
// https://github.com/Expensify/App/issues/34154#issuecomment-1898519399
- if (listenerId !== undefined && listenerId !== listenerCount.current) {
+ if (listenerID !== undefined && listenerID !== listenerCount.current) {
return;
}
@@ -97,27 +101,39 @@ function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBack
}
// Don't update the status bar style if it's the same as the current one, to prevent flashing.
- if (newStatusBarStyle !== statusBarStyle) {
+ // Force update if the root status bar is back on active or it won't overwirte the nested status bar style
+ if ((!didForceUpdateStatusBarRef.current && !prevIsRootStatusBarEnabled && isRootStatusBarEnabled) || newStatusBarStyle !== statusBarStyle) {
updateStatusBarAppearance({statusBarStyle: newStatusBarStyle});
setStatusBarStyle(newStatusBarStyle);
+
+ if (!prevIsRootStatusBarEnabled && isRootStatusBarEnabled) {
+ didForceUpdateStatusBarRef.current = true;
+ }
}
},
- [statusBarAnimation, statusBarStyle, theme.PAGE_THEMES, theme.appBG, theme.statusBarStyle],
+ [prevIsRootStatusBarEnabled, isRootStatusBarEnabled, statusBarAnimation, statusBarStyle, theme.PAGE_THEMES, theme.appBG, theme.statusBarStyle],
);
- // Add navigation state listeners to update the status bar every time the route changes
- // We have to pass a count as the listener id, because "react-navigation" somehow doesn't remove listeners properly
+ useEffect(() => {
+ didForceUpdateStatusBarRef.current = false;
+ }, [isRootStatusBarEnabled]);
+
useEffect(() => {
if (isDisabled) {
return;
}
- const listenerId = ++listenerCount.current;
- const listener = () => updateStatusBarStyle(listenerId);
+ // Update status bar when theme changes
+ updateStatusBarStyle();
+
+ // Add navigation state listeners to update the status bar every time the route changes
+ // We have to pass a count as the listener id, because "react-navigation" somehow doesn't remove listeners properly
+ const listenerID = ++listenerCount.current;
+ const listener = () => updateStatusBarStyle(listenerID);
navigationRef.addListener('state', listener);
return () => navigationRef.removeListener('state', listener);
- }, [isDisabled, theme.appBG, updateStatusBarStyle]);
+ }, [isDisabled, updateStatusBarStyle]);
// Update the global background and status bar style (on web) everytime the theme changes.
// The background of the html element needs to be updated, otherwise you will see a big contrast when resizing the window or when the keyboard is open on iOS web.
diff --git a/src/components/DistanceEReceipt.tsx b/src/components/DistanceEReceipt.tsx
index 9846cfa04257..941d63c1bf94 100644
--- a/src/components/DistanceEReceipt.tsx
+++ b/src/components/DistanceEReceipt.tsx
@@ -75,8 +75,6 @@ function DistanceEReceipt({transaction}: DistanceEReceiptProps) {
let descriptionKey: TranslationPaths = 'distance.waypointDescription.stop';
if (index === 0) {
descriptionKey = 'distance.waypointDescription.start';
- } else if (index === Object.keys(waypoints).length - 1) {
- descriptionKey = 'distance.waypointDescription.finish';
}
return (
diff --git a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx
index a1f3efbf0291..57e4fb0b530e 100644
--- a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx
+++ b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx
@@ -42,7 +42,7 @@ function DistanceRequestRenderItem({waypoints, item = '', onSecondaryInteraction
descriptionKey += 'start';
waypointIcon = Expensicons.DotIndicatorUnfilled;
} else if (index === lastWaypointIndex) {
- descriptionKey += 'finish';
+ descriptionKey += 'stop';
waypointIcon = Expensicons.Location;
} else {
descriptionKey += 'stop';
diff --git a/src/components/Form/InputWrapper.tsx b/src/components/Form/InputWrapper.tsx
index 4d55de008516..b5535a2fe6c1 100644
--- a/src/components/Form/InputWrapper.tsx
+++ b/src/components/Form/InputWrapper.tsx
@@ -2,12 +2,16 @@ import type {ComponentPropsWithoutRef, ComponentType, ForwardedRef} from 'react'
import React, {forwardRef, useContext} from 'react';
import type {AnimatedTextInputRef} from '@components/RNTextInput';
import RoomNameInput from '@components/RoomNameInput';
+import type RoomNameInputProps from '@components/RoomNameInput/types';
import TextInput from '@components/TextInput';
+import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import FormContext from './FormContext';
import type {InputComponentBaseProps, InputComponentValueProps, ValidInputs, ValueTypeKey} from './types';
-const textInputBasedComponents: ComponentType[] = [TextInput, RoomNameInput];
+type TextInputBasedComponents = [ComponentType, ComponentType];
+
+const textInputBasedComponents: TextInputBasedComponents = [TextInput, RoomNameInput];
type ComputedComponentSpecificRegistrationParams = {
shouldSubmitForm: boolean;
diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts
index 37d0f730c9e9..ba147dfa81a2 100644
--- a/src/components/Form/types.ts
+++ b/src/components/Form/types.ts
@@ -8,6 +8,7 @@ import type CheckboxWithLabel from '@components/CheckboxWithLabel';
import type CountrySelector from '@components/CountrySelector';
import type Picker from '@components/Picker';
import type RadioButtons from '@components/RadioButtons';
+import type RoomNameInput from '@components/RoomNameInput';
import type SingleChoiceQuestion from '@components/SingleChoiceQuestion';
import type StatePicker from '@components/StatePicker';
import type TextInput from '@components/TextInput';
@@ -22,7 +23,7 @@ import type {BaseForm} from '@src/types/form/Form';
* when adding new inputs or removing old ones.
*
* TODO: Add remaining inputs here once these components are migrated to Typescript:
- * EmojiPickerButtonDropdown | RoomNameInput | ValuePicker
+ * EmojiPickerButtonDropdown
*/
type ValidInputs =
| typeof TextInput
@@ -35,6 +36,7 @@ type ValidInputs =
| typeof AmountForm
| typeof BusinessTypePicker
| typeof StatePicker
+ | typeof RoomNameInput
| typeof ValuePicker
| typeof RadioButtons;
diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx
index 7313bb4aa7bb..25b468181b87 100644
--- a/src/components/LocaleContextProvider.tsx
+++ b/src/components/LocaleContextProvider.tsx
@@ -132,4 +132,4 @@ Provider.displayName = 'withOnyx(LocaleContextProvider)';
export {Provider as LocaleContextProvider, LocaleContext};
-export type {LocaleContextProps};
+export type {LocaleContextProps, Locale};
diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx
index 459131ecc434..23040a242807 100644
--- a/src/components/MentionSuggestions.tsx
+++ b/src/components/MentionSuggestions.tsx
@@ -1,4 +1,5 @@
import React, {useCallback} from 'react';
+import type {MeasureInWindowOnSuccessCallback} from 'react-native';
import {View} from 'react-native';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
@@ -18,7 +19,7 @@ type Mention = {
alternateText: string;
/** Email/phone number of the user */
- login: string;
+ login?: string;
/** Array of icons of the user. We use the first element of this array */
icons: Icon[];
@@ -32,7 +33,7 @@ type MentionSuggestionsProps = {
mentions: Mention[];
/** Fired when the user selects a mention */
- onSelect: () => void;
+ onSelect: (highlightedMentionIndex: number) => void;
/** Mention prefix that follows the @ sign */
prefix: string;
@@ -43,7 +44,7 @@ type MentionSuggestionsProps = {
isMentionPickerLarge: boolean;
/** Measures the parent container's position and dimensions. */
- measureParentContainer: () => void;
+ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
};
/**
@@ -142,3 +143,5 @@ function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSe
MentionSuggestions.displayName = 'MentionSuggestions';
export default MentionSuggestions;
+
+export type {Mention};
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
deleted file mode 100755
index df2781d3ea89..000000000000
--- a/src/components/MoneyRequestConfirmationList.js
+++ /dev/null
@@ -1,898 +0,0 @@
-import {useIsFocused} from '@react-navigation/native';
-import {format} from 'date-fns';
-import {isEmpty} from 'lodash';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import * as CurrencyUtils from '@libs/CurrencyUtils';
-import DistanceRequestUtils from '@libs/DistanceRequestUtils';
-import * as IOUUtils from '@libs/IOUUtils';
-import Log from '@libs/Log';
-import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
-import Navigation from '@libs/Navigation/Navigation';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
-import * as PolicyUtils from '@libs/PolicyUtils';
-import * as ReceiptUtils from '@libs/ReceiptUtils';
-import * as ReportUtils from '@libs/ReportUtils';
-import * as TransactionUtils from '@libs/TransactionUtils';
-import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes';
-import {policyPropTypes} from '@pages/workspace/withPolicy';
-import * as IOU from '@userActions/IOU';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
-import categoryPropTypes from './categoryPropTypes';
-import ConfirmedRoute from './ConfirmedRoute';
-import FormHelpMessage from './FormHelpMessage';
-import Image from './Image';
-import MenuItemWithTopDescription from './MenuItemWithTopDescription';
-import optionPropTypes from './optionPropTypes';
-import OptionsSelector from './OptionsSelector';
-import ReceiptEmptyState from './ReceiptEmptyState';
-import SettlementButton from './SettlementButton';
-import ShowMoreButton from './ShowMoreButton';
-import Switch from './Switch';
-import tagPropTypes from './tagPropTypes';
-import Text from './Text';
-import transactionPropTypes from './transactionPropTypes';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails';
-
-const propTypes = {
- /** Callback to inform parent modal of success */
- onConfirm: PropTypes.func,
-
- /** Callback to parent modal to send money */
- onSendMoney: PropTypes.func,
-
- /** Callback to inform a participant is selected */
- onSelectParticipant: PropTypes.func,
-
- /** Should we request a single or multiple participant selection from user */
- hasMultipleParticipants: PropTypes.bool.isRequired,
-
- /** IOU amount */
- iouAmount: PropTypes.number.isRequired,
-
- /** IOU comment */
- iouComment: PropTypes.string,
-
- /** IOU currency */
- iouCurrencyCode: PropTypes.string,
-
- /** IOU type */
- iouType: PropTypes.string,
-
- /** IOU date */
- iouCreated: PropTypes.string,
-
- /** IOU merchant */
- iouMerchant: PropTypes.string,
-
- /** IOU Category */
- iouCategory: PropTypes.string,
-
- /** IOU Tag */
- iouTag: PropTypes.string,
-
- /** IOU isBillable */
- iouIsBillable: PropTypes.bool,
-
- /** Callback to toggle the billable state */
- onToggleBillable: PropTypes.func,
-
- /** Selected participants from MoneyRequestModal with login / accountID */
- selectedParticipants: PropTypes.arrayOf(optionPropTypes).isRequired,
-
- /** Payee of the money request with login */
- payeePersonalDetails: optionPropTypes,
-
- /** Can the participants be modified or not */
- canModifyParticipants: PropTypes.bool,
-
- /** Should the list be read only, and not editable? */
- isReadOnly: PropTypes.bool,
-
- /** Depending on expense report or personal IOU report, respective bank account route */
- bankAccountRoute: PropTypes.string,
-
- ...withCurrentUserPersonalDetailsPropTypes,
-
- /** Current user session */
- session: PropTypes.shape({
- email: PropTypes.string.isRequired,
- }),
-
- /** The policyID of the request */
- policyID: PropTypes.string,
-
- /** The reportID of the request */
- reportID: PropTypes.string,
-
- /** File path of the receipt */
- receiptPath: PropTypes.string,
-
- /** File name of the receipt */
- receiptFilename: PropTypes.string,
-
- /** List styles for OptionsSelector */
- listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
-
- /** ID of the transaction that represents the money request */
- transactionID: PropTypes.string,
-
- /** Transaction that represents the money request */
- transaction: transactionPropTypes,
-
- /** Unit and rate used for if the money request is a distance request */
- mileageRate: PropTypes.shape({
- /** Unit used to represent distance */
- unit: PropTypes.oneOf([CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]),
-
- /** Rate used to calculate the distance request amount */
- rate: PropTypes.number,
-
- /** The currency of the rate */
- currency: PropTypes.string,
- }),
-
- /** Whether the money request is a distance request */
- isDistanceRequest: PropTypes.bool,
-
- /** Whether the money request is a scan request */
- isScanRequest: PropTypes.bool,
-
- /** Whether we're editing a split bill */
- isEditingSplitBill: PropTypes.bool,
-
- /** Whether we should show the amount, date, and merchant fields. */
- shouldShowSmartScanFields: PropTypes.bool,
-
- /** A flag for verifying that the current report is a sub-report of a workspace chat */
- isPolicyExpenseChat: PropTypes.bool,
-
- /* Onyx Props */
- /** Collection of categories attached to a policy */
- policyCategories: PropTypes.objectOf(categoryPropTypes),
-
- /** Collection of tags attached to a policy */
- policyTags: tagPropTypes,
-
- /* Onyx Props */
- /** The policy of the report */
- policy: policyPropTypes.policy,
-
- /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
- iou: iouPropTypes,
-};
-
-const defaultProps = {
- onConfirm: () => {},
- onSendMoney: () => {},
- onSelectParticipant: () => {},
- iouType: CONST.IOU.TYPE.REQUEST,
- iouCategory: '',
- iouTag: '',
- iouIsBillable: false,
- onToggleBillable: () => {},
- payeePersonalDetails: null,
- canModifyParticipants: false,
- isReadOnly: false,
- bankAccountRoute: '',
- session: {
- email: null,
- },
- policyID: '',
- reportID: '',
- ...withCurrentUserPersonalDetailsDefaultProps,
- receiptPath: '',
- receiptFilename: '',
- listStyles: [],
- policy: {},
- policyCategories: {},
- policyTags: {},
- transactionID: '',
- transaction: {},
- mileageRate: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate: 0, currency: 'USD'},
- isDistanceRequest: false,
- isScanRequest: false,
- shouldShowSmartScanFields: true,
- isPolicyExpenseChat: false,
- iou: iouDefaultProps,
-};
-
-function MoneyRequestConfirmationList(props) {
- const theme = useTheme();
- const styles = useThemeStyles();
- // Destructure functions from props to pass it as a dependecy to useCallback/useMemo hooks.
- // Prop functions pass props itself as a "this" value to the function which means they change every time props change.
- const {onSendMoney, onConfirm, onSelectParticipant} = props;
- const {translate, toLocaleDigit} = useLocalize();
- const transaction = props.transaction;
- const {canUseViolations} = usePermissions();
-
- const isTypeRequest = props.iouType === CONST.IOU.TYPE.REQUEST;
- const isSplitBill = props.iouType === CONST.IOU.TYPE.SPLIT;
- const isTypeSend = props.iouType === CONST.IOU.TYPE.SEND;
-
- const isSplitWithScan = isSplitBill && props.isScanRequest;
-
- const {unit, rate, currency} = props.mileageRate;
- const distance = lodashGet(transaction, 'routes.route0.distance', 0);
- const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0;
- const taxRates = lodashGet(props.policy, 'taxRates', {});
-
- // A flag for showing the categories field
- const shouldShowCategories = props.isPolicyExpenseChat && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories)));
- // A flag and a toggler for showing the rest of the form fields
- const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false);
-
- // Do not hide fields in case of send money request
- const shouldShowAllFields = props.isDistanceRequest || shouldExpandFields || !props.shouldShowSmartScanFields || isTypeSend || props.isEditingSplitBill;
-
- // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item
- const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan;
- const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !props.isDistanceRequest && !isSplitWithScan;
-
- const policyTagLists = useMemo(() => PolicyUtils.getTagLists(props.policyTags), [props.policyTags]);
-
- // A flag for showing the tags field
- const shouldShowTags = props.isPolicyExpenseChat && (props.iouTag || OptionsListUtils.hasEnabledTags(policyTagLists));
-
- // A flag for showing tax fields - tax rate and tax amount
- const shouldShowTax = props.isPolicyExpenseChat && lodashGet(props.policy, 'tax.trackingEnabled', props.policy.isTaxTrackingEnabled);
-
- // A flag for showing the billable field
- const shouldShowBillable = !lodashGet(props.policy, 'disabledFields.defaultBillable', true);
-
- const hasRoute = TransactionUtils.hasRoute(transaction);
- const isDistanceRequestWithPendingRoute = props.isDistanceRequest && (!hasRoute || !rate);
- const formattedAmount = isDistanceRequestWithPendingRoute
- ? ''
- : CurrencyUtils.convertToDisplayString(
- shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : props.iouAmount,
- props.isDistanceRequest ? currency : props.iouCurrencyCode,
- );
- const formattedTaxAmount = CurrencyUtils.convertToDisplayString(props.transaction.taxAmount, props.iouCurrencyCode);
-
- const defaultTaxKey = taxRates.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
- const taxRateTitle = (props.transaction.taxRate && props.transaction.taxRate.text) || defaultTaxName;
-
- const isFocused = useIsFocused();
- const [formError, setFormError] = useState('');
-
- const [didConfirm, setDidConfirm] = useState(false);
- const [didConfirmSplit, setDidConfirmSplit] = useState(false);
-
- const shouldDisplayFieldError = useMemo(() => {
- if (!props.isEditingSplitBill) {
- return false;
- }
-
- return (props.hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction));
- }, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]);
-
- const isMerchantEmpty = !props.iouMerchant || props.iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
- const shouldDisplayMerchantError = props.isPolicyExpenseChat && !props.isScanRequest && isMerchantEmpty;
-
- useEffect(() => {
- if (shouldDisplayFieldError && didConfirmSplit) {
- setFormError('iou.error.genericSmartscanFailureMessage');
- return;
- }
- if (shouldDisplayFieldError && props.hasSmartScanFailed) {
- setFormError('iou.receiptScanningFailed');
- return;
- }
- // reset the form error whenever the screen gains or loses focus
- setFormError('');
- }, [isFocused, transaction, shouldDisplayFieldError, props.hasSmartScanFailed, didConfirmSplit]);
-
- useEffect(() => {
- if (!shouldCalculateDistanceAmount) {
- return;
- }
-
- const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate);
- IOU.setMoneyRequestAmount(amount);
- }, [shouldCalculateDistanceAmount, distance, rate, unit]);
-
- /**
- * Returns the participants with amount
- * @param {Array} participants
- * @returns {Array}
- */
- const getParticipantsWithAmount = useCallback(
- (participantsList) => {
- const iouAmount = IOUUtils.calculateAmount(participantsList.length, props.iouAmount, props.iouCurrencyCode);
- return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(
- participantsList,
- props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(iouAmount, props.iouCurrencyCode) : '',
- );
- },
- [props.iouAmount, props.iouCurrencyCode],
- );
-
- // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again
- if (props.isEditingSplitBill && didConfirm) {
- setDidConfirm(false);
- }
-
- const splitOrRequestOptions = useMemo(() => {
- let text;
- if (isSplitBill && props.iouAmount === 0) {
- text = translate('iou.split');
- } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) {
- text = translate('iou.request');
- if (props.iouAmount !== 0) {
- text = translate('iou.requestAmount', {amount: formattedAmount});
- }
- } else {
- const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount';
- text = translate(translationKey, {amount: formattedAmount});
- }
- return [
- {
- text: text[0].toUpperCase() + text.slice(1),
- value: props.iouType,
- },
- ];
- }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]);
-
- const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]);
- const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]);
- const canModifyParticipants = !props.isReadOnly && props.canModifyParticipants && props.hasMultipleParticipants;
- const shouldDisablePaidBySection = canModifyParticipants;
-
- const optionSelectorSections = useMemo(() => {
- const sections = [];
- const unselectedParticipants = _.filter(props.selectedParticipants, (participant) => !participant.selected);
- if (props.hasMultipleParticipants) {
- const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipants);
- let formattedParticipantsList = _.union(formattedSelectedParticipants, unselectedParticipants);
-
- if (!canModifyParticipants) {
- formattedParticipantsList = _.map(formattedParticipantsList, (participant) => ({
- ...participant,
- isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID),
- }));
- }
-
- const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, props.iouAmount, props.iouCurrencyCode, true);
- const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(
- payeePersonalDetails,
- props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, props.iouCurrencyCode) : '',
- );
-
- sections.push(
- {
- title: translate('moneyRequestConfirmationList.paidBy'),
- data: [formattedPayeeOption],
- shouldShow: true,
- indexOffset: 0,
- isDisabled: shouldDisablePaidBySection,
- },
- {
- title: translate('moneyRequestConfirmationList.splitWith'),
- data: formattedParticipantsList,
- shouldShow: true,
- indexOffset: 1,
- },
- );
- } else {
- const formattedSelectedParticipants = _.map(props.selectedParticipants, (participant) => ({
- ...participant,
- isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID),
- }));
- sections.push({
- title: translate('common.to'),
- data: formattedSelectedParticipants,
- shouldShow: true,
- indexOffset: 0,
- });
- }
- return sections;
- }, [
- props.selectedParticipants,
- props.hasMultipleParticipants,
- props.iouAmount,
- props.iouCurrencyCode,
- getParticipantsWithAmount,
- selectedParticipants,
- payeePersonalDetails,
- translate,
- shouldDisablePaidBySection,
- canModifyParticipants,
- ]);
-
- const selectedOptions = useMemo(() => {
- if (!props.hasMultipleParticipants) {
- return [];
- }
- return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)];
- }, [selectedParticipants, props.hasMultipleParticipants, payeePersonalDetails]);
-
- useEffect(() => {
- if (!props.isDistanceRequest) {
- return;
- }
-
- /*
- Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as:
- When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page.
- In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending.
- */
- IOU.setMoneyRequestPendingFields(props.transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null});
-
- const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate, currency, translate, toLocaleDigit);
- IOU.setMoneyRequestMerchant(props.transactionID, distanceMerchant, false);
- }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, props.isDistanceRequest, props.transactionID]);
-
- /**
- * @param {Object} option
- */
- const selectParticipant = useCallback(
- (option) => {
- // Return early if selected option is currently logged in user.
- if (option.accountID === props.session.accountID) {
- return;
- }
- onSelectParticipant(option);
- },
- [props.session.accountID, onSelectParticipant],
- );
-
- /**
- * Navigate to report details or profile of selected user
- * @param {Object} option
- */
- const navigateToReportOrUserDetail = (option) => {
- if (option.accountID) {
- const activeRoute = Navigation.getActiveRouteWithoutParams();
-
- Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute));
- } else if (option.reportID) {
- Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID));
- }
- };
-
- /**
- * @param {String} paymentMethod
- */
- const confirm = useCallback(
- (paymentMethod) => {
- if (_.isEmpty(selectedParticipants)) {
- return;
- }
- if (props.iouCategory && props.iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) {
- setFormError('iou.error.invalidCategoryLength');
- return;
- }
- if (props.iouType === CONST.IOU.TYPE.SEND) {
- if (!paymentMethod) {
- return;
- }
-
- setDidConfirm(true);
-
- Log.info(`[IOU] Sending money via: ${paymentMethod}`);
- onSendMoney(paymentMethod);
- } else {
- // validate the amount for distance requests
- const decimals = CurrencyUtils.getCurrencyDecimals(props.iouCurrencyCode);
- if (props.isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) {
- setFormError('common.error.invalidAmount');
- return;
- }
-
- if (props.isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) {
- setDidConfirmSplit(true);
- return;
- }
-
- setDidConfirm(true);
- onConfirm(selectedParticipants);
- }
- },
- [
- selectedParticipants,
- onSendMoney,
- onConfirm,
- props.isEditingSplitBill,
- props.iouType,
- props.isDistanceRequest,
- props.iouCategory,
- isDistanceRequestWithPendingRoute,
- props.iouCurrencyCode,
- props.iouAmount,
- transaction,
- ],
- );
-
- const footerContent = useMemo(() => {
- if (props.isReadOnly) {
- return;
- }
-
- const shouldShowSettlementButton = props.iouType === CONST.IOU.TYPE.SEND;
- const shouldDisableButton = selectedParticipants.length === 0 || shouldDisplayMerchantError;
-
- const button = shouldShowSettlementButton ? (
-
- ) : (
- confirm(value)}
- options={splitOrRequestOptions}
- buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
- enterKeyEventListenerPriority={1}
- />
- );
-
- return (
- <>
- {!_.isEmpty(formError) && (
-
- )}
- {button}
- >
- );
- }, [
- props.isReadOnly,
- props.iouType,
- props.bankAccountRoute,
- props.iouCurrencyCode,
- props.policyID,
- selectedParticipants.length,
- shouldDisplayMerchantError,
- confirm,
- splitOrRequestOptions,
- formError,
- styles.ph1,
- styles.mb2,
- ]);
-
- const {image: receiptImage, thumbnail: receiptThumbnail} =
- props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, props.receiptPath, props.receiptFilename) : {};
- return (
-
- {props.isDistanceRequest && (
-
-
-
- )}
- {receiptImage || receiptThumbnail ? (
-
- ) : (
- // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate")
- PolicyUtils.isPaidGroupPolicy(props.policy) &&
- !props.isDistanceRequest &&
- props.iouType === CONST.IOU.TYPE.REQUEST && (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
- CONST.IOU.ACTION.CREATE,
- props.iouType,
- transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- )
- }
- />
- )
- )}
- {props.shouldShowSmartScanFields && (
- {
- if (props.isDistanceRequest) {
- return;
- }
- if (props.isEditingSplitBill) {
- Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.AMOUNT));
- return;
- }
- Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(props.iouType, props.reportID));
- }}
- style={[styles.moneyRequestMenuItem, styles.mt2]}
- titleStyle={styles.moneyRequestConfirmationAmount}
- disabled={didConfirm}
- brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
- error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''}
- />
- )}
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(
- CONST.IOU.ACTION.EDIT,
- props.iouType,
- transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- style={[styles.moneyRequestMenuItem]}
- titleStyle={styles.flex1}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- numberOfLinesTitle={2}
- />
- {!shouldShowAllFields && (
-
- )}
- {shouldShowAllFields && (
- <>
- {shouldShowDate && (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(
- CONST.IOU.ACTION.EDIT,
- props.iouType,
- transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
- error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''}
- />
- )}
- {props.isDistanceRequest && (
- Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))}
- disabled={didConfirm || !isTypeRequest}
- interactive={!props.isReadOnly}
- />
- )}
- {shouldShowMerchant && (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(
- CONST.IOU.ACTION.EDIT,
- props.iouType,
- transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- brickRoadIndicator={
- props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''
- }
- error={
- shouldDisplayMerchantError || (props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction))
- ? translate('common.error.enterMerchant')
- : ''
- }
- />
- )}
- {shouldShowCategories && (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(
- CONST.IOU.ACTION.EDIT,
- props.iouType,
- props.transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- style={[styles.moneyRequestMenuItem]}
- titleStyle={styles.flex1}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- rightLabel={canUseViolations && Boolean(props.policy.requiresCategory) ? translate('common.required') : ''}
- />
- )}
- {shouldShowTags &&
- _.map(policyTagLists, ({name}, index) => (
- {
- if (props.isEditingSplitBill) {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(
- CONST.IOU.ACTION.EDIT,
- CONST.IOU.TYPE.SPLIT,
- index,
- props.transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- return;
- }
- Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID));
- }}
- style={[styles.moneyRequestMenuItem]}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- rightLabel={canUseViolations && Boolean(props.policy.requiresTag) ? translate('common.required') : ''}
- />
- ))}
-
- {shouldShowTax && (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()),
- )
- }
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- />
- )}
-
- {shouldShowTax && (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()),
- )
- }
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- />
- )}
-
- {shouldShowBillable && (
-
- {translate('common.billable')}
-
-
- )}
- >
- )}
-
- );
-}
-
-MoneyRequestConfirmationList.propTypes = propTypes;
-MoneyRequestConfirmationList.defaultProps = defaultProps;
-MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList';
-
-export default compose(
- withCurrentUserPersonalDetails,
- withOnyx({
- session: {
- key: ONYXKEYS.SESSION,
- },
- policyCategories: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
- },
- policyTags: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
- },
- mileageRate: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- selector: DistanceRequestUtils.getDefaultMileageRate,
- },
- splitTransactionDraft: {
- key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`,
- },
- policy: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- },
- iou: {
- key: ONYXKEYS.IOU,
- },
- }),
-)(MoneyRequestConfirmationList);
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
new file mode 100755
index 000000000000..773e98b6462e
--- /dev/null
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -0,0 +1,904 @@
+import {useIsFocused} from '@react-navigation/native';
+import {format} from 'date-fns';
+import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as CurrencyUtils from '@libs/CurrencyUtils';
+import DistanceRequestUtils from '@libs/DistanceRequestUtils';
+import * as IOUUtils from '@libs/IOUUtils';
+import Log from '@libs/Log';
+import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as ReceiptUtils from '@libs/ReceiptUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import * as TransactionUtils from '@libs/TransactionUtils';
+import * as IOU from '@userActions/IOU';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Route} from '@src/ROUTES';
+import ROUTES from '@src/ROUTES';
+import type * as OnyxTypes from '@src/types/onyx';
+import type {Participant} from '@src/types/onyx/IOU';
+import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
+import type {MileageRate} from '@src/types/onyx/Policy';
+import type DeepValueOf from '@src/types/utils/DeepValueOf';
+import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
+import ConfirmedRoute from './ConfirmedRoute';
+import FormHelpMessage from './FormHelpMessage';
+import Image from './Image';
+import MenuItemWithTopDescription from './MenuItemWithTopDescription';
+import OptionsSelector from './OptionsSelector';
+import ReceiptEmptyState from './ReceiptEmptyState';
+import SettlementButton from './SettlementButton';
+import ShowMoreButton from './ShowMoreButton';
+import Switch from './Switch';
+import Text from './Text';
+import type {WithCurrentUserPersonalDetailsProps} from './withCurrentUserPersonalDetails';
+import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails';
+
+type DropdownOption = {
+ text: string;
+ value: DeepValueOf;
+};
+
+type Option = Partial;
+
+type CategorySection = {
+ title: string | undefined;
+ shouldShow: boolean;
+ indexOffset: number;
+ data: Option[];
+};
+
+type MoneyRequestConfirmationListOnyxProps = {
+ /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
+ iou: OnyxEntry;
+
+ /** Unit and rate used for if the money request is a distance request */
+ mileageRate: OnyxEntry;
+
+ /** Collection of categories attached to a policy */
+ policyCategories: OnyxEntry;
+
+ /** Collection of tags attached to a policy */
+ policyTags: OnyxEntry;
+
+ /** The policy of root parent report */
+ policy: OnyxEntry;
+
+ /** The session of the logged in user */
+ session: OnyxEntry;
+};
+
+type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps &
+ WithCurrentUserPersonalDetailsProps & {
+ /** Callback to inform parent modal of success */
+ onConfirm?: (selectedParticipants: Participant[]) => void;
+
+ /** Callback to parent modal to send money */
+ onSendMoney?: (paymentMethod: PaymentMethodType) => void;
+
+ /** Callback to inform a participant is selected */
+ onSelectParticipant?: (option: Participant) => void;
+
+ /** Should we request a single or multiple participant selection from user */
+ hasMultipleParticipants: boolean;
+
+ /** IOU amount */
+ iouAmount: number;
+
+ /** IOU comment */
+ iouComment?: string;
+
+ /** IOU currency */
+ iouCurrencyCode?: string;
+
+ /** IOU type */
+ iouType?: ValueOf;
+
+ /** IOU date */
+ iouCreated?: string;
+
+ /** IOU merchant */
+ iouMerchant?: string;
+
+ /** IOU Category */
+ iouCategory?: string;
+
+ /** IOU Tag */
+ iouTag?: string;
+
+ /** IOU isBillable */
+ iouIsBillable?: boolean;
+
+ /** Callback to toggle the billable state */
+ onToggleBillable?: () => void;
+
+ /** Selected participants from MoneyRequestModal with login / accountID */
+ selectedParticipants: Participant[];
+
+ /** Payee of the money request with login */
+ payeePersonalDetails?: OnyxEntry;
+
+ /** Can the participants be modified or not */
+ canModifyParticipants?: boolean;
+
+ /** Should the list be read only, and not editable? */
+ isReadOnly?: boolean;
+
+ /** Depending on expense report or personal IOU report, respective bank account route */
+ bankAccountRoute?: Route;
+
+ /** The policyID of the request */
+ policyID?: string;
+
+ /** The reportID of the request */
+ reportID?: string;
+
+ /** File path of the receipt */
+ receiptPath?: string;
+
+ /** File name of the receipt */
+ receiptFilename?: string;
+
+ /** List styles for OptionsSelector */
+ listStyles?: StyleProp;
+
+ /** ID of the transaction that represents the money request */
+ transactionID?: string;
+
+ /** Whether the money request is a distance request */
+ isDistanceRequest?: boolean;
+
+ /** Whether the money request is a scan request */
+ isScanRequest?: boolean;
+
+ /** Whether we're editing a split bill */
+ isEditingSplitBill?: boolean;
+
+ /** Whether we should show the amount, date, and merchant fields. */
+ shouldShowSmartScanFields?: boolean;
+
+ /** A flag for verifying that the current report is a sub-report of a workspace chat */
+ isPolicyExpenseChat?: boolean;
+
+ /** Whether there is smartscan failed */
+ hasSmartScanFailed?: boolean;
+
+ /** ID of the report action */
+ reportActionID?: string;
+
+ /** Transaction object */
+ transaction?: OnyxTypes.Transaction;
+ };
+
+function MoneyRequestConfirmationList({
+ onConfirm = () => {},
+ onSendMoney = () => {},
+ onSelectParticipant = () => {},
+ iouType = CONST.IOU.TYPE.REQUEST,
+ iouCategory = '',
+ iouTag = '',
+ iouIsBillable = false,
+ onToggleBillable = () => {},
+ payeePersonalDetails,
+ canModifyParticipants = false,
+ isReadOnly = false,
+ bankAccountRoute,
+ policyID,
+ reportID,
+ receiptPath,
+ receiptFilename,
+ transactionID,
+ mileageRate,
+ isDistanceRequest = false,
+ isScanRequest = false,
+ shouldShowSmartScanFields = true,
+ isPolicyExpenseChat = false,
+ transaction,
+ iouAmount,
+ policyTags,
+ policyCategories,
+ policy,
+ iouCurrencyCode,
+ isEditingSplitBill,
+ hasSmartScanFailed,
+ iouMerchant,
+ currentUserPersonalDetails,
+ hasMultipleParticipants,
+ selectedParticipants,
+ session,
+ iou,
+ reportActionID,
+ iouCreated,
+ listStyles,
+ iouComment,
+}: MoneyRequestConfirmationListProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate, toLocaleDigit} = useLocalize();
+ const {canUseViolations} = usePermissions();
+
+ const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST;
+ const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT;
+ const isTypeSend = iouType === CONST.IOU.TYPE.SEND;
+
+ const isSplitWithScan = isSplitBill && isScanRequest;
+
+ const distance = transaction?.routes?.route0.distance ?? 0;
+ const shouldCalculateDistanceAmount = isDistanceRequest && iouAmount === 0;
+ const taxRates = policy?.taxRates;
+
+ // A flag for showing the categories field
+ const shouldShowCategories = isPolicyExpenseChat && (iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {})));
+
+ // A flag and a toggler for showing the rest of the form fields
+ const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false);
+
+ // Do not hide fields in case of send money request
+ const shouldShowAllFields = isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || isEditingSplitBill;
+
+ // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item
+ const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan;
+ const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !isDistanceRequest && !isSplitWithScan;
+ const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]);
+ // A flag for showing the tags field
+ const shouldShowTags = isPolicyExpenseChat && (iouTag || OptionsListUtils.hasEnabledTags(policyTagLists));
+
+ // A flag for showing tax fields - tax rate and tax amount
+ const shouldShowTax = isPolicyExpenseChat && (policy?.tax?.trackingEnabled ?? policy?.isTaxTrackingEnabled);
+
+ // A flag for showing the billable field
+ const shouldShowBillable = !policy?.disabledFields?.defaultBillable ?? true;
+
+ const hasRoute = TransactionUtils.hasRoute(transaction ?? null);
+ const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !mileageRate?.rate);
+ const formattedAmount = isDistanceRequestWithPendingRoute
+ ? ''
+ : CurrencyUtils.convertToDisplayString(
+ shouldCalculateDistanceAmount
+ ? DistanceRequestUtils.getDistanceRequestAmount(distance, mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, mileageRate?.rate ?? 0)
+ : iouAmount,
+ isDistanceRequest ? mileageRate?.currency : iouCurrencyCode,
+ );
+ const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode);
+
+ const defaultTaxKey = taxRates?.defaultExternalID;
+ const defaultTaxName = (defaultTaxKey && `${taxRates?.taxes[defaultTaxKey].name} (${taxRates?.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) ?? '';
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const taxRateTitle = transaction?.taxRate?.text || defaultTaxName;
+
+ const isFocused = useIsFocused();
+ const [formError, setFormError] = useState(null);
+
+ const [didConfirm, setDidConfirm] = useState(false);
+ const [didConfirmSplit, setDidConfirmSplit] = useState(false);
+
+ const shouldDisplayFieldError = useMemo(() => {
+ if (!isEditingSplitBill) {
+ return false;
+ }
+
+ return (!!hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction ?? null)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null));
+ }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]);
+
+ const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
+ const shouldDisplayMerchantError = isPolicyExpenseChat && !isScanRequest && isMerchantEmpty;
+
+ useEffect(() => {
+ if (shouldDisplayFieldError && didConfirmSplit) {
+ setFormError('iou.error.genericSmartscanFailureMessage');
+ return;
+ }
+ if (shouldDisplayFieldError && hasSmartScanFailed) {
+ setFormError('iou.receiptScanningFailed');
+ return;
+ }
+ // reset the form error whenever the screen gains or loses focus
+ setFormError(null);
+ }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]);
+
+ useEffect(() => {
+ if (!shouldCalculateDistanceAmount) {
+ return;
+ }
+
+ const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, mileageRate?.rate ?? 0);
+ IOU.setMoneyRequestAmount(amount);
+ }, [shouldCalculateDistanceAmount, distance, mileageRate?.rate, mileageRate?.unit]);
+
+ /**
+ * Returns the participants with amount
+ */
+ const getParticipantsWithAmount = useCallback(
+ (participantsList: Participant[]) => {
+ const calculatedIouAmount = IOUUtils.calculateAmount(participantsList.length, iouAmount, iouCurrencyCode ?? '');
+ return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(
+ participantsList,
+ calculatedIouAmount > 0 ? CurrencyUtils.convertToDisplayString(calculatedIouAmount, iouCurrencyCode) : '',
+ );
+ },
+ [iouAmount, iouCurrencyCode],
+ );
+
+ // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again
+ if (isEditingSplitBill && didConfirm) {
+ setDidConfirm(false);
+ }
+
+ const splitOrRequestOptions: DropdownOption[] = useMemo(() => {
+ let text;
+ if (isSplitBill && iouAmount === 0) {
+ text = translate('iou.split');
+ } else if (!!(receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) {
+ text = translate('iou.request');
+ if (iouAmount !== 0) {
+ text = translate('iou.requestAmount', {amount: formattedAmount});
+ }
+ } else {
+ const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount';
+ text = translate(translationKey, {amount: formattedAmount});
+ }
+ return [
+ {
+ text: text[0].toUpperCase() + text.slice(1),
+ value: iouType,
+ },
+ ];
+ }, [isSplitBill, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]);
+
+ const selectedParticipantsMemo = useMemo(() => selectedParticipants.filter((participant) => participant.selected), [selectedParticipants]);
+ const payeePersonalDetailsMemo = useMemo(() => payeePersonalDetails ?? currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]);
+ const canModifyParticipantsValue = !isReadOnly && canModifyParticipants && hasMultipleParticipants;
+
+ const optionSelectorSections: CategorySection[] = useMemo(() => {
+ const sections = [];
+ const unselectedParticipants = selectedParticipants.filter((participant) => !participant.selected);
+ if (hasMultipleParticipants) {
+ const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipantsMemo);
+ let formattedParticipantsList = [...new Set([...formattedSelectedParticipants, ...unselectedParticipants])];
+
+ if (!canModifyParticipantsValue) {
+ formattedParticipantsList = formattedParticipantsList.map((participant) => ({
+ ...participant,
+ isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1),
+ }));
+ }
+
+ const myIOUAmount = IOUUtils.calculateAmount(selectedParticipantsMemo.length, iouAmount, iouCurrencyCode ?? '', true);
+ const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(
+ payeePersonalDetailsMemo,
+ iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode) : '',
+ );
+
+ sections.push(
+ {
+ title: translate('moneyRequestConfirmationList.paidBy'),
+ data: [formattedPayeeOption],
+ shouldShow: true,
+ indexOffset: 0,
+ isDisabled: canModifyParticipantsValue,
+ },
+ {
+ title: translate('moneyRequestConfirmationList.splitWith'),
+ data: formattedParticipantsList,
+ shouldShow: true,
+ indexOffset: 1,
+ },
+ );
+ } else {
+ const formattedSelectedParticipants = selectedParticipants.map((participant) => ({
+ ...participant,
+ isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1),
+ }));
+ sections.push({
+ title: translate('common.to'),
+ data: formattedSelectedParticipants,
+ shouldShow: true,
+ indexOffset: 0,
+ });
+ }
+ return sections;
+ }, [
+ selectedParticipants,
+ hasMultipleParticipants,
+ iouAmount,
+ iouCurrencyCode,
+ getParticipantsWithAmount,
+ payeePersonalDetailsMemo,
+ translate,
+ canModifyParticipantsValue,
+ selectedParticipantsMemo,
+ ]);
+
+ const selectedOptions = useMemo(() => {
+ if (!hasMultipleParticipants) {
+ return [];
+ }
+ const myIOUAmount = IOUUtils.calculateAmount(selectedParticipantsMemo.length, iouAmount, iouCurrencyCode ?? '', true);
+ return [
+ ...selectedParticipantsMemo,
+ OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetailsMemo, CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode)),
+ ];
+ }, [hasMultipleParticipants, selectedParticipantsMemo, iouAmount, iouCurrencyCode, payeePersonalDetailsMemo]);
+
+ useEffect(() => {
+ if (!isDistanceRequest) {
+ return;
+ }
+ /*
+ Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as:
+ When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page.
+ In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending.
+ */
+ IOU.setMoneyRequestPendingFields(transactionID ?? '', {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null});
+ const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(
+ hasRoute,
+ distance,
+ mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ mileageRate?.rate ?? 0,
+ mileageRate?.currency ?? 'USD',
+ translate,
+ toLocaleDigit,
+ );
+ IOU.setMoneyRequestMerchant(transactionID ?? '', distanceMerchant, false);
+ }, [hasRoute, distance, mileageRate?.unit, mileageRate?.rate, mileageRate?.currency, translate, toLocaleDigit, isDistanceRequest, transactionID, isDistanceRequestWithPendingRoute]);
+
+ const selectParticipant = useCallback(
+ (option: Participant) => {
+ // Return early if selected option is currently logged in user.
+ if (option.accountID === session?.accountID) {
+ return;
+ }
+ onSelectParticipant(option);
+ },
+ [session?.accountID, onSelectParticipant],
+ );
+
+ /**
+ * Navigate to report details or profile of selected user
+ */
+ const navigateToReportOrUserDetail = (option: Participant | OnyxTypes.Report) => {
+ if ('accountID' in option && option.accountID) {
+ const activeRoute = Navigation.getActiveRouteWithoutParams();
+
+ Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute));
+ } else if ('reportID' in option && option.reportID) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID));
+ }
+ };
+
+ const confirm = useCallback(
+ (paymentMethod: PaymentMethodType | undefined) => {
+ if (selectedParticipantsMemo.length === 0) {
+ return;
+ }
+ if (iouCategory && iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) {
+ setFormError('iou.error.invalidCategoryLength');
+ return;
+ }
+ if (iouType === CONST.IOU.TYPE.SEND) {
+ if (!paymentMethod) {
+ return;
+ }
+
+ setDidConfirm(true);
+
+ Log.info(`[IOU] Sending money via: ${paymentMethod}`);
+ onSendMoney(paymentMethod);
+ } else {
+ // validate the amount for distance requests
+ const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode);
+ if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) {
+ setFormError('common.error.invalidAmount');
+ return;
+ }
+
+ if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null)) {
+ setDidConfirmSplit(true);
+ return;
+ }
+
+ setDidConfirm(true);
+ onConfirm(selectedParticipantsMemo);
+ }
+ },
+ [
+ selectedParticipantsMemo,
+ iouCategory,
+ iouType,
+ onSendMoney,
+ iouCurrencyCode,
+ isDistanceRequest,
+ isDistanceRequestWithPendingRoute,
+ iouAmount,
+ isEditingSplitBill,
+ transaction,
+ onConfirm,
+ ],
+ );
+
+ const footerContent = useMemo(() => {
+ if (isReadOnly) {
+ return;
+ }
+
+ const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.SEND;
+ const shouldDisableButton = selectedParticipantsMemo.length === 0 || shouldDisplayMerchantError;
+
+ const button = shouldShowSettlementButton ? (
+
+ ) : (
+ confirm(value as PaymentMethodType)}
+ options={splitOrRequestOptions}
+ buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
+ enterKeyEventListenerPriority={1}
+ />
+ );
+
+ return (
+ <>
+ {!!formError && (
+
+ )}
+ {button}
+ >
+ );
+ }, [
+ isReadOnly,
+ iouType,
+ selectedParticipantsMemo.length,
+ shouldDisplayMerchantError,
+ confirm,
+ bankAccountRoute,
+ iouCurrencyCode,
+ policyID,
+ splitOrRequestOptions,
+ formError,
+ styles.ph1,
+ styles.mb2,
+ ]);
+
+ const receiptData = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction ?? null, receiptPath, receiptFilename) : null;
+ return (
+ // @ts-expect-error TODO: Remove this once OptionsSelector (https://github.com/Expensify/App/issues/25125) is migrated to TypeScript.
+
+ {isDistanceRequest && (
+
+
+
+ )}
+ {receiptData?.image ?? receiptData?.thumbnail ? (
+
+ ) : (
+ // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate")
+ PolicyUtils.isPaidGroupPolicy(policy) &&
+ !isDistanceRequest &&
+ iouType === CONST.IOU.TYPE.REQUEST && (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
+ CONST.IOU.ACTION.CREATE,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ )
+ }
+ />
+ )
+ )}
+ {shouldShowSmartScanFields && (
+ {
+ if (isDistanceRequest) {
+ return;
+ }
+ if (isEditingSplitBill) {
+ Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID ?? '', reportActionID ?? '', CONST.EDIT_REQUEST_FIELD.AMOUNT));
+ return;
+ }
+ Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(iouType, reportID));
+ }}
+ style={[styles.moneyRequestMenuItem, styles.mt2]}
+ titleStyle={styles.moneyRequestConfirmationAmount}
+ disabled={didConfirm}
+ brickRoadIndicator={
+ isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined
+ }
+ error={
+ shouldDisplayMerchantError || (isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null))
+ ? translate('common.error.enterMerchant')
+ : ''
+ }
+ />
+ )}
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ style={styles.moneyRequestMenuItem}
+ titleStyle={styles.flex1}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ numberOfLinesTitle={2}
+ />
+ {!shouldShowAllFields && (
+
+ )}
+ {shouldShowAllFields && (
+ <>
+ {shouldShowDate && (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
+ error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction ?? null) ? translate('common.error.enterDate') : ''}
+ />
+ )}
+ {isDistanceRequest && (
+ Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(iouType, reportID))}
+ disabled={didConfirm || !isTypeRequest}
+ interactive={!isReadOnly}
+ />
+ )}
+ {shouldShowMerchant && (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
+ error={
+ shouldDisplayMerchantError || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null))
+ ? translate('common.error.enterMerchant')
+ : ''
+ }
+ />
+ )}
+ {shouldShowCategories && (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ style={styles.moneyRequestMenuItem}
+ titleStyle={styles.flex1}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ rightLabel={canUseViolations && Boolean(policy?.requiresCategory) ? translate('common.required') : ''}
+ />
+ )}
+ {shouldShowTags &&
+ policyTagLists.map(({name}, index) => (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ CONST.IOU.TYPE.SPLIT,
+ index,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ style={styles.moneyRequestMenuItem}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ rightLabel={canUseViolations && !!policy?.requiresTag ? translate('common.required') : ''}
+ />
+ ))}
+
+ {shouldShowTax && (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction?.transactionID ?? '', reportID ?? '', Navigation.getActiveRouteWithoutParams()),
+ )
+ }
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ />
+ )}
+
+ {shouldShowTax && (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction?.transactionID ?? '', reportID ?? '', Navigation.getActiveRouteWithoutParams()),
+ )
+ }
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ />
+ )}
+
+ {shouldShowBillable && (
+
+ {translate('common.billable')}
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList';
+
+export default withCurrentUserPersonalDetails(
+ withOnyx({
+ policyCategories: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
+ },
+ policyTags: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ },
+ mileageRate: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ selector: DistanceRequestUtils.getDefaultMileageRate,
+ },
+ policy: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ },
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ })(MoneyRequestConfirmationList),
+);
diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx
index b95de8844ee0..8b606bd4429d 100644
--- a/src/components/MultipleAvatars.tsx
+++ b/src/components/MultipleAvatars.tsx
@@ -5,6 +5,7 @@ import type {ValueOf} from 'type-fest';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ReportUtils from '@libs/ReportUtils';
import type {AvatarSource} from '@libs/UserUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -106,7 +107,8 @@ function MultipleAvatars({
let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction);
const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]);
- const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => icon.name) : ['']), [shouldShowTooltip, icons]);
+ const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => ReportUtils.getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]);
+
const avatarSize = useMemo(() => {
if (isFocusMode) {
return CONST.AVATAR_SIZE.MID_SUBSCRIPT;
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index 1fa63f181dd6..c5c7c3ec50b0 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -1,8 +1,6 @@
-import {useIsFocused} from '@react-navigation/native';
import lodashGet from 'lodash/get';
-import lodashIsEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
-import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
+import React, {Component} from 'react';
import {ScrollView, View} from 'react-native';
import _ from 'underscore';
import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager';
@@ -13,10 +11,11 @@ import OptionsList from '@components/OptionsList';
import ReferralProgramCTA from '@components/ReferralProgramCTA';
import ShowMoreButton from '@components/ShowMoreButton';
import TextInput from '@components/TextInput';
-import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
-import useLocalize from '@hooks/useLocalize';
-import usePrevious from '@hooks/usePrevious';
-import useThemeStyles from '@hooks/useThemeStyles';
+import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import withNavigationFocus from '@components/withNavigationFocus';
+import withTheme, {withThemePropTypes} from '@components/withTheme';
+import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles';
+import compose from '@libs/compose';
import getPlatform from '@libs/getPlatform';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import setSelection from '@libs/setSelection';
@@ -36,6 +35,9 @@ const propTypes = {
/** List styles for OptionsList */
listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ /** Whether navigation is focused */
+ isFocused: PropTypes.bool.isRequired,
+
/** Whether referral CTA should be displayed */
shouldShowReferralCTA: PropTypes.bool,
@@ -43,9 +45,13 @@ const propTypes = {
referralContentType: PropTypes.string,
...optionsSelectorPropTypes,
+ ...withLocalizePropTypes,
+ ...withThemeStylesPropTypes,
+ ...withThemePropTypes,
};
const defaultProps = {
+ shouldDelayFocus: false,
shouldShowReferralCTA: false,
referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND,
safeAreaPaddingBottomStyle: {},
@@ -55,657 +61,622 @@ const defaultProps = {
...optionsSelectorDefaultProps,
};
-function BaseOptionsSelector(props) {
- const isFocused = useIsFocused();
- const {translate} = useLocalize();
- const themeStyles = useThemeStyles();
-
- const getInitiallyFocusedIndex = useCallback(
- (allOptions) => {
- let defaultIndex;
- if (props.shouldTextInputAppearBelowOptions) {
- defaultIndex = allOptions.length;
- } else if (props.focusedIndex >= 0) {
- defaultIndex = props.focusedIndex;
- } else {
- defaultIndex = props.selectedOptions.length;
+class BaseOptionsSelector extends Component {
+ constructor(props) {
+ super(props);
+
+ this.updateFocusedIndex = this.updateFocusedIndex.bind(this);
+ this.scrollToIndex = this.scrollToIndex.bind(this);
+ this.selectRow = this.selectRow.bind(this);
+ this.selectFocusedOption = this.selectFocusedOption.bind(this);
+ this.addToSelection = this.addToSelection.bind(this);
+ this.updateSearchValue = this.updateSearchValue.bind(this);
+ this.incrementPage = this.incrementPage.bind(this);
+ this.sliceSections = this.sliceSections.bind(this);
+ this.calculateAllVisibleOptionsCount = this.calculateAllVisibleOptionsCount.bind(this);
+ this.handleFocusIn = this.handleFocusIn.bind(this);
+ this.handleFocusOut = this.handleFocusOut.bind(this);
+ this.debouncedUpdateSearchValue = _.debounce(this.updateSearchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME);
+ this.relatedTarget = null;
+ this.accessibilityRoles = _.values(CONST.ROLE);
+ this.isWebOrDesktop = [CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform());
+
+ const allOptions = this.flattenSections();
+ const sections = this.sliceSections();
+ const focusedIndex = this.getInitiallyFocusedIndex(allOptions);
+ this.focusedOption = allOptions[focusedIndex];
+
+ this.state = {
+ sections,
+ allOptions,
+ focusedIndex,
+ shouldDisableRowSelection: false,
+ errorMessage: '',
+ paginationPage: 1,
+ disableEnterShortCut: false,
+ value: '',
+ };
+ }
+
+ componentDidMount() {
+ this.subscribeToEnterShortcut();
+ this.subscribeToCtrlEnterShortcut();
+ this.subscribeActiveElement();
+
+ if (this.props.isFocused && this.props.autoFocus && this.textInput) {
+ this.focusTimeout = setTimeout(() => {
+ this.textInput.focus();
+ }, CONST.ANIMATED_TRANSITION);
+ }
+
+ this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.disableEnterShortCut !== this.state.disableEnterShortCut) {
+ // Unregister the shortcut before registering a new one to avoid lingering shortcut listener
+ this.unsubscribeEnter();
+ if (!this.state.disableEnterShortCut) {
+ this.subscribeToEnterShortcut();
}
- if (_.isUndefined(props.initiallyFocusedOptionKey)) {
- return defaultIndex;
+ }
+
+ if (prevProps.isFocused !== this.props.isFocused) {
+ // Unregister the shortcut before registering a new one to avoid lingering shortcut listener
+ this.unSubscribeFromKeyboardShortcut();
+ if (this.props.isFocused) {
+ this.subscribeToEnterShortcut();
+ this.subscribeToCtrlEnterShortcut();
}
+ }
- const indexOfInitiallyFocusedOption = _.findIndex(allOptions, (option) => option.keyForList === props.initiallyFocusedOptionKey);
-
- return indexOfInitiallyFocusedOption;
- },
- [props.shouldTextInputAppearBelowOptions, props.initiallyFocusedOptionKey, props.selectedOptions.length, props.focusedIndex],
- );
-
- const isWebOrDesktop = [CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform());
- const accessibilityRoles = _.values(CONST.ROLE);
-
- const [disabledOptionsIndexes, setDisabledOptionsIndexes] = useState([]);
- const [shouldDisableRowSelection, setShouldDisableRowSelection] = useState(false);
- const [errorMessage, setErrorMessage] = useState('');
- const [value, setValue] = useState('');
- const [paginationPage, setPaginationPage] = useState(1);
- const [disableEnterShortCut, setDisableEnterShortCut] = useState(false);
-
- const relatedTarget = useRef(null);
- const listRef = useRef();
- const textInputRef = useRef();
- const enterSubscription = useRef();
- const CTRLEnterSubscription = useRef();
- const focusTimeout = useRef();
- const prevLocale = useRef(props.preferredLocale);
- const prevPaginationPage = useRef(paginationPage);
- const prevSelectedOptions = useRef(props.selectedOptions);
- const prevValue = useRef(value);
- const previousSections = usePrevious(props.sections);
-
- useImperativeHandle(props.forwardedRef, () => textInputRef.current);
+ // Screen coming back into focus, for example
+ // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K.
+ // Only applies to platforms that support keyboard shortcuts
+ if (this.isWebOrDesktop && !prevProps.isFocused && this.props.isFocused && this.props.autoFocus && this.textInput) {
+ setTimeout(() => {
+ this.textInput.focus();
+ }, CONST.ANIMATED_TRANSITION);
+ }
- /**
- * Flattens the sections into a single array of options.
- * Each object in this array is enhanced to have:
- *
- * 1. A `sectionIndex`, which represents the index of the section it came from
- * 2. An `index`, which represents the index of the option within the section it came from.
- *
- * @returns {Array
- {props.shouldShowReferralCTA && (
-
-
-
- )}
-
- {shouldShowFooter && (
-
- {shouldShowDefaultConfirmButton && (
-
- )}
- {props.footerContent}
-
- )}
-
- );
+
+ );
+ }
}
BaseOptionsSelector.defaultProps = defaultProps;
BaseOptionsSelector.propTypes = propTypes;
-const BaseOptionsSelectorWithRef = forwardRef((props, ref) => (
-
-));
-
-BaseOptionsSelectorWithRef.displayName = 'BaseOptionsSelectorWithRef';
-
-export default BaseOptionsSelectorWithRef;
+export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(BaseOptionsSelector);
diff --git a/src/components/OptionsSelector/index.android.js b/src/components/OptionsSelector/index.android.js
index 9f7c924e427f..ace5a5614ffb 100644
--- a/src/components/OptionsSelector/index.android.js
+++ b/src/components/OptionsSelector/index.android.js
@@ -6,6 +6,7 @@ const OptionsSelector = forwardRef((props, ref) => (
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={ref}
+ shouldDelayFocus
/>
));
diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js
index 7339718e7073..558f6636a325 100644
--- a/src/components/PDFView/index.native.js
+++ b/src/components/PDFView/index.native.js
@@ -104,12 +104,14 @@ function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused
/**
* After the PDF is successfully loaded hide PDFPasswordForm and the loading
* indicator.
+ * @param {Number} numberOfPages
+ * @param {Number} path - Path to cache location
*/
- const finishPDFLoad = () => {
+ const finishPDFLoad = (numberOfPages, path) => {
setShouldRequestPassword(false);
setShouldShowLoadingIndicator(false);
setSuccessToLoadPDF(true);
- onLoadComplete();
+ onLoadComplete(path);
};
function renderPDFView() {
@@ -137,7 +139,7 @@ function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused
fitPolicy={0}
trustAllCerts={false}
renderActivityIndicator={() => }
- source={{uri: sourceURL}}
+ source={{uri: sourceURL, cache: true, expiration: 864000}}
style={pdfStyles}
onError={handleFailureToLoadPDF}
password={password}
diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx
index 2816715dae2c..3109453ca6b0 100644
--- a/src/components/ParentNavigationSubtitle.tsx
+++ b/src/components/ParentNavigationSubtitle.tsx
@@ -21,7 +21,7 @@ type ParentNavigationSubtitleProps = {
function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID = '', pressableStyles}: ParentNavigationSubtitleProps) {
const styles = useThemeStyles();
- const {workspaceName, rootReportName} = parentNavigationSubtitleData;
+ const {workspaceName, reportName} = parentNavigationSubtitleData;
const {translate} = useLocalize();
@@ -30,7 +30,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID
onPress={() => {
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID));
}}
- accessibilityLabel={translate('threads.parentNavigationSummary', {rootReportName, workspaceName})}
+ accessibilityLabel={translate('threads.parentNavigationSummary', {reportName, workspaceName})}
role={CONST.ROLE.LINK}
style={pressableStyles}
>
@@ -39,7 +39,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID
numberOfLines={1}
>
{`${translate('threads.from')} `}
- {rootReportName}
+ {reportName}
{Boolean(workspaceName) && {` ${translate('threads.in')} ${workspaceName}`}}
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index a391ff061baa..83da817da858 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -18,7 +18,7 @@ import Text from './Text';
type PopoverMenuItem = {
/** An icon element displayed on the left side */
- icon: IconAsset;
+ icon?: IconAsset;
/** Text label */
text: string;
diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx
index 90c7d8580b5c..2030ce8f0bfd 100644
--- a/src/components/RadioButtons.tsx
+++ b/src/components/RadioButtons.tsx
@@ -39,8 +39,9 @@ type RadioButtonsProps = {
function RadioButtons({items, onPress, defaultCheckedValue = '', radioButtonStyle, errorText, onInputChange = () => {}, value}: RadioButtonsProps, ref: ForwardedRef) {
const styles = useThemeStyles();
const [checkedValue, setCheckedValue] = useState(defaultCheckedValue);
+
useEffect(() => {
- if (value === checkedValue) {
+ if (value === checkedValue || value === undefined) {
return;
}
setCheckedValue(value ?? '');
diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.tsx
similarity index 75%
rename from src/components/ReimbursementAccountLoadingIndicator.js
rename to src/components/ReimbursementAccountLoadingIndicator.tsx
index 141e056afd93..cc9beb513002 100644
--- a/src/components/ReimbursementAccountLoadingIndicator.js
+++ b/src/components/ReimbursementAccountLoadingIndicator.tsx
@@ -1,4 +1,3 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
@@ -10,14 +9,15 @@ import LottieAnimations from './LottieAnimations';
import ScreenWrapper from './ScreenWrapper';
import Text from './Text';
-const propTypes = {
+type ReimbursementAccountLoadingIndicatorProps = {
/** Method to trigger when pressing back button of the header */
- onBackButtonPress: PropTypes.func.isRequired,
+ onBackButtonPress: () => void;
};
-function ReimbursementAccountLoadingIndicator(props) {
+function ReimbursementAccountLoadingIndicator({onBackButtonPress}: ReimbursementAccountLoadingIndicatorProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+
return (
-
+
-
- {translate('reimbursementAccountLoadingAnimation.explanationLine')}
+
+ {translate('reimbursementAccountLoadingAnimation.explanationLine')}
@@ -46,7 +46,6 @@ function ReimbursementAccountLoadingIndicator(props) {
);
}
-ReimbursementAccountLoadingIndicator.propTypes = propTypes;
ReimbursementAccountLoadingIndicator.displayName = 'ReimbursementAccountLoadingIndicator';
export default ReimbursementAccountLoadingIndicator;
diff --git a/src/components/RoomNameInput/index.native.js b/src/components/RoomNameInput/index.native.tsx
similarity index 55%
rename from src/components/RoomNameInput/index.native.js
rename to src/components/RoomNameInput/index.native.tsx
index bae347fca3d2..6598015286e2 100644
--- a/src/components/RoomNameInput/index.native.js
+++ b/src/components/RoomNameInput/index.native.tsx
@@ -1,26 +1,30 @@
+import type {ForwardedRef} from 'react';
import React from 'react';
-import _ from 'underscore';
+import type {NativeSyntheticEvent, TextInputChangeEventData} from 'react-native';
import TextInput from '@components/TextInput';
+import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useLocalize from '@hooks/useLocalize';
import getOperatingSystem from '@libs/getOperatingSystem';
import * as RoomNameInputUtils from '@libs/RoomNameInputUtils';
import CONST from '@src/CONST';
-import * as roomNameInputPropTypes from './roomNameInputPropTypes';
+import type RoomNameInputProps from './types';
-function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, value, onBlur, onChangeText, onInputChange, onSubmitEditing, returnKeyType, shouldDelayFocus}) {
+function RoomNameInput(
+ {disabled = false, autoFocus = false, shouldDelayFocus = false, isFocused, value, onBlur, onChangeText, onInputChange, ...props}: RoomNameInputProps,
+ ref: ForwardedRef,
+) {
const {translate} = useLocalize();
/**
* Calls the onChangeText callback with a modified room name
- * @param {Event} event
*/
- const setModifiedRoomName = (event) => {
+ const setModifiedRoomName = (event: NativeSyntheticEvent) => {
const roomName = event.nativeEvent.text;
const modifiedRoomName = RoomNameInputUtils.modifyRoomName(roomName);
- onChangeText(modifiedRoomName);
+ onChangeText?.(modifiedRoomName);
// if custom component has onInputChange, use it to trigger changes (Form input)
- if (_.isFunction(onInputChange)) {
+ if (typeof onInputChange === 'function') {
onInputChange(modifiedRoomName);
}
};
@@ -29,40 +33,27 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef,
return (
isFocused && onBlur(event)}
- onSubmitEditing={onSubmitEditing}
- returnKeyType={returnKeyType}
+ onBlur={(event) => isFocused && onBlur?.(event)}
autoFocus={isFocused && autoFocus}
- autoCapitalize="none"
shouldDelayFocus={shouldDelayFocus}
+ autoCapitalize="none"
+ onChange={setModifiedRoomName}
+ keyboardType={keyboardType} // this is a bit hacky solution to a RN issue https://github.com/facebook/react-native/issues/27449
/>
);
}
-RoomNameInput.propTypes = roomNameInputPropTypes.propTypes;
-RoomNameInput.defaultProps = roomNameInputPropTypes.defaultProps;
RoomNameInput.displayName = 'RoomNameInput';
-const RoomNameInputWithRef = React.forwardRef((props, ref) => (
-
-));
-
-RoomNameInputWithRef.displayName = 'RoomNameInputWithRef';
-
-export default RoomNameInputWithRef;
+export default React.forwardRef(RoomNameInput);
diff --git a/src/components/RoomNameInput/index.js b/src/components/RoomNameInput/index.tsx
similarity index 62%
rename from src/components/RoomNameInput/index.js
rename to src/components/RoomNameInput/index.tsx
index e3c5a86ff945..7025543e479d 100644
--- a/src/components/RoomNameInput/index.js
+++ b/src/components/RoomNameInput/index.tsx
@@ -1,27 +1,31 @@
import React, {useState} from 'react';
-import _ from 'underscore';
+import type {ForwardedRef} from 'react';
+import type {NativeSyntheticEvent, TextInputChangeEventData} from 'react-native';
import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
+import type {Selection} from '@libs/ComposerUtils';
import * as RoomNameInputUtils from '@libs/RoomNameInputUtils';
+import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types';
import CONST from '@src/CONST';
-import * as roomNameInputPropTypes from './roomNameInputPropTypes';
+import type RoomNameInputProps from './types';
-function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, value, onBlur, onChangeText, onInputChange, onSubmitEditing, returnKeyType, shouldDelayFocus}) {
+function RoomNameInput(
+ {disabled = false, autoFocus = false, shouldDelayFocus = false, isFocused, value = '', onBlur, onChangeText, onInputChange, ...props}: RoomNameInputProps,
+ ref: ForwardedRef,
+) {
const {translate} = useLocalize();
-
- const [selection, setSelection] = useState();
+ const [selection, setSelection] = useState({start: value.length - 1, end: value.length - 1});
/**
* Calls the onChangeText callback with a modified room name
- * @param {Event} event
*/
- const setModifiedRoomName = (event) => {
+ const setModifiedRoomName = (event: NativeSyntheticEvent) => {
const roomName = event.nativeEvent.text;
const modifiedRoomName = RoomNameInputUtils.modifyRoomName(roomName);
- onChangeText(modifiedRoomName);
+ onChangeText?.(modifiedRoomName);
// if custom component has onInputChange, use it to trigger changes (Form input)
- if (_.isFunction(onInputChange)) {
+ if (typeof onInputChange === 'function') {
onInputChange(modifiedRoomName);
}
@@ -30,7 +34,7 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef,
// If it is, then the room name is valid (does not contain forbidden characters) – no action required
// If not, then the room name contains invalid characters, and we must adjust the cursor position manually
// Read more: https://github.com/Expensify/App/issues/12741
- const oldRoomNameWithHash = value || '';
+ const oldRoomNameWithHash = value ?? '';
const newRoomNameWithHash = `${CONST.POLICY.ROOM_PREFIX}${roomName}`;
if (modifiedRoomName !== newRoomNameWithHash) {
const offset = modifiedRoomName.length - oldRoomNameWithHash.length;
@@ -41,43 +45,30 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef,
return (
isFocused && onBlur?.(event)}
+ autoFocus={isFocused && autoFocus}
+ shouldDelayFocus={shouldDelayFocus}
+ autoCapitalize="none"
onChange={setModifiedRoomName}
- value={value.substring(1)} // Since the room name always starts with a prefix, we omit the first character to avoid displaying it twice.
- selection={selection}
onSelectionChange={(event) => setSelection(event.nativeEvent.selection)}
- onSubmitEditing={onSubmitEditing}
- returnKeyType={returnKeyType}
- errorText={errorText}
- autoCapitalize="none"
- onBlur={(event) => isFocused && onBlur(event)}
- shouldDelayFocus={shouldDelayFocus}
- autoFocus={isFocused && autoFocus}
- maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH}
+ selection={selection}
spellCheck={false}
shouldInterceptSwipe
/>
);
}
-RoomNameInput.propTypes = roomNameInputPropTypes.propTypes;
-RoomNameInput.defaultProps = roomNameInputPropTypes.defaultProps;
RoomNameInput.displayName = 'RoomNameInput';
-const RoomNameInputWithRef = React.forwardRef((props, ref) => (
-
-));
-
-RoomNameInputWithRef.displayName = 'RoomNameInputWithRef';
-
-export default RoomNameInputWithRef;
+export default React.forwardRef(RoomNameInput);
diff --git a/src/components/RoomNameInput/roomNameInputPropTypes.js b/src/components/RoomNameInput/roomNameInputPropTypes.js
deleted file mode 100644
index aa547354a4c5..000000000000
--- a/src/components/RoomNameInput/roomNameInputPropTypes.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import PropTypes from 'prop-types';
-import refPropTypes from '@components/refPropTypes';
-import {translatableTextPropTypes} from '@libs/Localize';
-
-const propTypes = {
- /** Callback to execute when the text input is modified correctly */
- onChangeText: PropTypes.func,
-
- /** Room name to show in input field. This should include the '#' already prefixed to the name */
- value: PropTypes.string,
-
- /** Whether we should show the input as disabled */
- disabled: PropTypes.bool,
-
- /** Error text to show */
- errorText: translatableTextPropTypes,
-
- /** A ref forwarded to the TextInput */
- forwardedRef: refPropTypes,
-
- /** On submit editing handler provided by the FormProvider */
- onSubmitEditing: PropTypes.func,
-
- /** Return key type provided to the TextInput */
- returnKeyType: PropTypes.string,
-
- /** The ID used to uniquely identify the input in a Form */
- inputID: PropTypes.string,
-
- /** Callback that is called when the text input is blurred */
- onBlur: PropTypes.func,
-
- /** AutoFocus */
- autoFocus: PropTypes.bool,
-
- /** Whether we should wait before focusing the TextInput, useful when using transitions on Android */
- shouldDelayFocus: PropTypes.bool,
-
- /** Whether navigation is focused */
- isFocused: PropTypes.bool.isRequired,
-};
-
-const defaultProps = {
- onChangeText: () => {},
- value: '',
- disabled: false,
- errorText: '',
- forwardedRef: () => {},
- onSubmitEditing: () => {},
- returnKeyType: undefined,
-
- inputID: undefined,
- onBlur: () => {},
- autoFocus: false,
- shouldDelayFocus: false,
-};
-
-export {propTypes, defaultProps};
diff --git a/src/components/RoomNameInput/types.ts b/src/components/RoomNameInput/types.ts
new file mode 100644
index 000000000000..80f08a01e472
--- /dev/null
+++ b/src/components/RoomNameInput/types.ts
@@ -0,0 +1,19 @@
+import type {NativeSyntheticEvent, ReturnKeyTypeOptions, TextInputFocusEventData, TextInputSubmitEditingEventData} from 'react-native';
+import type {MaybePhraseKey} from '@libs/Localize';
+
+type RoomNameInputProps = {
+ value?: string;
+ disabled?: boolean;
+ errorText?: MaybePhraseKey;
+ onChangeText?: (value: string) => void;
+ onSubmitEditing?: (event: NativeSyntheticEvent) => void;
+ onInputChange?: (value: string) => void;
+ returnKeyType?: ReturnKeyTypeOptions;
+ inputID?: string;
+ onBlur?: (event: NativeSyntheticEvent) => void;
+ autoFocus?: boolean;
+ shouldDelayFocus?: boolean;
+ isFocused: boolean;
+};
+
+export default RoomNameInputProps;
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index 2f853dc55839..98b1999625ee 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -19,6 +19,7 @@ function BaseListItem({
shouldPreventDefaultFocusOnSelectRow = false,
canSelectMultiple = false,
onSelectRow,
+ onCheckboxPress,
onDismissError = () => {},
rightHandSideComponent,
keyForList,
@@ -43,6 +44,14 @@ function BaseListItem({
return rightHandSideComponent;
};
+ const handleCheckboxPress = () => {
+ if (onCheckboxPress) {
+ onCheckboxPress(item);
+ } else {
+ onSelectRow(item);
+ }
+ };
+
return (
onDismissError(item)}
@@ -66,8 +75,10 @@ function BaseListItem({
<>
{canSelectMultiple && (
-
@@ -80,7 +91,7 @@ function BaseListItem({
/>
)}
-
+
)}
{typeof children === 'function' ? children(hovered) : children}
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 1c69d00b3910..9cd37709552b 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -8,7 +8,6 @@ import Button from '@components/Button';
import Checkbox from '@components/Checkbox';
import FixedFooter from '@components/FixedFooter';
import OptionsListSkeletonView from '@components/OptionsListSkeletonView';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import SectionList from '@components/SectionList';
import Text from '@components/Text';
@@ -30,6 +29,7 @@ function BaseSelectionList(
ListItem,
canSelectMultiple = false,
onSelectRow,
+ onCheckboxPress,
onSelectAll,
onDismissError,
textInputLabel = '',
@@ -63,6 +63,7 @@ function BaseSelectionList(
onLayout,
customListHeader,
listHeaderWrapperStyle,
+ isRowMultilineSupported = false,
}: BaseSelectionListProps,
inputRef: ForwardedRef,
) {
@@ -289,10 +290,12 @@ function BaseSelectionList(
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
onSelectRow={() => selectRow(item)}
+ onCheckboxPress={onCheckboxPress ? () => onCheckboxPress?.(item) : undefined}
onDismissError={() => onDismissError?.(item)}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
rightHandSideComponent={rightHandSideComponent}
keyForList={item.keyForList}
+ isMultilineSupported={isRowMultilineSupported}
/>
);
};
@@ -429,28 +432,19 @@ function BaseSelectionList(
) : (
<>
{!headerMessage && canSelectMultiple && shouldShowSelectAll && (
- e.preventDefault() : undefined}
- >
+
{customListHeader ?? (
{translate('workspace.people.selectAll')}
)}
-
+
)}
{!headerMessage && !canSelectMultiple && customListHeader}
{!!item.alternateText && (
diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx
index 922937c72219..2acad7f40f20 100644
--- a/src/components/SelectionList/TableListItem.tsx
+++ b/src/components/SelectionList/TableListItem.tsx
@@ -15,6 +15,7 @@ function TableListItem({
isDisabled,
canSelectMultiple,
onSelectRow,
+ onCheckboxPress,
onDismissError,
shouldPreventDefaultFocusOnSelectRow,
rightHandSideComponent,
@@ -37,6 +38,7 @@ function TableListItem({
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
onSelectRow={onSelectRow}
+ onCheckboxPress={onCheckboxPress}
onDismissError={onDismissError}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
rightHandSideComponent={rightHandSideComponent}
diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx
index 06d0f6452117..fc39bb4b1259 100644
--- a/src/components/SelectionList/UserListItem.tsx
+++ b/src/components/SelectionList/UserListItem.tsx
@@ -18,6 +18,7 @@ function UserListItem({
isDisabled,
canSelectMultiple,
onSelectRow,
+ onCheckboxPress,
onDismissError,
shouldPreventDefaultFocusOnSelectRow,
rightHandSideComponent,
@@ -41,6 +42,7 @@ function UserListItem({
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
onSelectRow={onSelectRow}
+ onCheckboxPress={onCheckboxPress}
onDismissError={onDismissError}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
rightHandSideComponent={rightHandSideComponent}
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index 59f6b14cfb1f..f3b87cf4a796 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -23,6 +23,9 @@ type CommonListItemProps = {
/** Callback to fire when the item is pressed */
onSelectRow: (item: TItem) => void;
+ /** Callback to fire when a checkbox is pressed */
+ onCheckboxPress?: (item: TItem) => void;
+
/** Callback to fire when an error is dismissed */
onDismissError?: (item: TItem) => void;
@@ -37,6 +40,9 @@ type CommonListItemProps = {
/** Styles for the checkbox wrapper view if select multiple option is on */
selectMultipleStyle?: StyleProp;
+
+ /** Whether to wrap long text up to 2 lines */
+ isMultilineSupported?: boolean;
};
type ListItem = {
@@ -83,6 +89,9 @@ type ListItem = {
/** Whether this option should show subscript */
shouldShowSubscript?: boolean;
+
+ /** Whether to wrap long text up to 2 lines */
+ isMultilineSupported?: boolean;
};
type ListItemProps = CommonListItemProps & {
@@ -157,6 +166,9 @@ type BaseSelectionListProps = Partial & {
/** Callback to fire when a row is pressed */
onSelectRow: (item: TItem) => void;
+ /** Optional callback function triggered upon pressing a checkbox. If undefined and the list displays checkboxes, checkbox interactions are managed by onSelectRow, allowing for pressing anywhere on the list. */
+ onCheckboxPress?: (item: TItem) => void;
+
/** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */
onSelectAll?: () => void;
@@ -258,6 +270,9 @@ type BaseSelectionListProps = Partial & {
/** Styles for the list header wrapper */
listHeaderWrapperStyle?: StyleProp;
+
+ /** Whether to wrap long text up to 2 lines */
+ isRowMultilineSupported?: boolean;
};
type ItemLayout = {
diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx
index 50bfcd4cc8be..6ca13a61933c 100644
--- a/src/components/SettlementButton.tsx
+++ b/src/components/SettlementButton.tsx
@@ -228,10 +228,10 @@ function SettlementButton({
buttonRef={buttonRef}
isDisabled={isDisabled}
isLoading={isLoading}
- onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType, triggerKYCFlow)}
+ onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType as PaymentMethodType, triggerKYCFlow)}
pressOnEnter={pressOnEnter}
options={paymentButtonOptions}
- onOptionSelected={(option) => savePreferredPaymentMethod(policyID, option.value)}
+ onOptionSelected={(option) => savePreferredPaymentMethod(policyID, option.value as PaymentMethodType)}
style={style}
buttonSize={buttonSize}
anchorAlignment={paymentMethodDropdownAnchorAlignment}
diff --git a/src/components/TextWithTooltip/index.native.tsx b/src/components/TextWithTooltip/index.native.tsx
index f8013ae00e4c..3780df6362be 100644
--- a/src/components/TextWithTooltip/index.native.tsx
+++ b/src/components/TextWithTooltip/index.native.tsx
@@ -2,11 +2,11 @@ import React from 'react';
import Text from '@components/Text';
import type TextWithTooltipProps from './types';
-function TextWithTooltip({text, textStyles}: TextWithTooltipProps) {
+function TextWithTooltip({text, textStyles, numberOfLines = 1}: TextWithTooltipProps) {
return (
{text}
diff --git a/src/components/TextWithTooltip/index.tsx b/src/components/TextWithTooltip/index.tsx
index fd202db8de64..96721488c6db 100644
--- a/src/components/TextWithTooltip/index.tsx
+++ b/src/components/TextWithTooltip/index.tsx
@@ -7,7 +7,7 @@ type LayoutChangeEvent = {
target: HTMLElement;
};
-function TextWithTooltip({text, shouldShowTooltip, textStyles}: TextWithTooltipProps) {
+function TextWithTooltip({text, shouldShowTooltip, textStyles, numberOfLines = 1}: TextWithTooltipProps) {
const [showTooltip, setShowTooltip] = useState(false);
return (
@@ -17,7 +17,7 @@ function TextWithTooltip({text, shouldShowTooltip, textStyles}: TextWithTooltipP
>
{
const target = (e.nativeEvent as unknown as LayoutChangeEvent).target;
if (!shouldShowTooltip) {
diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts
index 80517b0b2acf..19c0b0dca6ed 100644
--- a/src/components/TextWithTooltip/types.ts
+++ b/src/components/TextWithTooltip/types.ts
@@ -1,9 +1,17 @@
import type {StyleProp, TextStyle} from 'react-native';
type TextWithTooltipProps = {
+ /** The text to display */
text: string;
+
+ /** Whether to show the toolip text */
shouldShowTooltip: boolean;
+
+ /** Additional text styles */
textStyles?: StyleProp;
+
+ /** Custom number of lines for text wrapping */
+ numberOfLines?: number;
};
export default TextWithTooltipProps;
diff --git a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx
index c4015c74abd5..592cec3beca5 100644
--- a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx
+++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx
@@ -19,7 +19,7 @@ function BaseUserDetailsTooltip({accountID, fallbackUserDetails, icon, delegateA
const personalDetails = usePersonalDetails();
const userDetails = personalDetails?.[accountID] ?? fallbackUserDetails ?? {};
- let userDisplayName = ReportUtils.getDisplayNameForParticipant(accountID) || (userDetails.displayName ? userDetails.displayName.trim() : '');
+ let userDisplayName = ReportUtils.getUserDetailTooltipText(accountID, userDetails.displayName ? userDetails.displayName.trim() : '');
let userLogin = userDetails.login?.trim() && userDetails.login !== userDetails.displayName ? Str.removeSMSDomain(userDetails.login) : '';
let userAvatar = userDetails.avatar;
@@ -29,7 +29,7 @@ function BaseUserDetailsTooltip({accountID, fallbackUserDetails, icon, delegateA
// the Copilot feature is implemented.
if (delegateAccountID) {
const delegateUserDetails = personalDetails?.[delegateAccountID];
- const delegateUserDisplayName = ReportUtils.getDisplayNameForParticipant(delegateAccountID);
+ const delegateUserDisplayName = ReportUtils.getUserDetailTooltipText(delegateAccountID);
userDisplayName = `${delegateUserDisplayName} (${translate('reportAction.asCopilot')} ${userDisplayName})`;
userLogin = delegateUserDetails?.login ?? '';
userAvatar = delegateUserDetails?.avatar;
diff --git a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
index 1c1d042d3893..45f47eb87c36 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
+++ b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
-import React, {memo, useState} from 'react';
+import React, {memo, useCallback, useState} from 'react';
import {View} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import Animated, {runOnJS, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
@@ -9,6 +9,7 @@ import IconButton from '@components/VideoPlayer/IconButton';
import {useVolumeContext} from '@components/VideoPlayerContexts/VolumeContext';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as NumberUtils from '@libs/NumberUtils';
import stylePropTypes from '@styles/stylePropTypes';
const propTypes = {
@@ -38,27 +39,35 @@ function VolumeButton({style, small}) {
const [volumeIcon, setVolumeIcon] = useState({icon: getVolumeIcon(volume.value)});
const [isSliderBeingUsed, setIsSliderBeingUsed] = useState(false);
- const onSliderLayout = (e) => {
+ const onSliderLayout = useCallback((e) => {
setSliderHeight(e.nativeEvent.layout.height);
- };
+ }, []);
+
+ const changeVolumeOnPan = useCallback(
+ (event) => {
+ const val = NumberUtils.roundToTwoDecimalPlaces(1 - event.y / sliderHeight);
+ volume.value = NumberUtils.clamp(val, 0, 1);
+ },
+ [sliderHeight, volume],
+ );
const pan = Gesture.Pan()
- .onBegin(() => {
+ .onBegin((event) => {
runOnJS(setIsSliderBeingUsed)(true);
+ changeVolumeOnPan(event);
})
.onChange((event) => {
- const val = Math.floor((1 - event.y / sliderHeight) * 100) / 100;
- volume.value = Math.min(Math.max(val, 0), 1);
+ changeVolumeOnPan(event);
})
- .onEnd(() => {
+ .onFinalize(() => {
runOnJS(setIsSliderBeingUsed)(false);
});
const progressBarStyle = useAnimatedStyle(() => ({height: `${volume.value * 100}%`}));
- const updateIcon = (vol) => {
+ const updateIcon = useCallback((vol) => {
setVolumeIcon({icon: getVolumeIcon(vol)});
- };
+ }, []);
useDerivedValue(() => {
runOnJS(updateVolume)(volume.value);
diff --git a/src/components/VideoPlayerContexts/VolumeContext.js b/src/components/VideoPlayerContexts/VolumeContext.js
index 2df463654075..a0b972d37a0d 100644
--- a/src/components/VideoPlayerContexts/VolumeContext.js
+++ b/src/components/VideoPlayerContexts/VolumeContext.js
@@ -14,7 +14,7 @@ function VolumeContextProvider({children}) {
if (!currentVideoPlayerRef.current) {
return;
}
- currentVideoPlayerRef.current.setStatusAsync({volume: newVolume});
+ currentVideoPlayerRef.current.setStatusAsync({volume: newVolume, isMuted: newVolume === 0});
volume.value = newVolume;
},
[currentVideoPlayerRef, volume],
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 91f3198ca1e4..4fa22d8e255d 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -632,7 +632,7 @@ export default {
waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up, payment is held until ${submitterDisplayName} adds a bank account`,
adminCanceledRequest: ({manager, amount}: AdminCanceledRequestParams) => `${manager} cancelled the ${amount} payment.`,
canceledRequest: ({amount, submitterDisplayName}: CanceledRequestParams) =>
- `Canceled the ${amount} payment, because ${submitterDisplayName} did not enable their Expensify Wallet within 30 days`,
+ `canceled the ${amount} payment, because ${submitterDisplayName} did not enable their Expensify Wallet within 30 days`,
settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) =>
`${submitterDisplayName} added a bank account. The ${amount} payment has been made.`,
paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer ? `${payer} ` : ''}paid ${amount} elsewhere`,
@@ -2171,7 +2171,7 @@ export default {
reply: 'Reply',
from: 'From',
in: 'in',
- parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `From ${rootReportName}${workspaceName ? ` in ${workspaceName}` : ''}`,
+ parentNavigationSummary: ({reportName, workspaceName}: ParentNavigationSummaryParams) => `From ${reportName}${workspaceName ? ` in ${workspaceName}` : ''}`,
},
qrCodes: {
copy: 'Copy URL',
@@ -2238,7 +2238,6 @@ export default {
address: 'Address',
waypointDescription: {
start: 'Start',
- finish: 'Finish',
stop: 'Stop',
},
mapPending: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index d17355e69d55..7bea48d71d36 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -625,7 +625,7 @@ export default {
waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inicio el pago, pero no se procesará hasta que ${submitterDisplayName} añada una cuenta bancaria`,
adminCanceledRequest: ({manager, amount}: AdminCanceledRequestParams) => `${manager} canceló el pago de ${amount}.`,
canceledRequest: ({amount, submitterDisplayName}: CanceledRequestParams) =>
- `Canceló el pago ${amount}, porque ${submitterDisplayName} no habilitó su billetera Expensify en un plazo de 30 días.`,
+ `canceló el pago ${amount}, porque ${submitterDisplayName} no habilitó su billetera Expensify en un plazo de 30 días.`,
settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) =>
`${submitterDisplayName} añadió una cuenta bancaria. El pago de ${amount} se ha realizado.`,
paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount} de otra forma`,
@@ -2659,7 +2659,7 @@ export default {
reply: 'Respuesta',
from: 'De',
in: 'en',
- parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `De ${rootReportName}${workspaceName ? ` en ${workspaceName}` : ''}`,
+ parentNavigationSummary: ({reportName, workspaceName}: ParentNavigationSummaryParams) => `De ${reportName}${workspaceName ? ` en ${workspaceName}` : ''}`,
},
qrCodes: {
copy: 'Copiar URL',
@@ -2727,7 +2727,6 @@ export default {
address: 'Dirección',
waypointDescription: {
start: 'Comienzo',
- finish: 'Final',
stop: 'Parada',
},
mapPending: {
diff --git a/src/languages/types.ts b/src/languages/types.ts
index bb56d5c38cd3..975dd8fd1570 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -109,7 +109,7 @@ type RequestAmountParams = {amount: string};
type RequestedAmountMessageParams = {formattedAmount: string; comment?: string};
-type SplitAmountParams = {amount: number};
+type SplitAmountParams = {amount: string | number};
type DidSplitAmountMessageParams = {formattedAmount: string; comment: string};
@@ -195,7 +195,7 @@ type OOOEventSummaryFullDayParams = {summary: string; dayCount: number; date: st
type OOOEventSummaryPartialDayParams = {summary: string; timePeriod: string; date: string};
-type ParentNavigationSummaryParams = {rootReportName?: string; workspaceName?: string};
+type ParentNavigationSummaryParams = {reportName?: string; workspaceName?: string};
type SetTheRequestParams = {valueName: string; newValueToDisplay: string};
diff --git a/src/libs/API/parameters/AddCommentOrAttachementParams.ts b/src/libs/API/parameters/AddCommentOrAttachementParams.ts
index 58faf9fdfc9c..a705c92f7f27 100644
--- a/src/libs/API/parameters/AddCommentOrAttachementParams.ts
+++ b/src/libs/API/parameters/AddCommentOrAttachementParams.ts
@@ -1,9 +1,11 @@
+import type {FileObject} from '@components/AttachmentModal';
+
type AddCommentOrAttachementParams = {
reportID: string;
reportActionID?: string;
commentReportActionID?: string | null;
reportComment?: string;
- file?: File;
+ file?: FileObject;
timezone?: string;
shouldAllowActionableMentionWhispers?: boolean;
clientCreatedTime?: string;
diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts
index 94bba5d0d00c..f2e940abeb73 100644
--- a/src/libs/ComposerUtils/index.ts
+++ b/src/libs/ComposerUtils/index.ts
@@ -50,3 +50,4 @@ function findCommonSuffixLength(str1: string, str2: string, cursorPosition: numb
}
export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys, insertWhiteSpaceAtIndex, findCommonSuffixLength};
+export type {Selection};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index d3514a110314..6da5c8af1ff2 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -265,7 +265,7 @@ function formatToLongDateWithWeekday(datetime: string | Date): string {
*
* @returns Sunday
*/
-function formatToDayOfWeek(datetime: string): string {
+function formatToDayOfWeek(datetime: Date): string {
return format(new Date(datetime), CONST.DATE.WEEKDAY_TIME_FORMAT);
}
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index a42cb6a8f756..155a167f322e 100644
--- a/src/libs/DistanceRequestUtils.ts
+++ b/src/libs/DistanceRequestUtils.ts
@@ -1,17 +1,11 @@
import type {OnyxEntry} from 'react-native-onyx';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
import CONST from '@src/CONST';
-import type {Unit} from '@src/types/onyx/Policy';
+import type {MileageRate, Unit} from '@src/types/onyx/Policy';
import type Policy from '@src/types/onyx/Policy';
import * as CurrencyUtils from './CurrencyUtils';
import * as PolicyUtils from './PolicyUtils';
-type DefaultMileageRate = {
- rate?: number;
- currency?: string;
- unit: Unit;
-};
-
/**
* Retrieves the default mileage rate based on a given policy.
*
@@ -22,7 +16,7 @@ type DefaultMileageRate = {
* @returns [currency] - The currency associated with the rate.
* @returns [unit] - The unit of measurement for the distance.
*/
-function getDefaultMileageRate(policy: OnyxEntry): DefaultMileageRate | null {
+function getDefaultMileageRate(policy: OnyxEntry): MileageRate | null {
if (!policy?.customUnits) {
return null;
}
@@ -39,7 +33,7 @@ function getDefaultMileageRate(policy: OnyxEntry): DefaultMileageRate |
return {
rate: distanceRate.rate,
- currency: distanceRate.currency,
+ currency: distanceRate.currency ?? 'USD',
unit: distanceUnit.attributes.unit,
};
}
diff --git a/src/libs/E2E/client.ts b/src/libs/E2E/client.ts
index 265c55c4a230..f76bdf2ed9a5 100644
--- a/src/libs/E2E/client.ts
+++ b/src/libs/E2E/client.ts
@@ -3,10 +3,19 @@ import Routes from '../../../tests/e2e/server/routes';
import type {NetworkCacheMap, TestConfig} from './types';
type TestResult = {
+ /** Name of the test */
name: string;
+
+ /** The branch where test were running */
branch?: string;
+
+ /** Duration in milliseconds */
duration?: number;
+
+ /** Optional, if set indicates that the test run failed and has no valid results. */
error?: string;
+
+ /** Render count */
renderCount?: number;
};
@@ -113,3 +122,4 @@ export default {
updateNetworkCache,
getNetworkCache,
};
+export type {TestResult, NativeCommand};
diff --git a/src/libs/E2E/tests/chatOpeningTest.e2e.ts b/src/libs/E2E/tests/chatOpeningTest.e2e.ts
index ef380f847c3f..17d9dfa1cb4d 100644
--- a/src/libs/E2E/tests/chatOpeningTest.e2e.ts
+++ b/src/libs/E2E/tests/chatOpeningTest.e2e.ts
@@ -1,14 +1,14 @@
+import type {NativeConfig} from 'react-native-config';
import E2ELogin from '@libs/E2E/actions/e2eLogin';
import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
import E2EClient from '@libs/E2E/client';
-import type {TestConfig} from '@libs/E2E/types';
import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow';
import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
-const test = (config: TestConfig) => {
+const test = (config: NativeConfig) => {
// check for login (if already logged in the action will simply resolve)
console.debug('[E2E] Logging in for chat opening');
diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.ts b/src/libs/E2E/tests/reportTypingTest.e2e.ts
index 4e0678aeb020..817bda941611 100644
--- a/src/libs/E2E/tests/reportTypingTest.e2e.ts
+++ b/src/libs/E2E/tests/reportTypingTest.e2e.ts
@@ -1,9 +1,9 @@
+import type {NativeConfig} from 'react-native-config';
import Config from 'react-native-config';
import E2ELogin from '@libs/E2E/actions/e2eLogin';
import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
import waitForKeyboard from '@libs/E2E/actions/waitForKeyboard';
import E2EClient from '@libs/E2E/client';
-import type {TestConfig} from '@libs/E2E/types';
import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow';
import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
@@ -12,7 +12,7 @@ import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction';
-const test = (config: TestConfig) => {
+const test = (config: NativeConfig) => {
// check for login (if already logged in the action will simply resolve)
console.debug('[E2E] Logging in for typing');
diff --git a/src/libs/E2E/types.ts b/src/libs/E2E/types.ts
index 2d48813fa115..93640fbb4ce8 100644
--- a/src/libs/E2E/types.ts
+++ b/src/libs/E2E/types.ts
@@ -20,7 +20,7 @@ type NetworkCacheMap = Record<
type TestConfig = {
name: string;
- [key: string]: string;
+ [key: string]: string | {autoFocus: boolean};
};
export type {SigninParams, IsE2ETestSession, NetworkCacheMap, NetworkCacheEntry, TestConfig};
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts
index 730f2a5d3295..cab0f48d75fd 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.ts
@@ -10,7 +10,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {FrequentlyUsedEmoji, Locale} from '@src/types/onyx';
import type {ReportActionReaction, UsersReactions} from '@src/types/onyx/ReportActionReactions';
import type IconAsset from '@src/types/utils/IconAsset';
-import type {SupportedLanguage} from './EmojiTrie';
type HeaderIndice = {code: string; index: number; icon: IconAsset};
type EmojiSpacer = {code: string; spacer: boolean};
@@ -384,7 +383,7 @@ function replaceAndExtractEmojis(text: string, preferredSkinTone: number = CONST
* Suggest emojis when typing emojis prefix after colon
* @param [limit] - matching emojis limit
*/
-function suggestEmojis(text: string, lang: keyof SupportedLanguage, limit = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS): Emoji[] | undefined {
+function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS): Emoji[] | undefined {
// emojisTrie is importing the emoji JSON file on the app starting and we want to avoid it
const emojisTrie = require('./EmojiTrie').default;
diff --git a/src/libs/Navigation/dismissModal.ts b/src/libs/Navigation/dismissModal.ts
index 484008d2e070..1f46f5c37193 100644
--- a/src/libs/Navigation/dismissModal.ts
+++ b/src/libs/Navigation/dismissModal.ts
@@ -25,6 +25,7 @@ function dismissModal(navigationRef: NavigationContainerRef)
case NAVIGATORS.RIGHT_MODAL_NAVIGATOR:
case SCREENS.NOT_FOUND:
case SCREENS.REPORT_ATTACHMENTS:
+ case SCREENS.CONCIERGE:
navigationRef.dispatch({...StackActions.pop(), target: state.key});
break;
default: {
diff --git a/src/libs/Navigation/dismissModalWithReport.ts b/src/libs/Navigation/dismissModalWithReport.ts
index b708819cdf9c..4fc82f76df86 100644
--- a/src/libs/Navigation/dismissModalWithReport.ts
+++ b/src/libs/Navigation/dismissModalWithReport.ts
@@ -38,6 +38,7 @@ function dismissModalWithReport(targetReport: Report | EmptyObject, navigationRe
case NAVIGATORS.RIGHT_MODAL_NAVIGATOR:
case SCREENS.NOT_FOUND:
case SCREENS.REPORT_ATTACHMENTS:
+ case SCREENS.CONCIERGE:
// If we are not in the target report, we need to navigate to it after dismissing the modal
if (targetReport.reportID !== getTopmostReportId(state)) {
const reportState = getStateFromPath(ROUTES.REPORT_WITH_ID.getRoute(targetReport.reportID));
diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts
index f03c34b1696e..3c003ab03590 100644
--- a/src/libs/NextStepUtils.ts
+++ b/src/libs/NextStepUtils.ts
@@ -1,5 +1,6 @@
import {format, lastDayOfMonth, setDate} from 'date-fns';
import Str from 'expensify-common/lib/str';
+import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
@@ -63,7 +64,7 @@ type BuildNextStepParameters = {
* @returns nextStep
*/
function buildNextStep(
- report: Report | EmptyObject,
+ report: OnyxEntry | EmptyObject,
predictedNextStatus: ValueOf,
{isPaidWithExpensify}: BuildNextStepParameters = {},
): ReportNextStep | null {
@@ -71,13 +72,13 @@ function buildNextStep(
return null;
}
- const {policyID = '', ownerAccountID = -1, managerID = -1} = report;
+ const {policyID = '', ownerAccountID = -1, managerID = -1} = report ?? {};
const policy = ReportUtils.getPolicy(policyID);
const {submitsTo, harvesting, isPreventSelfApprovalEnabled, preventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy;
const isOwner = currentUserAccountID === ownerAccountID;
const isManager = currentUserAccountID === managerID;
const isSelfApproval = currentUserAccountID === submitsTo;
- const ownerLogin = PersonalDetailsUtils.getLoginsByAccountIDs([ownerAccountID])[0] ?? '';
+ const ownerLogin = PersonalDetailsUtils.getLoginsByAccountIDs([report?.ownerAccountID ?? -1])[0] ?? '';
const managerDisplayName = isSelfApproval ? 'you' : ReportUtils.getDisplayNameForParticipant(submitsTo) ?? '';
const type: ReportNextStep['type'] = 'neutral';
let optimisticNextStep: ReportNextStep | null;
diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts
index 60e5246f5ed2..62d6fa00906a 100644
--- a/src/libs/NumberUtils.ts
+++ b/src/libs/NumberUtils.ts
@@ -76,4 +76,20 @@ function roundDownToLargestMultiple(p: number, q: number) {
return Math.floor(p / q) * q;
}
-export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple};
+/**
+ * Rounds a number to two decimal places.
+ * @returns the rounded value
+ */
+function roundToTwoDecimalPlaces(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+/**
+ * Clamps a value between a minimum and maximum value.
+ * @returns the clamped value
+ */
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple, roundToTwoDecimalPlaces, clamp};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 79e67164a15a..06472765d98e 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -31,6 +31,7 @@ import type {
import type {Participant} from '@src/types/onyx/IOU';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
+import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import times from '@src/utils/times';
import Timing from './actions/Timing';
@@ -1732,7 +1733,7 @@ function getShareLogOptions(reports: OnyxCollection, personalDetails: On
/**
* Build the IOUConfirmation options for showing the payee personalDetail
*/
-function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: PersonalDetails, amountText: string): PayeePersonalDetails {
+function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: PersonalDetails | EmptyObject, amountText: string): PayeePersonalDetails {
const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetail.login ?? '');
return {
text: PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail, formattedLogin),
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index cbb6119e1238..07e54f0f8cc8 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -1,3 +1,4 @@
+import {format} from 'date-fns';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import Str from 'expensify-common/lib/str';
import {isEmpty} from 'lodash';
@@ -8,6 +9,7 @@ import lodashIsEqual from 'lodash/isEqual';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
+import type {FileObject} from '@components/AttachmentModal';
import * as Expensicons from '@components/Icon/Expensicons';
import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvatars';
import CONST from '@src/CONST';
@@ -100,11 +102,6 @@ type SpendBreakdown = {
type ParticipantDetails = [number, string, UserUtils.AvatarSource, UserUtils.AvatarSource];
-type ReportAndWorkspaceName = {
- rootReportName: string;
- workspaceName?: string;
-};
-
type OptimisticAddCommentReportAction = Pick<
ReportAction,
| 'reportActionID'
@@ -1329,7 +1326,7 @@ function getRoomWelcomeMessage(report: OnyxEntry, isUserPolicyAdmin: boo
/**
* Returns true if Concierge is one of the chat participants (1:1 as well as group chats)
*/
-function chatIncludesConcierge(report: OnyxEntry): boolean {
+function chatIncludesConcierge(report: Partial>): boolean {
return Boolean(report?.participantAccountIDs?.length && report?.participantAccountIDs?.includes(CONST.ACCOUNT_ID.CONCIERGE));
}
@@ -1702,6 +1699,14 @@ function getDisplayNamesWithTooltips(
});
}
+/**
+ * Returns the the display names of the given user accountIDs
+ */
+function getUserDetailTooltipText(accountID: number, fallbackUserDisplayName = ''): string {
+ const displayNameForParticipant = getDisplayNameForParticipant(accountID);
+ return displayNameForParticipant || fallbackUserDisplayName;
+}
+
/**
* For a deleted parent report action within a chat report,
* let us return the appropriate display message
@@ -1983,6 +1988,13 @@ function getFormulaTypeReportField(reportFields: PolicyReportFields) {
return Object.values(reportFields).find((field) => field.type === 'formula');
}
+/**
+ * Given a set of report fields, return the field that refers to title
+ */
+function getTitleReportField(reportFields: PolicyReportFields) {
+ return Object.values(reportFields).find((field) => isReportFieldOfTypeTitle(field));
+}
+
/**
* Get the report fields attached to the policy given policyID
*/
@@ -2563,39 +2575,6 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu
return participantsWithoutCurrentUser.map((accountID) => getDisplayNameForParticipant(accountID, isMultipleParticipantReport)).join(', ');
}
-/**
- * Recursively navigates through thread parents to get the root report and workspace name.
- * The recursion stops when we find a non thread or money request report, whichever comes first.
- */
-function getRootReportAndWorkspaceName(report: OnyxEntry): ReportAndWorkspaceName {
- if (!report) {
- return {
- rootReportName: '',
- };
- }
- if (isChildReport(report) && !isMoneyRequestReport(report) && !isTaskReport(report)) {
- const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null;
- return getRootReportAndWorkspaceName(parentReport);
- }
-
- if (isIOURequest(report)) {
- return {
- rootReportName: getReportName(report),
- };
- }
- if (isExpenseRequest(report)) {
- return {
- rootReportName: getReportName(report),
- workspaceName: isIOUReport(report) ? CONST.POLICY.OWNER_EMAIL_FAKE : getPolicyName(report, true),
- };
- }
-
- return {
- rootReportName: getReportName(report),
- workspaceName: getPolicyName(report, true),
- };
-}
-
/**
* Get either the policyName or domainName the chat is tied to
*/
@@ -2623,16 +2602,15 @@ function getChatRoomSubtitle(report: OnyxEntry): string | undefined {
* Gets the parent navigation subtitle for the report
*/
function getParentNavigationSubtitle(report: OnyxEntry): ParentNavigationSummaryParams {
- if (isThread(report)) {
- const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null;
- const {rootReportName, workspaceName} = getRootReportAndWorkspaceName(parentReport);
- if (!rootReportName) {
- return {};
- }
-
- return {rootReportName, workspaceName};
+ const parentReport = getParentReport(report);
+ if (isEmptyObject(parentReport)) {
+ return {};
}
- return {};
+
+ return {
+ reportName: getReportName(parentReport),
+ workspaceName: getPolicyName(parentReport, true),
+ };
}
/**
@@ -2723,7 +2701,7 @@ function getPolicyDescriptionText(policy: Policy): string {
return parser.htmlToText(policy.description);
}
-function buildOptimisticAddCommentReportAction(text?: string, file?: File, actorAccountID?: number): OptimisticReportAction {
+function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, actorAccountID?: number): OptimisticReportAction {
const parser = new ExpensiMark();
const commentText = getParsedComment(text ?? '');
const isAttachment = !text && file !== undefined;
@@ -2909,6 +2887,38 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number
};
}
+function getHumanReadableStatus(statusNum: number): string {
+ const status = Object.keys(CONST.REPORT.STATUS_NUM).find((key) => CONST.REPORT.STATUS_NUM[key as keyof typeof CONST.REPORT.STATUS_NUM] === statusNum);
+ return status ? `${status.charAt(0)}${status.slice(1).toLowerCase()}` : '';
+}
+
+/**
+ * Populates the report field formula with the values from the report and policy.
+ * Currently, this only supports optimistic expense reports.
+ * Each formula field is either replaced with a value, or removed.
+ * If after all replacements the formula is empty, the original formula is returned.
+ * See {@link https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates}
+ */
+function populateOptimisticReportFormula(formula: string, report: OptimisticExpenseReport, policy: Policy | EmptyObject): string {
+ const createdDate = report.lastVisibleActionCreated ? new Date(report.lastVisibleActionCreated) : undefined;
+ const result = formula
+ .replaceAll('{report:id}', report.reportID)
+ // We don't translate because the server response is always in English
+ .replaceAll('{report:type}', 'Expense Report')
+ .replaceAll('{report:startdate}', createdDate ? format(createdDate, CONST.DATE.FNS_FORMAT_STRING) : '')
+ .replaceAll('{report:total}', report.total?.toString() ?? '')
+ .replaceAll('{report:currency}', report.currency ?? '')
+ .replaceAll('{report:policyname}', policy.name ?? '')
+ .replaceAll('{report:created}', createdDate ? format(createdDate, CONST.DATE.FNS_DATE_TIME_FORMAT_STRING) : '')
+ .replaceAll('{report:created:yyyy-MM-dd}', createdDate ? format(createdDate, CONST.DATE.FNS_FORMAT_STRING) : '')
+ .replaceAll('{report:status}', report.statusNum !== undefined ? getHumanReadableStatus(report.statusNum) : '')
+ .replaceAll('{user:email}', currentUserEmail ?? '')
+ .replaceAll('{user:email|frontPart}', currentUserEmail ? currentUserEmail.split('@')[0] : '')
+ .replaceAll(/\{report:(.+)}/g, '');
+
+ return result.trim().length ? result : formula;
+}
+
/**
* Builds an optimistic Expense report with a randomly generated reportID
*
@@ -2918,7 +2928,6 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number
* @param total - Amount in cents
* @param currency
*/
-
function buildOptimisticExpenseReport(chatReportID: string, policyID: string, payeeAccountID: number, total: number, currency: string): OptimisticExpenseReport {
// The amount for Expense reports are stored as negative value in the database
const storedTotal = total * -1;
@@ -2939,7 +2948,6 @@ function buildOptimisticExpenseReport(chatReportID: string, policyID: string, pa
type: CONST.REPORT.TYPE.EXPENSE,
ownerAccountID: payeeAccountID,
currency,
-
// We don't translate reportName because the server response is always in English
reportName: `${policyName} owes ${formattedTotal}`,
stateNum,
@@ -2955,6 +2963,11 @@ function buildOptimisticExpenseReport(chatReportID: string, policyID: string, pa
expenseReport.managerID = policy.submitsTo;
}
+ const titleReportField = getTitleReportField(getReportFieldsByPolicyID(policyID) ?? {});
+ if (!!titleReportField && reportFieldsEnabled(expenseReport)) {
+ expenseReport.reportName = populateOptimisticReportFormula(titleReportField.defaultValue, expenseReport, policy);
+ }
+
return expenseReport;
}
@@ -5217,6 +5230,7 @@ export {
buildOptimisticUnHoldReportAction,
shouldDisplayThreadReplies,
shouldDisableThread,
+ getUserDetailTooltipText,
doesReportBelongToWorkspace,
getChildReportNotificationPreference,
getAllAncestorReportActions,
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 67e31c610369..2e1a283bc8b8 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -140,11 +140,11 @@ function hasReceipt(transaction: Transaction | undefined | null): boolean {
return !!transaction?.receipt?.state || hasEReceipt(transaction);
}
-function isMerchantMissing(transaction: Transaction) {
- if (transaction.modifiedMerchant && transaction.modifiedMerchant !== '') {
+function isMerchantMissing(transaction: OnyxEntry) {
+ if (transaction?.modifiedMerchant && transaction?.modifiedMerchant !== '') {
return transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
}
- const isMerchantEmpty = transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === '';
+ const isMerchantEmpty = transaction?.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction?.merchant === '';
return isMerchantEmpty;
}
@@ -156,15 +156,15 @@ function isPartialMerchant(merchant: string): boolean {
return merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
}
-function isAmountMissing(transaction: Transaction) {
- return transaction.amount === 0 && (!transaction.modifiedAmount || transaction.modifiedAmount === 0);
+function isAmountMissing(transaction: OnyxEntry) {
+ return transaction?.amount === 0 && (!transaction?.modifiedAmount || transaction?.modifiedAmount === 0);
}
-function isCreatedMissing(transaction: Transaction) {
- return transaction.created === '' && (!transaction.created || transaction.modifiedCreated === '');
+function isCreatedMissing(transaction: OnyxEntry) {
+ return transaction?.created === '' && (!transaction?.created || transaction?.modifiedCreated === '');
}
-function areRequiredFieldsEmpty(transaction: Transaction): boolean {
+function areRequiredFieldsEmpty(transaction: OnyxEntry): boolean {
const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] ?? null;
const isFromExpenseReport = parentReport?.type === CONST.REPORT.TYPE.EXPENSE;
return (isFromExpenseReport && isMerchantMissing(transaction)) || isAmountMissing(transaction) || isCreatedMissing(transaction);
@@ -487,7 +487,7 @@ function hasMissingSmartscanFields(transaction: OnyxEntry): boolean
/**
* Check if the transaction has a defined route
*/
-function hasRoute(transaction: Transaction): boolean {
+function hasRoute(transaction: OnyxEntry): boolean {
return !!transaction?.routes?.route0?.geometry?.coordinates;
}
diff --git a/src/libs/actions/CachedPDFPaths/index.native.ts b/src/libs/actions/CachedPDFPaths/index.native.ts
new file mode 100644
index 000000000000..09203995e9a1
--- /dev/null
+++ b/src/libs/actions/CachedPDFPaths/index.native.ts
@@ -0,0 +1,47 @@
+import {exists, unlink} from 'react-native-fs';
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Add, Clear, ClearAll, ClearByKey} from './types';
+
+/*
+ * We need to save the paths of PDF files so we can delete them later.
+ * This is to remove the cached PDFs when an attachment is deleted or the user logs out.
+ */
+let pdfPaths: Record = {};
+Onyx.connect({
+ key: ONYXKEYS.CACHED_PDF_PATHS,
+ callback: (val) => {
+ pdfPaths = val ?? {};
+ },
+});
+
+const add: Add = (id: string, path: string) => {
+ if (pdfPaths[id]) {
+ return Promise.resolve();
+ }
+ return Onyx.merge(ONYXKEYS.CACHED_PDF_PATHS, {[id]: path});
+};
+
+const clear: Clear = (path: string) => {
+ if (!path) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve) => {
+ exists(path).then((exist) => {
+ if (!exist) {
+ resolve();
+ }
+ return unlink(path);
+ });
+ });
+};
+
+const clearByKey: ClearByKey = (id: string) => {
+ clear(pdfPaths[id] ?? '').then(() => Onyx.merge(ONYXKEYS.CACHED_PDF_PATHS, {[id]: null}));
+};
+
+const clearAll: ClearAll = () => {
+ Promise.all(Object.values(pdfPaths).map(clear)).then(() => Onyx.merge(ONYXKEYS.CACHED_PDF_PATHS, {}));
+};
+
+export {add, clearByKey, clearAll};
diff --git a/src/libs/actions/CachedPDFPaths/index.ts b/src/libs/actions/CachedPDFPaths/index.ts
new file mode 100644
index 000000000000..3cac21bf3c25
--- /dev/null
+++ b/src/libs/actions/CachedPDFPaths/index.ts
@@ -0,0 +1,9 @@
+import type {Add, ClearAll, ClearByKey} from './types';
+
+const add: Add = () => Promise.resolve();
+
+const clearByKey: ClearByKey = () => {};
+
+const clearAll: ClearAll = () => {};
+
+export {add, clearByKey, clearAll};
diff --git a/src/libs/actions/CachedPDFPaths/types.ts b/src/libs/actions/CachedPDFPaths/types.ts
new file mode 100644
index 000000000000..98b768c4645e
--- /dev/null
+++ b/src/libs/actions/CachedPDFPaths/types.ts
@@ -0,0 +1,6 @@
+type Add = (id: string, path: string) => Promise;
+type Clear = (path: string) => Promise;
+type ClearAll = () => void;
+type ClearByKey = (id: string) => void;
+
+export type {Add, Clear, ClearAll, ClearByKey};
diff --git a/src/libs/actions/EmojiPickerAction.ts b/src/libs/actions/EmojiPickerAction.ts
index 0fc21713c608..c63e1c13d29a 100644
--- a/src/libs/actions/EmojiPickerAction.ts
+++ b/src/libs/actions/EmojiPickerAction.ts
@@ -68,7 +68,7 @@ function showEmojiPicker(
/**
* Hide the Emoji Picker modal.
*/
-function hideEmojiPicker(isNavigating: boolean) {
+function hideEmojiPicker(isNavigating?: boolean) {
if (!emojiPickerRef.current) {
return;
}
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 8dea073622b4..0e6f8b0293c6 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -1,3 +1,4 @@
+import type {ParamListBase, StackNavigationState} from '@react-navigation/native';
import type {StackScreenProps} from '@react-navigation/stack';
import {format} from 'date-fns';
import fastMerge from 'expensify-common/lib/fastMerge';
@@ -43,7 +44,7 @@ import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUR
import * as TransactionUtils from '@libs/TransactionUtils';
import * as UserUtils from '@libs/UserUtils';
import ViolationsUtils from '@libs/Violations/ViolationsUtils';
-import type {MoneyRequestNavigatorParamList} from '@navigation/types';
+import type {MoneyRequestNavigatorParamList, NavigationPartialRoute} from '@navigation/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -57,6 +58,7 @@ import type {OnyxData} from '@src/types/onyx/Request';
import type {Comment, Receipt, ReceiptSource, TaxRate, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import * as CachedPDFPaths from './CachedPDFPaths';
import * as Policy from './Policy';
import * as Report from './Report';
@@ -260,6 +262,28 @@ function clearMoneyRequest(transactionID: string) {
Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null);
}
+/**
+ * Update money request-related pages IOU type params
+ */
+function updateMoneyRequestTypeParams(routes: StackNavigationState['routes'] | NavigationPartialRoute[], newIouType: string, tab: string) {
+ routes.forEach((route) => {
+ const tabList = [CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN] as string[];
+ if (!route.name.startsWith('Money_Request_') && !tabList.includes(route.name)) {
+ return;
+ }
+ const newParams: Record = {iouType: newIouType};
+ if (route.name === 'Money_Request_Create') {
+ // Both screen and nested params are needed to properly update the nested tab navigator
+ newParams.params = {...newParams};
+ newParams.screen = tab;
+ }
+ Navigation.setParams(newParams, route.key ?? '');
+
+ // Recursively update nested money request tab params
+ updateMoneyRequestTypeParams(route.state?.routes ?? [], newIouType, tab);
+ });
+}
+
// eslint-disable-next-line @typescript-eslint/naming-convention
function startMoneyRequest_temporaryForRefactor(iouType: ValueOf, reportID: string) {
clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID);
@@ -361,9 +385,25 @@ function getReceiptError(receipt?: Receipt, filename?: string, isScanRequest = t
: ErrorUtils.getMicroSecondOnyxErrorObject({error: CONST.IOU.RECEIPT_ERROR, source: receipt.source?.toString() ?? '', filename: filename ?? ''});
}
-/** Return the object to update hasOutstandingChildRequest */
-function getOutstandingChildRequest(needsToBeManuallySubmitted: boolean, policy: OnyxEntry | EmptyObject = null): OutstandingChildRequest {
- if (!needsToBeManuallySubmitted) {
+function needsToBeManuallySubmitted(iouReport: OnyxTypes.Report) {
+ const isPolicyExpenseChat = ReportUtils.isExpenseReport(iouReport);
+
+ if (isPolicyExpenseChat) {
+ const policy = ReportUtils.getPolicy(iouReport.policyID);
+ const isFromPaidPolicy = PolicyUtils.isPaidGroupPolicy(policy);
+
+ // If the scheduled submit is turned off on the policy, user needs to manually submit the report which is indicated by GBR in LHN
+ return isFromPaidPolicy && !policy.harvesting?.enabled;
+ }
+
+ return true;
+}
+
+/**
+ * Return the object to update hasOutstandingChildRequest
+ */
+function getOutstandingChildRequest(policy: OnyxEntry | EmptyObject, iouReport: OnyxTypes.Report): OutstandingChildRequest {
+ if (!needsToBeManuallySubmitted(iouReport)) {
return {
hasOutstandingChildRequest: false,
};
@@ -375,8 +415,9 @@ function getOutstandingChildRequest(needsToBeManuallySubmitted: boolean, policy:
};
}
- // We don't need to update hasOutstandingChildRequest in this case
- return {};
+ return {
+ hasOutstandingChildRequest: iouReport.managerID === userAccountID && iouReport.total !== 0,
+ };
}
/** Builds the Onyx data for a money request */
@@ -399,10 +440,9 @@ function buildOnyxDataForMoneyRequest(
policyTagList?: OnyxEntry,
policyCategories?: OnyxEntry,
optimisticNextStep?: OnyxTypes.ReportNextStep | null,
- needsToBeManuallySubmitted = true,
): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] {
const isScanRequest = TransactionUtils.isScanRequest(transaction);
- const outstandingChildRequest = getOutstandingChildRequest(needsToBeManuallySubmitted, policy);
+ const outstandingChildRequest = getOutstandingChildRequest(policy ?? {}, iouReport);
const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null]));
const optimisticData: OnyxUpdate[] = [];
@@ -795,15 +835,10 @@ function getMoneyRequestInformation(
iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null;
}
- // Check if the Scheduled Submit is enabled in case of expense report
- let needsToBeManuallySubmitted = true;
let isFromPaidPolicy = false;
if (isPolicyExpenseChat) {
isFromPaidPolicy = PolicyUtils.isPaidGroupPolicy(policy ?? null);
- // If the scheduled submit is turned off on the policy, user needs to manually submit the report which is indicated by GBR in LHN
- needsToBeManuallySubmitted = isFromPaidPolicy && !policy?.harvesting?.enabled;
-
// If the linked expense report on paid policy is not draft and not instantly submitted, we need to create a new draft expense report
if (iouReport && isFromPaidPolicy && !ReportUtils.isDraftExpenseReport(iouReport) && !ReportUtils.isExpenseReportWithInstantSubmittedState(iouReport)) {
iouReport = null;
@@ -943,7 +978,6 @@ function getMoneyRequestInformation(
policyTagList,
policyCategories,
optimisticNextStep,
- needsToBeManuallySubmitted,
);
return {
@@ -1210,11 +1244,21 @@ function getUpdateMoneyRequestParams(
}
updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency);
- optimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
- value: updatedMoneyRequestReport,
- });
+ optimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
+ value: updatedMoneyRequestReport,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.parentReportID}`,
+ value: {
+ hasOutstandingChildRequest:
+ iouReport && needsToBeManuallySubmitted(iouReport) && updatedMoneyRequestReport.managerID === userAccountID && updatedMoneyRequestReport.total !== 0,
+ },
+ },
+ );
successData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
@@ -2995,6 +3039,13 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor
[reportPreviewAction?.reportActionID ?? '']: updatedReportPreviewAction,
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`,
+ value: {
+ hasOutstandingChildRequest: iouReport && needsToBeManuallySubmitted(iouReport) && updatedIOUReport?.managerID === userAccountID && updatedIOUReport.total !== 0,
+ },
+ },
);
if (!shouldDeleteIOUReport && updatedReportPreviewAction.childMoneyRequestCount === 0) {
@@ -3139,6 +3190,7 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor
// STEP 6: Make the API request
API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData});
+ CachedPDFPaths.clearByKey(transactionID);
// STEP 7: Navigate the user depending on which page they are on and which resources were deleted
if (iouReport && isSingleTransactionView && shouldDeleteTransactionThread && !shouldDeleteIOUReport) {
@@ -3621,14 +3673,14 @@ function sendMoneyWithWallet(report: OnyxTypes.Report, amount: number, currency:
Report.notifyNewAction(params.chatReportID, managerID);
}
-function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
- const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null;
- const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(expenseReport.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID);
+function approveMoneyRequest(expenseReport: OnyxEntry | EmptyObject) {
+ const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`] ?? null;
+ const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(expenseReport?.total ?? 0, expenseReport?.currency ?? '', expenseReport?.reportID ?? '');
const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.APPROVED);
const optimisticReportActionsData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`,
value: {
[optimisticApprovedReportAction.reportActionID]: {
...(optimisticApprovedReportAction as OnyxTypes.ReportAction),
@@ -3638,7 +3690,7 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
};
const optimisticIOUReportData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`,
value: {
...expenseReport,
lastMessageText: optimisticApprovedReportAction.message?.[0].text,
@@ -3649,7 +3701,7 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
};
const optimisticNextStepData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`,
value: optimisticNextStep,
};
const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionsData, optimisticNextStepData];
@@ -3657,7 +3709,7 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
const successData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`,
value: {
[optimisticApprovedReportAction.reportActionID]: {
pendingAction: null,
@@ -3669,22 +3721,22 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`,
value: {
- [expenseReport.reportActionID ?? '']: {
+ [expenseReport?.reportActionID ?? '']: {
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'),
},
},
},
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`,
value: currentNextStep,
},
];
const parameters: ApproveMoneyRequestParams = {
- reportID: expenseReport.reportID,
+ reportID: expenseReport?.reportID ?? '',
approvedReportActionID: optimisticApprovedReportAction.reportActionID,
};
@@ -4234,6 +4286,8 @@ export {
initMoneyRequest,
startMoneyRequest_temporaryForRefactor,
resetMoneyRequestInfo,
+ clearMoneyRequest,
+ updateMoneyRequestTypeParams,
setMoneyRequestAmount_temporaryForRefactor,
setMoneyRequestBillable_temporaryForRefactor,
setMoneyRequestCreated,
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index 32da5c4e0def..ce222940c7ca 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -392,6 +392,9 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean) {
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
autoReporting: enabled,
+ harvesting: {
+ enabled: true,
+ },
pendingFields: {isAutoApprovalEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
},
},
@@ -403,7 +406,7 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean) {
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
autoReporting: !enabled,
- pendingFields: {isAutoApprovalEnabled: null},
+ pendingFields: {isAutoApprovalEnabled: null, harvesting: null},
},
},
];
@@ -413,7 +416,7 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
- pendingFields: {isAutoApprovalEnabled: null},
+ pendingFields: {isAutoApprovalEnabled: null, harvesting: null},
},
},
];
@@ -1399,6 +1402,12 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
outputCurrency,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
customUnits,
+ areCategoriesEnabled: true,
+ areTagsEnabled: false,
+ areDistanceRatesEnabled: false,
+ areWorkflowsEnabled: false,
+ areReportFieldsEnabled: false,
+ areConnectionsEnabled: false,
},
},
{
@@ -1800,6 +1809,12 @@ function createWorkspaceFromIOUPayment(iouReport: Report | EmptyObject): string
outputCurrency: CONST.CURRENCY.USD,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
customUnits,
+ areCategoriesEnabled: true,
+ areTagsEnabled: false,
+ areDistanceRatesEnabled: false,
+ areWorkflowsEnabled: false,
+ areReportFieldsEnabled: false,
+ areConnectionsEnabled: false,
};
const optimisticData: OnyxUpdate[] = [
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 6142077004d4..5617db450a6c 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -7,6 +7,7 @@ import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-nat
import Onyx from 'react-native-onyx';
import type {PartialDeep, ValueOf} from 'type-fest';
import type {Emoji} from '@assets/emojis/types';
+import type {FileObject} from '@components/AttachmentModal';
import * as ActiveClientManager from '@libs/ActiveClientManager';
import * as API from '@libs/API';
import type {
@@ -76,6 +77,7 @@ import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/Rep
import type ReportAction from '@src/types/onyx/ReportAction';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import * as CachedPDFPaths from './CachedPDFPaths';
import * as Modal from './Modal';
import * as Session from './Session';
import * as Welcome from './Welcome';
@@ -355,7 +357,7 @@ function notifyNewAction(reportID: string, accountID?: number, reportActionID?:
* - Adding one attachment
* - Add both a comment and attachment simultaneously
*/
-function addActions(reportID: string, text = '', file?: File) {
+function addActions(reportID: string, text = '', file?: FileObject) {
let reportCommentText = '';
let reportCommentAction: OptimisticAddCommentReportAction | undefined;
let attachmentAction: OptimisticAddCommentReportAction | undefined;
@@ -514,7 +516,7 @@ function addActions(reportID: string, text = '', file?: File) {
}
/** Add an attachment and optional comment. */
-function addAttachment(reportID: string, file: File, text = '') {
+function addAttachment(reportID: string, file: FileObject, text = '') {
addActions(reportID, text, file);
}
@@ -1223,6 +1225,7 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) {
reportActionID,
};
+ CachedPDFPaths.clearByKey(reportActionID);
API.write(WRITE_COMMANDS.DELETE_COMMENT, parameters, {optimisticData, successData, failureData});
}
@@ -1719,7 +1722,7 @@ function updateWriteCapabilityAndNavigate(report: Report, newValue: WriteCapabil
/**
* Navigates to the 1:1 report with Concierge
*/
-function navigateToConciergeChat(shouldDismissModal = false, shouldPopCurrentScreen = false, checkIfCurrentPageActive = () => true) {
+function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageActive = () => true) {
// If conciergeChatReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID.
// Otherwise, we would find the concierge chat and navigate to it.
if (!conciergeChatReportID) {
@@ -1730,17 +1733,11 @@ function navigateToConciergeChat(shouldDismissModal = false, shouldPopCurrentScr
if (!checkIfCurrentPageActive()) {
return;
}
- if (shouldPopCurrentScreen && !shouldDismissModal) {
- Navigation.goBack();
- }
navigateToAndOpenReport([CONST.EMAIL.CONCIERGE], shouldDismissModal);
});
} else if (shouldDismissModal) {
Navigation.dismissModal(conciergeChatReportID);
} else {
- if (shouldPopCurrentScreen) {
- Navigation.goBack();
- }
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeChatReportID));
}
}
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index d7cef2aca546..5d089ed6e393 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -33,7 +33,7 @@ import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {FrequentlyUsedEmoji} from '@src/types/onyx';
+import type {BlockedFromConcierge, FrequentlyUsedEmoji} from '@src/types/onyx';
import type Login from '@src/types/onyx/Login';
import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer';
import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails';
@@ -47,8 +47,6 @@ import * as OnyxUpdates from './OnyxUpdates';
import * as Report from './Report';
import * as Session from './Session';
-type BlockedFromConciergeNVP = {expiresAt: number};
-
let currentUserAccountID = -1;
let currentEmail = '';
Onyx.connect({
@@ -447,7 +445,7 @@ function validateSecondaryLogin(contactMethod: string, validateCode: string) {
* and if so whether the expiresAt date of a user's ban is before right now
*
*/
-function isBlockedFromConcierge(blockedFromConciergeNVP: OnyxEntry): boolean {
+function isBlockedFromConcierge(blockedFromConciergeNVP: OnyxEntry): boolean {
if (isEmptyObject(blockedFromConciergeNVP)) {
return false;
}
diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx
index 4abf8f0d2033..10040a6c3146 100644
--- a/src/pages/ConciergePage.tsx
+++ b/src/pages/ConciergePage.tsx
@@ -40,7 +40,7 @@ function ConciergePage({session}: ConciergePageProps) {
if (isUnmounted.current) {
return;
}
- Report.navigateToConciergeChat(undefined, true, () => !isUnmounted.current);
+ Report.navigateToConciergeChat(true, () => !isUnmounted.current);
});
} else {
Navigation.navigate();
diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx
index 054f229ac16a..72393e89ae1a 100755
--- a/src/pages/NewChatPage.tsx
+++ b/src/pages/NewChatPage.tsx
@@ -273,6 +273,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF
textInputLabel={translate('optionsSelector.nameEmailOrPhoneNumber')}
safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
isLoadingNewOptions={isSearchingForReports}
+ autoFocus={false}
/>
{isSmallScreenWidth && }
diff --git a/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx b/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx
index 025dcafd9740..9ad19482a4a7 100644
--- a/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx
+++ b/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx
@@ -90,7 +90,7 @@ function PurposeForUsingExpensifyModal() {
}
Report.completeEngagementModal(message, choice);
- Report.navigateToConciergeChat(false, true);
+ Report.navigateToConciergeChat(true);
}, []);
const menuItems: MenuItemProps[] = useMemo(
diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js
index c505758e80f8..0b986adf1c6f 100755
--- a/src/pages/ProfilePage.js
+++ b/src/pages/ProfilePage.js
@@ -7,13 +7,11 @@ import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import AutoUpdateTime from '@components/AutoUpdateTime';
import Avatar from '@components/Avatar';
-import BlockingView from '@components/BlockingViews/BlockingView';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import CommunicationsLink from '@components/CommunicationsLink';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
-import * as Illustrations from '@components/Icon/Illustrations';
import MenuItem from '@components/MenuItem';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -30,7 +28,6 @@ import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
-import variables from '@styles/variables';
import * as PersonalDetails from '@userActions/PersonalDetails';
import * as Report from '@userActions/Report';
import * as Session from '@userActions/Session';
@@ -145,7 +142,7 @@ function ProfilePage(props) {
return (
-
+
Navigation.goBack(navigateBackTo)}
@@ -251,16 +248,6 @@ function ProfilePage(props) {
)}
{!hasMinimumDetails && isLoading && }
- {shouldShowBlockingView && (
-
- )}
diff --git a/src/pages/ReimbursementAccount/ConnectBankAccount/ConnectBankAccount.tsx b/src/pages/ReimbursementAccount/ConnectBankAccount/ConnectBankAccount.tsx
index 1682cb66f7c8..078c216d836c 100644
--- a/src/pages/ReimbursementAccount/ConnectBankAccount/ConnectBankAccount.tsx
+++ b/src/pages/ReimbursementAccount/ConnectBankAccount/ConnectBankAccount.tsx
@@ -39,7 +39,7 @@ function ConnectBankAccount({reimbursementAccount, onBackButtonPress, account, p
const styles = useThemeStyles();
const {translate} = useLocalize();
- const handleNavigateToConciergeChat = () => Report.navigateToConciergeChat(false, true);
+ const handleNavigateToConciergeChat = () => Report.navigateToConciergeChat(true);
const bankAccountState = reimbursementAccount.achData?.state ?? '';
// If a user tries to navigate directly to the validate page we'll show them the EnableStep
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
index 2287b1ef5971..9855090e70d1 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
@@ -469,7 +469,7 @@ function ReimbursementAccountPage({reimbursementAccount, route, onfidoToken, pol
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
+ onBackButtonPress={() => Navigation.goBack()}
/>
{errorText}
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 7fba188dcedd..da5a8e4aae27 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -561,6 +561,7 @@ function ReportScreen({
getParticipantLocalTime(participant, preferredLocale));
useEffect(() => {
@@ -44,7 +43,8 @@ function ParticipantLocalTime(props) {
};
}, [participant, preferredLocale]);
- const reportRecipientDisplayName = lodashGet(props, 'participant.firstName') || lodashGet(props, 'participant.displayName');
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null
+ const reportRecipientDisplayName = participant.firstName || participant.displayName;
if (!reportRecipientDisplayName) {
return null;
@@ -65,7 +65,6 @@ function ParticipantLocalTime(props) {
);
}
-ParticipantLocalTime.propTypes = propTypes;
ParticipantLocalTime.displayName = 'ParticipantLocalTime';
-export default withLocalize(ParticipantLocalTime);
+export default ParticipantLocalTime;
diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
similarity index 81%
rename from src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
rename to src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
index 72727168cad6..68c7f0883683 100644
--- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
+++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
@@ -1,112 +1,94 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import {useIsFocused} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo} from 'react';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {ValueOf} from 'type-fest';
+import type {FileObject} from '@components/AttachmentModal';
import AttachmentPicker from '@components/AttachmentPicker';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
+import type {PopoverMenuItem} from '@components/PopoverMenu';
import PopoverMenu from '@components/PopoverMenu';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Tooltip from '@components/Tooltip/PopoverAnchorTooltip';
-import withNavigationFocus from '@components/withNavigationFocus';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
-import compose from '@libs/compose';
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 type * as OnyxTypes from '@src/types/onyx';
-const propTypes = {
- /** The report currently being looked at */
- report: PropTypes.shape({
- /** ID of the report */
- reportID: PropTypes.string,
-
- /** Whether or not the report is in the process of being created */
- loading: PropTypes.bool,
- }).isRequired,
+type MoneyRequestOptions = Record, PopoverMenuItem>;
+type AttachmentPickerWithMenuItemsOnyxProps = {
/** The policy tied to the report */
- policy: PropTypes.shape({
- /** Type of the policy */
- type: PropTypes.string,
- }),
+ policy: OnyxEntry;
+};
- /** The personal details of everyone in the report */
- reportParticipantIDs: PropTypes.arrayOf(PropTypes.number),
+type AttachmentPickerWithMenuItemsProps = AttachmentPickerWithMenuItemsOnyxProps & {
+ /** The report currently being looked at */
+ report: OnyxEntry;
/** Callback to open the file in the modal */
- displayFileInModal: PropTypes.func.isRequired,
+ displayFileInModal: (url: FileObject) => void;
/** Whether or not the full size composer is available */
- isFullComposerAvailable: PropTypes.bool.isRequired,
+ isFullComposerAvailable: boolean;
/** Whether or not the composer is full size */
- isComposerFullSize: PropTypes.bool.isRequired,
+ isComposerFullSize: boolean;
/** Whether or not the user is blocked from concierge */
- isBlockedFromConcierge: PropTypes.bool.isRequired,
+ isBlockedFromConcierge: boolean;
/** Whether or not the attachment picker is disabled */
- disabled: PropTypes.bool,
+ disabled?: boolean;
/** Sets the menu visibility */
- setMenuVisibility: PropTypes.func.isRequired,
+ setMenuVisibility: (isVisible: boolean) => void;
/** Whether or not the menu is visible */
- isMenuVisible: PropTypes.bool.isRequired,
+ isMenuVisible: boolean;
/** Report ID */
- reportID: PropTypes.string.isRequired,
+ reportID: string;
/** Called when opening the attachment picker */
- onTriggerAttachmentPicker: PropTypes.func.isRequired,
+ onTriggerAttachmentPicker: () => void;
/** Called when cancelling the attachment picker */
- onCanceledAttachmentPicker: PropTypes.func.isRequired,
+ onCanceledAttachmentPicker: () => void;
/** Called when the menu with the items is closed after it was open */
- onMenuClosed: PropTypes.func.isRequired,
+ onMenuClosed: () => void;
/** Called when the add action button is pressed */
- onAddActionPressed: PropTypes.func.isRequired,
+ onAddActionPressed: () => void;
/** Called when the menu item is selected */
- onItemSelected: PropTypes.func.isRequired,
+ onItemSelected: () => void;
/** A ref for the add action button */
- actionButtonRef: PropTypes.shape({
- // eslint-disable-next-line react/forbid-prop-types
- current: PropTypes.object,
- }).isRequired,
-
- /** Whether or not the screen is focused */
- isFocused: PropTypes.bool.isRequired,
+ actionButtonRef: React.RefObject;
/** A function that toggles isScrollLikelyLayoutTriggered flag for a certain period of time */
- raiseIsScrollLikelyLayoutTriggered: PropTypes.func.isRequired,
-};
+ raiseIsScrollLikelyLayoutTriggered: () => void;
-const defaultProps = {
- reportParticipantIDs: [],
- disabled: false,
- policy: {},
+ /** The personal details of everyone in the report */
+ reportParticipantIDs?: number[];
};
/**
* This includes the popover of options you see when pressing the + button in the composer.
* It also contains the attachment picker, as the menu items need to be able to open it.
- *
- * @returns {React.Component}
*/
function AttachmentPickerWithMenuItems({
report,
@@ -126,9 +108,9 @@ function AttachmentPickerWithMenuItems({
onAddActionPressed,
onItemSelected,
actionButtonRef,
- isFocused,
raiseIsScrollLikelyLayoutTriggered,
-}) {
+}: AttachmentPickerWithMenuItemsProps) {
+ const isFocused = useIsFocused();
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -136,37 +118,35 @@ function AttachmentPickerWithMenuItems({
/**
* Returns the list of IOU Options
- * @returns {Array}
*/
const moneyRequestOptions = useMemo(() => {
- const options = {
+ const options: MoneyRequestOptions = {
[CONST.IOU.TYPE.SPLIT]: {
icon: Expensicons.Receipt,
text: translate('iou.splitBill'),
- onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report.reportID),
+ onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report?.reportID ?? ''),
},
[CONST.IOU.TYPE.REQUEST]: {
icon: Expensicons.MoneyCircle,
text: translate('iou.requestMoney'),
- onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report.reportID),
+ onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report?.reportID ?? ''),
},
[CONST.IOU.TYPE.SEND]: {
icon: Expensicons.Send,
text: translate('iou.sendMoney'),
- onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report.reportID),
+ onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report?.reportID),
},
};
- return _.map(ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs), (option) => ({
+ return ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs ?? []).map((option) => ({
...options[option],
}));
}, [report, policy, reportParticipantIDs, translate]);
/**
* Determines if we can show the task option
- * @returns {Boolean}
*/
- const taskOption = useMemo(() => {
+ const taskOption: PopoverMenuItem[] = useMemo(() => {
if (!ReportUtils.canCreateTaskInReport(report)) {
return [];
}
@@ -205,6 +185,7 @@ function AttachmentPickerWithMenuItems({
return (
+ {/* @ts-expect-error TODO: Remove this once AttachmentPicker (https://github.com/Expensify/App/issues/25134) is migrated to TypeScript. */}
{({openPicker}) => {
const triggerAttachmentPicker = () => {
onTriggerAttachmentPicker();
@@ -234,7 +215,7 @@ function AttachmentPickerWithMenuItems({
{
- e.preventDefault();
+ e?.preventDefault();
raiseIsScrollLikelyLayoutTriggered();
Report.setIsComposerFullSize(reportID, false);
}}
@@ -256,7 +237,7 @@ function AttachmentPickerWithMenuItems({
{
- e.preventDefault();
+ e?.preventDefault();
raiseIsScrollLikelyLayoutTriggered();
Report.setIsComposerFullSize(reportID, true);
}}
@@ -278,14 +259,14 @@ function AttachmentPickerWithMenuItems({
{
- e.preventDefault();
+ e?.preventDefault();
if (!isFocused) {
return;
}
onAddActionPressed();
// Drop focus to avoid blue focus ring.
- actionButtonRef.current.blur();
+ actionButtonRef.current?.blur();
setMenuVisibility(!isMenuVisible);
}}
style={styles.composerSizeButton}
@@ -328,15 +309,10 @@ function AttachmentPickerWithMenuItems({
);
}
-AttachmentPickerWithMenuItems.propTypes = propTypes;
-AttachmentPickerWithMenuItems.defaultProps = defaultProps;
AttachmentPickerWithMenuItems.displayName = 'AttachmentPickerWithMenuItems';
-export default compose(
- withNavigationFocus,
- withOnyx({
- policy: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${lodashGet(report, 'policyID')}`,
- },
- }),
-)(AttachmentPickerWithMenuItems);
+export default withOnyx({
+ policy: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`,
+ },
+})(AttachmentPickerWithMenuItems);
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
similarity index 65%
rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index 026df340040e..af2d0b9eab56 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -1,11 +1,24 @@
import {useIsFocused, useNavigation} from '@react-navigation/native';
-import lodashGet from 'lodash/get';
-import React, {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
+import lodashDebounce from 'lodash/debounce';
+import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react';
+import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
+import type {
+ LayoutChangeEvent,
+ MeasureInWindowOnSuccessCallback,
+ NativeSyntheticEvent,
+ TextInput,
+ TextInputFocusEventData,
+ TextInputKeyPressEventData,
+ TextInputSelectionChangeEventData,
+} from 'react-native';
import {findNodeHandle, InteractionManager, NativeModules, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {useAnimatedRef} from 'react-native-reanimated';
+import type {Emoji} from '@assets/emojis/types';
+import type {FileObject} from '@components/AttachmentModal';
import Composer from '@components/Composer';
-import withKeyboardState from '@components/withKeyboardState';
+import useKeyboardState from '@hooks/useKeyboardState';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -14,7 +27,6 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
-import compose from '@libs/compose';
import * as ComposerUtils from '@libs/ComposerUtils';
import getDraftComment from '@libs/ComposerUtils/getDraftComment';
import convertToLTRForComposer from '@libs/convertToLTRForComposer';
@@ -28,6 +40,7 @@ import * as ReportUtils from '@libs/ReportUtils';
import * as SuggestionUtils from '@libs/SuggestionUtils';
import updateMultilineInputRange from '@libs/updateMultilineInputRange';
import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside';
+import type {ComposerRef, SuggestionsRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose';
import SilentCommentUpdater from '@pages/home/report/ReportActionCompose/SilentCommentUpdater';
import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions';
import * as EmojiPickerActions from '@userActions/EmojiPickerAction';
@@ -36,7 +49,128 @@ import * as Report from '@userActions/Report';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import {defaultProps, propTypes} from './composerWithSuggestionsProps';
+import type * as OnyxTypes from '@src/types/onyx';
+import type ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type SyncSelection = {
+ position: number;
+ value: string;
+};
+
+type AnimatedRef = ReturnType;
+
+type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string};
+
+type ComposerWithSuggestionsOnyxProps = {
+ /** The number of lines the comment should take up */
+ numberOfLines: OnyxEntry;
+
+ /** The parent report actions for the report */
+ parentReportActions: OnyxEntry;
+
+ /** The modal state */
+ modal: OnyxEntry;
+
+ /** The preferred skin tone of the user */
+ preferredSkinTone: number;
+
+ /** Whether the input is focused */
+ editFocused: OnyxEntry;
+};
+
+type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps &
+ Partial & {
+ /** Report ID */
+ reportID: string;
+
+ /** Callback to focus composer */
+ onFocus: () => void;
+
+ /** Callback to blur composer */
+ onBlur: (event: NativeSyntheticEvent) => void;
+
+ /** Callback to update the value of the composer */
+ onValueChange: (value: string) => void;
+
+ /** Whether the composer is full size */
+ isComposerFullSize: boolean;
+
+ /** Whether the menu is visible */
+ isMenuVisible: boolean;
+
+ /** The placeholder for the input */
+ inputPlaceholder: string;
+
+ /** Function to display a file in a modal */
+ displayFileInModal: (file: FileObject) => void;
+
+ /** Whether the text input should clear */
+ textInputShouldClear: boolean;
+
+ /** Function to set the text input should clear */
+ setTextInputShouldClear: (shouldClear: boolean) => void;
+
+ /** Whether the user is blocked from concierge */
+ isBlockedFromConcierge: boolean;
+
+ /** Whether the input is disabled */
+ disabled: boolean;
+
+ /** Whether the full composer is available */
+ isFullComposerAvailable: boolean;
+
+ /** Function to set whether the full composer is available */
+ setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
+
+ /** Function to set whether the comment is empty */
+ setIsCommentEmpty: (isCommentEmpty: boolean) => void;
+
+ /** Function to handle sending a message */
+ handleSendMessage: () => void;
+
+ /** Whether the compose input should show */
+ shouldShowComposeInput: OnyxEntry;
+
+ /** Function to measure the parent container */
+ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
+
+ /** The height of the list */
+ listHeight: number;
+
+ /** Whether the scroll is likely to trigger a layout */
+ isScrollLikelyLayoutTriggered: RefObject;
+
+ /** Function to raise the scroll is likely layout triggered */
+ raiseIsScrollLikelyLayoutTriggered: () => void;
+
+ /** The ref to the suggestions */
+ suggestionsRef: React.RefObject;
+
+ /** The ref to the animated input */
+ animatedRef: AnimatedRef;
+
+ /** The ref to the next modal will open */
+ isNextModalWillOpenRef: MutableRefObject;
+
+ /** Whether the edit is focused */
+ editFocused: boolean;
+
+ /** Wheater chat is empty */
+ isEmptyChat?: boolean;
+
+ /** The last report action */
+ lastReportAction?: OnyxTypes.ReportAction;
+
+ /** Whether to include chronos */
+ includeChronos?: boolean;
+
+ /** The parent report action ID */
+ parentReportActionID?: string;
+
+ /** The parent report ID */
+ // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC
+ parentReportID: string | undefined;
+ };
const {RNTextInputReset} = NativeModules;
@@ -44,9 +178,8 @@ const isIOSNative = getPlatform() === CONST.PLATFORM.IOS;
/**
* Broadcast that the user is typing. Debounced to limit how often we publish client events.
- * @param {String} reportID
*/
-const debouncedBroadcastUserIsTyping = _.debounce((reportID) => {
+const debouncedBroadcastUserIsTyping = lodashDebounce((reportID: string) => {
Report.broadcastUserIsTyping(reportID);
}, 100);
@@ -61,63 +194,66 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
* If a component really needs access to these state values it should be put here.
* However, double check if the component really needs access, as it will re-render
* on every key press.
- * @param {Object} props
- * @returns {React.Component}
*/
-function ComposerWithSuggestions({
- // Onyx
- modal,
- preferredSkinTone,
- parentReportActions,
- numberOfLines,
- // HOCs
- isKeyboardShown,
- // Props: Report
- reportID,
- includeChronos,
- isEmptyChat,
- lastReportAction,
- parentReportActionID,
- // Focus
- onFocus,
- onBlur,
- onValueChange,
- // Composer
- isComposerFullSize,
- isMenuVisible,
- inputPlaceholder,
- displayFileInModal,
- textInputShouldClear,
- setTextInputShouldClear,
- isBlockedFromConcierge,
- disabled,
- isFullComposerAvailable,
- setIsFullComposerAvailable,
- setIsCommentEmpty,
- handleSendMessage,
- shouldShowComposeInput,
- measureParentContainer,
- listHeight,
- isScrollLikelyLayoutTriggered,
- raiseIsScrollLikelyLayoutTriggered,
- // Refs
- suggestionsRef,
- animatedRef,
- forwardedRef,
- isNextModalWillOpenRef,
- editFocused,
- // For testing
- children,
-}) {
+function ComposerWithSuggestions(
+ {
+ // Onyx
+ modal,
+ preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
+ parentReportActions,
+ numberOfLines,
+
+ // Props: Report
+ reportID,
+ includeChronos,
+ isEmptyChat,
+ lastReportAction,
+ parentReportActionID,
+
+ // Focus
+ onFocus,
+ onBlur,
+ onValueChange,
+
+ // Composer
+ isComposerFullSize,
+ isMenuVisible,
+ inputPlaceholder,
+ displayFileInModal,
+ textInputShouldClear,
+ setTextInputShouldClear,
+ isBlockedFromConcierge,
+ disabled,
+ isFullComposerAvailable,
+ setIsFullComposerAvailable,
+ setIsCommentEmpty,
+ handleSendMessage,
+ shouldShowComposeInput,
+ measureParentContainer = () => {},
+ listHeight,
+ isScrollLikelyLayoutTriggered,
+ raiseIsScrollLikelyLayoutTriggered,
+
+ // Refs
+ suggestionsRef,
+ animatedRef,
+ isNextModalWillOpenRef,
+ editFocused,
+
+ // For testing
+ children,
+ }: ComposerWithSuggestionsProps,
+ ref: ForwardedRef,
+) {
+ const {isKeyboardShown} = useKeyboardState();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {preferredLocale} = useLocalize();
const isFocused = useIsFocused();
const navigation = useNavigation();
- const emojisPresentBefore = useRef([]);
-
- const draftComment = getDraftComment(reportID) || '';
+ const emojisPresentBefore = useRef([]);
+ const draftComment = getDraftComment(reportID) ?? '';
const [value, setValue] = useState(() => {
if (draftComment) {
emojisPresentBefore.current = EmojiUtils.extractEmojis(draftComment);
@@ -130,9 +266,9 @@ function ComposerWithSuggestions({
const {isSmallScreenWidth} = useWindowDimensions();
const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
- const parentReportAction = lodashGet(parentReportActions, [parentReportActionID]);
+ const parentReportAction = parentReportActions?.[parentReportActionID ?? ''] ?? null;
const shouldAutoFocus =
- !modal.isVisible && isFocused && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && shouldShowComposeInput;
+ !modal?.isVisible && isFocused && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && shouldShowComposeInput;
const valueRef = useRef(value);
valueRef.current = value;
@@ -141,14 +277,14 @@ function ComposerWithSuggestions({
const [composerHeight, setComposerHeight] = useState(0);
- const textInputRef = useRef(null);
- const insertedEmojisRef = useRef([]);
+ const textInputRef = useRef(null);
+ const insertedEmojisRef = useRef([]);
- const syncSelectionWithOnChangeTextRef = useRef(null);
+ const syncSelectionWithOnChangeTextRef = useRef(null);
- const suggestions = lodashGet(suggestionsRef, 'current.getSuggestions', () => [])();
+ const suggestions = suggestionsRef.current?.getSuggestions() ?? [];
- const hasEnoughSpaceForLargeSuggestion = SuggestionUtils.hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, suggestions.length);
+ const hasEnoughSpaceForLargeSuggestion = SuggestionUtils.hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, suggestions?.length ?? 0);
const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion);
@@ -163,15 +299,13 @@ function ComposerWithSuggestions({
/**
* Set the TextInput Ref
- *
- * @param {Element} el
- * @memberof ReportActionCompose
*/
const setTextInputRef = useCallback(
- (el) => {
+ (el: TextInput) => {
+ // @ts-expect-error need to reassign this ref
ReportActionComposeFocusManager.composerRef.current = el;
textInputRef.current = el;
- if (_.isFunction(animatedRef)) {
+ if (typeof animatedRef === 'function') {
animatedRef(el);
}
},
@@ -187,7 +321,7 @@ function ComposerWithSuggestions({
const debouncedSaveReportComment = useMemo(
() =>
- _.debounce((selectedReportID, newComment) => {
+ lodashDebounce((selectedReportID, newComment) => {
Report.saveReportComment(selectedReportID, newComment || '');
}, 1000),
[],
@@ -196,15 +330,15 @@ function ComposerWithSuggestions({
/**
* Find the newly added characters between the previous text and the new text based on the selection.
*
- * @param {string} prevText - The previous text.
- * @param {string} newText - The new text.
- * @returns {object} An object containing information about the newly added characters.
- * @property {number} startIndex - The start index of the newly added characters in the new text.
- * @property {number} endIndex - The end index of the newly added characters in the new text.
- * @property {string} diff - The newly added characters.
+ * @param prevText - The previous text.
+ * @param newText - The new text.
+ * @returns An object containing information about the newly added characters.
+ * @property startIndex - The start index of the newly added characters in the new text.
+ * @property endIndex - The end index of the newly added characters in the new text.
+ * @property diff - The newly added characters.
*/
const findNewlyAddedChars = useCallback(
- (prevText, newText) => {
+ (prevText: string, newText: string): NewlyAddedChars => {
let startIndex = -1;
let endIndex = -1;
let currentIndex = 0;
@@ -224,7 +358,6 @@ function ComposerWithSuggestions({
endIndex = currentIndex + newText.length;
}
}
-
return {
startIndex,
endIndex,
@@ -236,12 +369,9 @@ function ComposerWithSuggestions({
/**
* Update the value of the comment in Onyx
- *
- * @param {String} comment
- * @param {Boolean} shouldDebounceSaveComment
*/
const updateComment = useCallback(
- (commentValue, shouldDebounceSaveComment) => {
+ (commentValue: string, shouldDebounceSaveComment?: boolean) => {
raiseIsScrollLikelyLayoutTriggered();
const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue);
const isEmojiInserted = diff.length && endIndex > startIndex && diff.trim() === diff && EmojiUtils.containsOnlyEmojis(diff);
@@ -250,9 +380,9 @@ function ComposerWithSuggestions({
emojis,
cursorPosition,
} = EmojiUtils.replaceAndExtractEmojis(isEmojiInserted ? ComposerUtils.insertWhiteSpaceAtIndex(commentValue, endIndex) : commentValue, preferredSkinTone, preferredLocale);
- if (!_.isEmpty(emojis)) {
+ if (emojis.length) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
- if (!_.isEmpty(newEmojis)) {
+ if (newEmojis.length) {
// Ensure emoji suggestions are hidden after inserting emoji even when the selection is not changed
if (suggestionsRef.current) {
suggestionsRef.current.resetSuggestions();
@@ -272,7 +402,7 @@ function ComposerWithSuggestions({
emojisPresentBefore.current = emojis;
setValue(newCommentConverted);
if (commentValue !== newComment) {
- const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition || 0);
+ const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition ?? 0);
if (isIOSNative) {
syncSelectionWithOnChangeTextRef.current = {position, value: newComment};
@@ -320,10 +450,9 @@ function ComposerWithSuggestions({
/**
* Update the number of lines for a comment in Onyx
- * @param {Number} numberOfLines
*/
const updateNumberOfLines = useCallback(
- (newNumberOfLines) => {
+ (newNumberOfLines: number) => {
if (newNumberOfLines === numberOfLines) {
return;
}
@@ -332,10 +461,7 @@ function ComposerWithSuggestions({
[reportID, numberOfLines],
);
- /**
- * @returns {String}
- */
- const prepareCommentAndResetComposer = useCallback(() => {
+ const prepareCommentAndResetComposer = useCallback((): string => {
const trimmedComment = commentRef.current.trim();
const commentLength = ReportUtils.getCommentLength(trimmedComment);
@@ -360,37 +486,45 @@ function ComposerWithSuggestions({
/**
* Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker)
- * @param {String} text
*/
const replaceSelectionWithText = useCallback(
- (text) => {
+ (text: string) => {
updateComment(ComposerUtils.insertText(commentRef.current, selection, text));
},
[selection, updateComment],
);
const triggerHotkeyActions = useCallback(
- (e) => {
- if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) {
+ (event: NativeSyntheticEvent) => {
+ const webEvent = event as unknown as KeyboardEvent;
+ if (!webEvent || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) {
return;
}
- if (suggestionsRef.current.triggerHotkeyActions(e)) {
+ if (suggestionsRef.current?.triggerHotkeyActions(webEvent)) {
return;
}
// Submit the form when Enter is pressed
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) {
- e.preventDefault();
+ if (webEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !webEvent.shiftKey) {
+ webEvent.preventDefault();
handleSendMessage();
}
// Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants
const valueLength = valueRef.current.length;
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !includeChronos) {
- e.preventDefault();
+ if (
+ 'key' in event &&
+ event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey &&
+ textInputRef.current &&
+ 'selectionStart' in textInputRef.current &&
+ textInputRef.current?.selectionStart === 0 &&
+ valueLength === 0 &&
+ !includeChronos
+ ) {
+ event.preventDefault();
if (lastReportAction) {
- Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html);
+ Report.saveReportActionDraft(reportID, lastReportAction, lastReportAction.message?.at(-1)?.html ?? '');
}
}
},
@@ -398,7 +532,7 @@ function ComposerWithSuggestions({
);
const onChangeText = useCallback(
- (commentValue) => {
+ (commentValue: string) => {
updateComment(commentValue, true);
if (isIOSNative && syncSelectionWithOnChangeTextRef.current) {
@@ -409,7 +543,7 @@ function ComposerWithSuggestions({
InteractionManager.runAfterInteractions(() => {
// note: this implementation is only available on non-web RN, thus the wrapping
// 'if' block contains a redundant (since the ref is only used on iOS) platform check
- textInputRef.current.setSelection(positionSnapshot, positionSnapshot);
+ textInputRef.current?.setSelection(positionSnapshot, positionSnapshot);
});
}
},
@@ -417,8 +551,8 @@ function ComposerWithSuggestions({
);
const onSelectionChange = useCallback(
- (e) => {
- if (textInputRef.current && textInputRef.current.isFocused() && suggestionsRef.current.onSelectionChange(e)) {
+ (e: NativeSyntheticEvent) => {
+ if (textInputRef.current?.isFocused() && suggestionsRef.current?.onSelectionChange?.(e)) {
return;
}
@@ -444,8 +578,7 @@ function ComposerWithSuggestions({
/**
* Focus the composer text input
- * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer
- * @memberof ReportActionCompose
+ * @param [shouldDelay=false] Impose delay before focusing the composer
*/
const focus = useCallback((shouldDelay = false) => {
focusComposerWithDelay(textInputRef.current)(shouldDelay);
@@ -468,12 +601,12 @@ function ComposerWithSuggestions({
*/
const checkComposerVisibility = useCallback(() => {
// Checking whether the screen is focused or not, helps avoid `modal.isVisible` false when popups are closed, even if the modal is opened.
- const isComposerCoveredUp = !isFocused || EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible || modal.willAlertModalBecomeVisible;
+ const isComposerCoveredUp = !isFocused || EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || !!modal?.isVisible || modal?.willAlertModalBecomeVisible;
return !isComposerCoveredUp;
}, [isMenuVisible, modal, isFocused]);
const focusComposerOnKeyPress = useCallback(
- (e) => {
+ (e: KeyboardEvent) => {
const isComposerVisible = checkComposerVisibility();
if (!isComposerVisible) {
return;
@@ -484,7 +617,7 @@ function ComposerWithSuggestions({
}
// if we're typing on another input/text area, do not focus
- if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) {
+ if (['INPUT', 'TEXTAREA'].includes((e.target as Element | null)?.nodeName ?? '')) {
return;
}
@@ -519,17 +652,17 @@ function ComposerWithSuggestions({
};
}, [focusComposerOnKeyPress, navigation, setUpComposeFocusManager]);
- const prevIsModalVisible = usePrevious(modal.isVisible);
+ const prevIsModalVisible = usePrevious(modal?.isVisible);
const prevIsFocused = usePrevious(isFocused);
useEffect(() => {
- if (modal.isVisible && !prevIsModalVisible) {
+ if (modal?.isVisible && !prevIsModalVisible) {
// eslint-disable-next-line no-param-reassign
isNextModalWillOpenRef.current = false;
}
// We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused.
// We avoid doing this on native platforms since the software keyboard popping
// open creates a jarring and broken UX.
- if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !modal.isVisible && isFocused && (prevIsModalVisible || !prevIsFocused))) {
+ if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !modal?.isVisible && isFocused && (!!prevIsModalVisible || !prevIsFocused))) {
return;
}
@@ -538,11 +671,11 @@ function ComposerWithSuggestions({
return;
}
focus(true);
- }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef, shouldAutoFocus]);
+ }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal?.isVisible, isNextModalWillOpenRef, shouldAutoFocus]);
useEffect(() => {
// Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit
- updateMultilineInputRange(textInputRef.current, shouldAutoFocus);
+ updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus);
if (value.length === 0) {
return;
@@ -552,15 +685,14 @@ function ComposerWithSuggestions({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
-
useImperativeHandle(
- forwardedRef,
+ ref,
() => ({
blur,
focus,
replaceSelectionWithText,
prepareCommentAndResetComposer,
- isFocused: () => textInputRef.current.isFocused(),
+ isFocused: () => !!textInputRef.current?.isFocused(),
}),
[blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText],
);
@@ -574,7 +706,7 @@ function ComposerWithSuggestions({
}, [onValueChange, value]);
const onLayout = useCallback(
- (e) => {
+ (e: LayoutChangeEvent) => {
const composerLayoutHeight = e.nativeEvent.layout.height;
if (composerHeight === composerLayoutHeight) {
return;
@@ -594,7 +726,7 @@ function ComposerWithSuggestions({
(
-
-));
-
-ComposerWithSuggestionsWithRef.displayName = 'ComposerWithSuggestionsWithRef';
-
-export default compose(
- withKeyboardState,
- withOnyx({
- numberOfLines: {
- key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`,
- // We might not have number of lines in onyx yet, for which the composer would be rendered as null
- // during the first render, which we want to avoid:
- initWithStoredValues: false,
- },
- modal: {
- key: ONYXKEYS.MODAL,
- },
- preferredSkinTone: {
- key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
- selector: EmojiUtils.getPreferredSkinToneIndex,
- },
- editFocused: {
- key: ONYXKEYS.INPUT_FOCUSED,
- },
- parentReportActions: {
- key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
- canEvict: false,
- initWithStoredValues: false,
- },
- }),
-)(memo(ComposerWithSuggestionsWithRef));
+const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions);
+
+export default withOnyx, ComposerWithSuggestionsOnyxProps>({
+ numberOfLines: {
+ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`,
+ // We might not have number of lines in onyx yet, for which the composer would be rendered as null
+ // during the first render, which we want to avoid:
+ initWithStoredValues: false,
+ },
+ modal: {
+ key: ONYXKEYS.MODAL,
+ },
+ preferredSkinTone: {
+ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
+ selector: EmojiUtils.getPreferredSkinToneIndex,
+ },
+ editFocused: {
+ key: ONYXKEYS.INPUT_FOCUSED,
+ },
+ parentReportActions: {
+ key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
+ canEvict: false,
+ initWithStoredValues: false,
+ },
+})(memo(ComposerWithSuggestionsWithRef));
+
+export type {ComposerWithSuggestionsProps};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js
deleted file mode 100644
index 9d05db572949..000000000000
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import PropTypes from 'prop-types';
-import CONST from '@src/CONST';
-
-const propTypes = {
- /** Details about any modals being used */
- modal: PropTypes.shape({
- /** Indicates if there is a modal currently visible or not */
- isVisible: PropTypes.bool,
- }),
-
- /** User's preferred skin tone color */
- preferredSkinTone: PropTypes.number,
-
- /** Number of lines for the composer */
- numberOfLines: PropTypes.number,
-
- /** Whether the keyboard is open or not */
- isKeyboardShown: PropTypes.bool.isRequired,
-
- /** The ID of the report */
- reportID: PropTypes.string.isRequired,
-
- /** Callback when the input is focused */
- onFocus: PropTypes.func.isRequired,
-
- /** Callback when the input is blurred */
- onBlur: PropTypes.func.isRequired,
-
- /** Whether the composer is full size or not */
- isComposerFullSize: PropTypes.bool.isRequired,
-
- /** Whether the menu is visible or not */
- isMenuVisible: PropTypes.bool.isRequired,
-
- /** Placeholder text for the input */
- inputPlaceholder: PropTypes.string.isRequired,
-
- /** Function to display a file in the modal */
- displayFileInModal: PropTypes.func.isRequired,
-
- /** Whether the text input should be cleared or not */
- textInputShouldClear: PropTypes.bool.isRequired,
-
- /** Function to set whether the text input should be cleared or not */
- setTextInputShouldClear: PropTypes.func.isRequired,
-
- /** Whether the user is blocked from concierge or not */
- isBlockedFromConcierge: PropTypes.bool.isRequired,
-
- /** Whether the input is disabled or not */
- disabled: PropTypes.bool,
-
- /** Whether the full composer is available or not */
- isFullComposerAvailable: PropTypes.bool.isRequired,
-
- /** Function to set whether the full composer is available or not */
- setIsFullComposerAvailable: PropTypes.func.isRequired,
-
- /** Function to set whether the comment is empty or not */
- setIsCommentEmpty: PropTypes.func.isRequired,
-
- /** A method to call when the form is submitted */
- handleSendMessage: PropTypes.func.isRequired,
-
- /** Whether the compose input is shown or not */
- shouldShowComposeInput: PropTypes.bool.isRequired,
-
- /** Meaures the parent container's position and dimensions. */
- measureParentContainer: PropTypes.func,
-
- /** Ref for the suggestions component */
- suggestionsRef: PropTypes.shape({
- current: PropTypes.shape({
- /** Update the shouldShowSuggestionMenuToFalse prop */
- updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired,
-
- /** Trigger hotkey actions */
- triggerHotkeyActions: PropTypes.func.isRequired,
-
- /** Check if suggestion calculation should be blocked */
- setShouldBlockSuggestionCalc: PropTypes.func.isRequired,
-
- /** Callback when the selection changes */
- onSelectionChange: PropTypes.func.isRequired,
- }),
- }).isRequired,
-
- /** Ref for the animated view (text input) */
- animatedRef: PropTypes.func.isRequired,
-
- /** Ref for the composer */
- forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
-
- /** Ref for the isNextModalWillOpen */
- isNextModalWillOpenRef: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired,
-
- /** A flag to indicate whether the onScroll callback is likely triggered by a layout change (caused by text change) or not */
- isScrollLikelyLayoutTriggered: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired,
-
- /** A function that toggles isScrollLikelyLayoutTriggered flag for a certain period of time */
- raiseIsScrollLikelyLayoutTriggered: PropTypes.func.isRequired,
-};
-
-const defaultProps = {
- modal: {},
- preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
- numberOfLines: undefined,
- parentReportActions: {},
- reportActions: [],
- forwardedRef: null,
- measureParentContainer: () => {},
- disabled: false,
-};
-
-export {propTypes, defaultProps};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
similarity index 61%
rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js
rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
index cbbd1758c9cb..7f169ef15918 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
@@ -1,6 +1,8 @@
-import _ from 'lodash';
-import React, {useEffect} from 'react';
+import type {ForwardedRef} from 'react';
+import React, {forwardRef, useEffect} from 'react';
import E2EClient from '@libs/E2E/client';
+import type {ComposerRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose';
+import type {ComposerWithSuggestionsProps} from './ComposerWithSuggestions';
import ComposerWithSuggestions from './ComposerWithSuggestions';
let rerenderCount = 0;
@@ -14,20 +16,21 @@ function IncrementRenderCount() {
return null;
}
-const ComposerWithSuggestionsE2e = React.forwardRef((props, ref) => {
+function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: ForwardedRef) {
// Eventually Auto focus on e2e tests
useEffect(() => {
- if (_.get(E2EClient.getCurrentActiveTestConfig(), 'reportScreen.autoFocus', false) === false) {
+ const testConfig = E2EClient.getCurrentActiveTestConfig();
+ if (testConfig?.reportScreen && typeof testConfig.reportScreen !== 'string' && !testConfig?.reportScreen.autoFocus) {
return;
}
// We need to wait for the component to be mounted before focusing
setTimeout(() => {
- if (!ref || !ref.current) {
+ if (!(ref && 'current' in ref)) {
return;
}
- ref.current.focus(true);
+ ref.current?.focus(true);
}, 1);
}, [ref]);
@@ -44,9 +47,9 @@ const ComposerWithSuggestionsE2e = React.forwardRef((props, ref) => {
);
-});
+}
ComposerWithSuggestionsE2e.displayName = 'ComposerWithSuggestionsE2e';
-export default ComposerWithSuggestionsE2e;
+export default forwardRef(ComposerWithSuggestionsE2e);
export {getRerenderCount, resetRerenderCount};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.tsx
similarity index 100%
rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js
rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.tsx
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
similarity index 77%
rename from src/pages/home/report/ReportActionCompose/ReportActionCompose.js
rename to src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
index 4bbf3d393213..1e0e322be258 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
@@ -1,25 +1,29 @@
import {PortalHost} from '@gorhom/portal';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import type {SyntheticEvent} from 'react';
import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated';
-import _ from 'underscore';
+import type {Emoji} from '@assets/emojis/types';
+import type {FileObject} from '@components/AttachmentModal';
import AttachmentModal from '@components/AttachmentModal';
import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton';
import ExceededCommentLength from '@components/ExceededCommentLength';
+import type {Mention} from '@components/MentionSuggestions';
import OfflineIndicator from '@components/OfflineIndicator';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
-import {usePersonalDetails, withNetwork} from '@components/OnyxProvider';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
+import {usePersonalDetails} from '@components/OnyxProvider';
+import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
+import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import useDebounce from '@hooks/useDebounce';
import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
-import compose from '@libs/compose';
import getDraftComment from '@libs/ComposerUtils/getDraftComment';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import getModalState from '@libs/getModalState';
@@ -29,63 +33,59 @@ import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutsi
import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime';
import ReportDropUI from '@pages/home/report/ReportDropUI';
import ReportTypingIndicator from '@pages/home/report/ReportTypingIndicator';
-import reportPropTypes from '@pages/reportPropTypes';
import * as EmojiPickerActions from '@userActions/EmojiPickerAction';
import * as Report from '@userActions/Report';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx';
+import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems';
import ComposerWithSuggestions from './ComposerWithSuggestions';
+import type {ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions';
import SendButton from './SendButton';
-const propTypes = {
- /** A method to call when the form is submitted */
- onSubmit: PropTypes.func.isRequired,
-
- /** The ID of the report actions will be created for */
- reportID: PropTypes.string.isRequired,
-
- /** The report currently being looked at */
- report: reportPropTypes,
-
- /** Is composer full size */
- isComposerFullSize: PropTypes.bool,
-
- /** Whether user interactions should be disabled */
- disabled: PropTypes.bool,
+type ComposerRef = {
+ blur: () => void;
+ focus: (shouldDelay?: boolean) => void;
+ replaceSelectionWithText: (text: string, shouldAddTrailSpace: boolean) => void;
+ prepareCommentAndResetComposer: () => string;
+ isFocused: () => boolean;
+};
- /** Height of the list which the composer is part of */
- listHeight: PropTypes.number,
+type SuggestionsRef = {
+ resetSuggestions: () => void;
+ onSelectionChange?: (event: NativeSyntheticEvent) => void;
+ triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined;
+ updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void;
+ setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void;
+ getSuggestions: () => Mention[] | Emoji[];
+};
- // The NVP describing a user's block status
- blockedFromConcierge: PropTypes.shape({
- // The date that the user will be unblocked
- expiresAt: PropTypes.string,
- }),
+type ReportActionComposeOnyxProps = {
+ /** The NVP describing a user's block status */
+ blockedFromConcierge: OnyxEntry;
/** Whether the composer input should be shown */
- shouldShowComposeInput: PropTypes.bool,
+ shouldShowComposeInput: OnyxEntry;
+};
- /** The type of action that's pending */
- pendingAction: PropTypes.oneOf(['add', 'update', 'delete']),
+type ReportActionComposeProps = ReportActionComposeOnyxProps &
+ WithCurrentUserPersonalDetailsProps &
+ Pick & {
+ /** A method to call when the form is submitted */
+ onSubmit: (newComment: string | undefined) => void;
- /** /** Whetjer the report is ready for display */
- isReportReadyForDisplay: PropTypes.bool,
- ...withCurrentUserPersonalDetailsPropTypes,
-};
+ /** The report currently being looked at */
+ report: OnyxEntry;
-const defaultProps = {
- report: {},
- blockedFromConcierge: {},
- preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
- isComposerFullSize: false,
- pendingAction: null,
- shouldShowComposeInput: true,
- listHeight: 0,
- isReportReadyForDisplay: true,
- ...withCurrentUserPersonalDetailsDefaultProps,
-};
+ /** The type of action that's pending */
+ pendingAction?: OnyxCommon.PendingAction;
+
+ /** Whether the report is ready for display */
+ isReportReadyForDisplay?: boolean;
+ };
// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
// prevent auto focus on existing chat for mobile device
@@ -95,25 +95,25 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc();
function ReportActionCompose({
blockedFromConcierge,
- currentUserPersonalDetails,
+ currentUserPersonalDetails = {},
disabled,
- isComposerFullSize,
- network,
+ isComposerFullSize = false,
onSubmit,
pendingAction,
report,
reportID,
+ listHeight = 0,
+ shouldShowComposeInput = true,
+ isReportReadyForDisplay = true,
isEmptyChat,
lastReportAction,
- listHeight,
- shouldShowComposeInput,
- isReportReadyForDisplay,
-}) {
+}: ReportActionComposeProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isMediumScreenWidth, isSmallScreenWidth} = useWindowDimensions();
+ const {isOffline} = useNetwork();
const animatedRef = useAnimatedRef();
- const actionButtonRef = useRef(null);
+ const actionButtonRef = useRef(null);
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
/**
@@ -121,7 +121,7 @@ function ReportActionCompose({
*/
const [isFocused, setIsFocused] = useState(() => {
const initialModalState = getModalState();
- return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState.isVisible && !initialModalState.willAlertModalBecomeVisible;
+ return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible;
});
const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize);
@@ -166,11 +166,11 @@ function ReportActionCompose({
*/
const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength();
- const suggestionsRef = useRef(null);
- const composerRef = useRef(null);
+ const suggestionsRef = useRef(null);
+ const composerRef = useRef(null);
const reportParticipantIDs = useMemo(
- () => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID),
+ () => report?.participantAccountIDs?.filter((accountID) => accountID !== currentUserPersonalDetails.accountID),
[currentUserPersonalDetails.accountID, report],
);
@@ -179,13 +179,13 @@ function ReportActionCompose({
[personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize],
);
- const includesConcierge = useMemo(() => ReportUtils.chatIncludesConcierge({participantAccountIDs: report.participantAccountIDs}), [report.participantAccountIDs]);
+ const includesConcierge = useMemo(() => ReportUtils.chatIncludesConcierge({participantAccountIDs: report?.participantAccountIDs}), [report?.participantAccountIDs]);
const userBlockedFromConcierge = useMemo(() => User.isBlockedFromConcierge(blockedFromConcierge), [blockedFromConcierge]);
const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]);
// If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions
const conciergePlaceholderRandomIndex = useMemo(
- () => _.random(translate('reportActionCompose.conciergePlaceholderOptions').length - (isSmallScreenWidth ? 4 : 1)),
+ () => Math.floor(Math.random() * (translate('reportActionCompose.conciergePlaceholderOptions').length - (isSmallScreenWidth ? 4 : 1) + 1)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
@@ -204,7 +204,7 @@ function ReportActionCompose({
}, [includesConcierge, translate, userBlockedFromConcierge, conciergePlaceholderRandomIndex]);
const focus = () => {
- if (composerRef === null || composerRef.current === null) {
+ if (composerRef.current === null) {
return;
}
composerRef.current.focus(true);
@@ -220,9 +220,9 @@ function ReportActionCompose({
isKeyboardVisibleWhenShowingModalRef.current = false;
}, []);
- const containerRef = useRef(null);
+ const containerRef = useRef(null);
const measureContainer = useCallback(
- (callback) => {
+ (callback: MeasureInWindowOnSuccessCallback) => {
if (!containerRef.current) {
return;
}
@@ -235,9 +235,9 @@ function ReportActionCompose({
const onAddActionPressed = useCallback(() => {
if (!willBlurTextInputOnTapOutside) {
- isKeyboardVisibleWhenShowingModalRef.current = composerRef.current.isFocused();
+ isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused();
}
- composerRef.current.blur();
+ composerRef.current?.blur();
}, []);
const onItemSelected = useCallback(() => {
@@ -251,13 +251,10 @@ function ReportActionCompose({
suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false);
}, []);
- /**
- * @param {Object} file
- */
const addAttachment = useCallback(
- (file) => {
+ (file: FileObject) => {
playSound(SOUNDS.DONE);
- const newComment = composerRef.current.prepareCommentAndResetComposer();
+ const newComment = composerRef?.current?.prepareCommentAndResetComposer();
Report.addAttachment(reportID, file, newComment);
setTextInputShouldClear(false);
},
@@ -275,16 +272,12 @@ function ReportActionCompose({
/**
* Add a new comment to this chat
- *
- * @param {SyntheticEvent} [e]
*/
const submitForm = useCallback(
- (e) => {
- if (e) {
- e.preventDefault();
- }
+ (event?: SyntheticEvent) => {
+ event?.preventDefault();
- const newComment = composerRef.current.prepareCommentAndResetComposer();
+ const newComment = composerRef.current?.prepareCommentAndResetComposer();
if (!newComment) {
return;
}
@@ -300,12 +293,13 @@ function ReportActionCompose({
isKeyboardVisibleWhenShowingModalRef.current = true;
}, []);
- const onBlur = useCallback((e) => {
+ const onBlur = useCallback((event: NativeSyntheticEvent) => {
+ const webEvent = event as unknown as FocusEvent;
setIsFocused(false);
if (suggestionsRef.current) {
suggestionsRef.current.resetSuggestions();
}
- if (e.relatedTarget && e.relatedTarget === actionButtonRef.current) {
+ if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) {
isKeyboardVisibleWhenShowingModalRef.current = true;
}
}, []);
@@ -326,7 +320,7 @@ function ReportActionCompose({
// We are returning a callback here as we want to incoke the method on unmount only
useEffect(
() => () => {
- if (!EmojiPickerActions.isActive(report.reportID)) {
+ if (!EmojiPickerActions.isActive(report?.reportID ?? '')) {
return;
}
EmojiPickerActions.hideEmojiPicker();
@@ -339,9 +333,9 @@ function ReportActionCompose({
const reportRecipient = personalDetails[reportRecipientAcountIDs[0]];
const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused;
- const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient);
+ const hasReportRecipient = !isEmptyObject(reportRecipient);
- const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength;
+ const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || hasExceededMaxCommentLength;
const handleSendMessage = useCallback(() => {
'worklet';
@@ -365,7 +359,7 @@ function ReportActionCompose({
}, [styles]);
return (
-
+
{shouldShowReportRecipientLocalTime && hasReportRecipient && }
@@ -402,7 +396,7 @@ function ReportActionCompose({
isFullComposerAvailable={isFullComposerAvailable}
isComposerFullSize={isComposerFullSize}
isBlockedFromConcierge={isBlockedFromConcierge}
- disabled={disabled}
+ disabled={!!disabled}
setMenuVisibility={setMenuVisibility}
isMenuVisible={isMenuVisible}
onTriggerAttachmentPicker={onTriggerAttachmentPicker}
@@ -424,9 +418,9 @@ function ReportActionCompose({
isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered}
raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered}
reportID={reportID}
- parentReportID={report.parentReportID}
- parentReportActionID={report.parentReportActionID}
- includesChronos={ReportUtils.chatIncludesChronos(report)}
+ parentReportID={report?.parentReportID}
+ parentReportActionID={report?.parentReportActionID}
+ includeChronos={ReportUtils.chatIncludesChronos(report)}
isEmptyChat={isEmptyChat}
lastReportAction={lastReportAction}
isMenuVisible={isMenuVisible}
@@ -436,7 +430,7 @@ function ReportActionCompose({
textInputShouldClear={textInputShouldClear}
setTextInputShouldClear={setTextInputShouldClear}
isBlockedFromConcierge={isBlockedFromConcierge}
- disabled={disabled}
+ disabled={!!disabled}
isFullComposerAvailable={isFullComposerAvailable}
setIsFullComposerAvailable={setIsFullComposerAvailable}
setIsCommentEmpty={setIsCommentEmpty}
@@ -454,12 +448,12 @@ function ReportActionCompose({
}}
/>
{
+ onDrop={(event: DragEvent) => {
if (isAttachmentPreviewActive) {
return;
}
- const data = lodashGet(e, ['dataTransfer', 'items', 0]);
- displayFileInModal(data);
+ const data = event.dataTransfer?.items[0];
+ displayFileInModal(data as unknown as FileObject);
}}
/>
>
@@ -469,8 +463,9 @@ function ReportActionCompose({
composerRef.current.replaceSelectionWithText(...args)}
- emojiPickerID={report.reportID}
+ // @ts-expect-error TODO: Remove this once EmojiPickerButton (https://github.com/Expensify/App/issues/25155) is migrated to TypeScript.
+ onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)}
+ emojiPickerID={report?.reportID}
shiftVertical={emojiShiftVertical}
/>
)}
@@ -484,7 +479,7 @@ function ReportActionCompose({
styles.flexRow,
styles.justifyContentBetween,
styles.alignItemsCenter,
- (!isSmallScreenWidth || (isSmallScreenWidth && !network.isOffline)) && styles.chatItemComposeSecondaryRow,
+ (!isSmallScreenWidth || (isSmallScreenWidth && !isOffline)) && styles.chatItemComposeSecondaryRow,
]}
>
{!isSmallScreenWidth && }
@@ -497,19 +492,17 @@ function ReportActionCompose({
);
}
-ReportActionCompose.propTypes = propTypes;
-ReportActionCompose.defaultProps = defaultProps;
ReportActionCompose.displayName = 'ReportActionCompose';
-export default compose(
- withNetwork(),
- withCurrentUserPersonalDetails,
- withOnyx({
+export default withCurrentUserPersonalDetails(
+ withOnyx({
blockedFromConcierge: {
key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE,
},
shouldShowComposeInput: {
key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT,
},
- }),
-)(memo(ReportActionCompose));
+ })(memo(ReportActionCompose)),
+);
+
+export type {SuggestionsRef, ComposerRef};
diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.tsx
similarity index 87%
rename from src/pages/home/report/ReportActionCompose/SendButton.js
rename to src/pages/home/report/ReportActionCompose/SendButton.tsx
index e9e3ef244f9c..c505eb0e32e7 100644
--- a/src/pages/home/report/ReportActionCompose/SendButton.js
+++ b/src/pages/home/report/ReportActionCompose/SendButton.tsx
@@ -1,4 +1,3 @@
-import PropTypes from 'prop-types';
import React, {memo} from 'react';
import {View} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
@@ -11,24 +10,22 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
-const propTypes = {
+type SendButtonProps = {
/** Whether the button is disabled */
- isDisabled: PropTypes.bool.isRequired,
+ isDisabled: boolean;
/** Handle clicking on send button */
- handleSendMessage: PropTypes.func.isRequired,
+ handleSendMessage: () => void;
};
-function SendButton({isDisabled: isDisabledProp, handleSendMessage}) {
+function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
- const Tap = Gesture.Tap()
- .enabled()
- .onEnd(() => {
- handleSendMessage();
- });
+ const Tap = Gesture.Tap().onEnd(() => {
+ handleSendMessage();
+ });
return (
{
- updateComment(comment);
+ updateComment(comment ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps -- We need to run this on mount
}, []);
return null;
}
-SilentCommentUpdater.propTypes = propTypes;
-SilentCommentUpdater.defaultProps = defaultProps;
SilentCommentUpdater.displayName = 'SilentCommentUpdater';
-export default withOnyx({
+export default withOnyx({
comment: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
- initialValue: '',
},
})(SilentCommentUpdater);
diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx
similarity index 72%
rename from src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js
rename to src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx
index 23d69ec7defc..1abc6567bc7b 100644
--- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js
+++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx
@@ -1,47 +1,23 @@
-import PropTypes from 'prop-types';
import {useEffect} from 'react';
import {withOnyx} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import ONYXKEYS from '@src/ONYXKEYS';
-
-const propTypes = {
- /** The comment of the report */
- comment: PropTypes.string,
-
- /** The value of the comment */
- value: PropTypes.string.isRequired,
-
- /** The ref of the comment */
- commentRef: PropTypes.shape({
- /** The current value of the comment */
- current: PropTypes.string,
- }).isRequired,
-
- /** Updates the comment */
- updateComment: PropTypes.func.isRequired,
-
- reportID: PropTypes.string.isRequired,
-};
-
-const defaultProps = {
- comment: '',
-};
+import type {SilentCommentUpdaterOnyxProps, SilentCommentUpdaterProps} from './types';
/**
* This component doesn't render anything. It runs a side effect to update the comment of a report under certain conditions.
* It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid
* re-rendering a UI component for that. That's why the side effect was moved down to a separate component.
- * @returns {null}
*/
-function SilentCommentUpdater({comment, commentRef, reportID, value, updateComment}) {
+function SilentCommentUpdater({comment, commentRef, reportID, value, updateComment}: SilentCommentUpdaterProps) {
const prevCommentProp = usePrevious(comment);
const prevReportId = usePrevious(reportID);
const {preferredLocale} = useLocalize();
const prevPreferredLocale = usePrevious(preferredLocale);
useEffect(() => {
- updateComment(comment);
+ updateComment(comment ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps -- We need to run this on mount
}, []);
@@ -56,17 +32,15 @@ function SilentCommentUpdater({comment, commentRef, reportID, value, updateComme
return;
}
- updateComment(comment);
+ updateComment(comment ?? '');
}, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, reportID, updateComment, value, commentRef]);
return null;
}
-SilentCommentUpdater.propTypes = propTypes;
-SilentCommentUpdater.defaultProps = defaultProps;
SilentCommentUpdater.displayName = 'SilentCommentUpdater';
-export default withOnyx({
+export default withOnyx({
comment: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
initialValue: '',
diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts
new file mode 100644
index 000000000000..dbc23b0279c3
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts
@@ -0,0 +1,22 @@
+import type {OnyxEntry} from 'react-native-onyx';
+
+type SilentCommentUpdaterOnyxProps = {
+ /** The comment of the report */
+ comment: OnyxEntry;
+};
+
+type SilentCommentUpdaterProps = SilentCommentUpdaterOnyxProps & {
+ /** Updates the comment */
+ updateComment: (comment: string) => void;
+
+ /** The ID of the report associated with the comment */
+ reportID: string;
+
+ /** The value of the comment */
+ value: string;
+
+ /** The ref of the comment */
+ commentRef: React.RefObject;
+};
+
+export type {SilentCommentUpdaterProps, SilentCommentUpdaterOnyxProps};
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx
similarity index 72%
rename from src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
rename to src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx
index b075740a3f4f..0ae45d2d705d 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx
@@ -1,7 +1,8 @@
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
+import type {ForwardedRef, RefAttributes} from 'react';
+import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
+import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {Emoji} from '@assets/emojis/types';
import EmojiSuggestions from '@components/EmojiSuggestions';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useLocalize from '@hooks/useLocalize';
@@ -9,61 +10,59 @@ import * as EmojiUtils from '@libs/EmojiUtils';
import * as SuggestionsUtils from '@libs/SuggestionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import * as SuggestionProps from './suggestionProps';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import type {SuggestionsRef} from './ReportActionCompose';
+import type {SuggestionProps} from './Suggestions';
+
+type SuggestionsValue = {
+ suggestedEmojis: Emoji[];
+ colonIndex: number;
+ shouldShowSuggestionMenu: boolean;
+};
+
+type SuggestionEmojiOnyxProps = {
+ /** Preferred skin tone */
+ preferredSkinTone: number;
+};
+
+type SuggestionEmojiProps = SuggestionProps &
+ SuggestionEmojiOnyxProps & {
+ /** Function to clear the input */
+ resetKeyboardInput?: () => void;
+ };
/**
* Check if this piece of string looks like an emoji
- * @param {String} str
- * @param {Number} pos
- * @returns {Boolean}
*/
-const isEmojiCode = (str, pos) => {
+const isEmojiCode = (str: string, pos: number): boolean => {
const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI);
- const leftWord = _.last(leftWords);
+ const leftWord = leftWords.at(-1) ?? '';
return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2;
};
-const defaultSuggestionsValues = {
+const defaultSuggestionsValues: SuggestionsValue = {
suggestedEmojis: [],
- colonSignIndex: -1,
+ colonIndex: -1,
shouldShowSuggestionMenu: false,
};
-const propTypes = {
- /** Preferred skin tone */
- preferredSkinTone: PropTypes.number,
-
- /** A ref to this component */
- forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
-
- /** Function to clear the input */
- resetKeyboardInput: PropTypes.func.isRequired,
-
- ...SuggestionProps.baseProps,
-};
-
-const defaultProps = {
- preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
- forwardedRef: null,
-};
-
-function SuggestionEmoji({
- preferredSkinTone,
- value,
- setValue,
- selection,
- setSelection,
- updateComment,
- isComposerFullSize,
- isAutoSuggestionPickerLarge,
- forwardedRef,
- resetKeyboardInput,
- measureParentContainer,
- isComposerFocused,
-}) {
+function SuggestionEmoji(
+ {
+ preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
+ value,
+ selection,
+ setSelection,
+ updateComment,
+ isAutoSuggestionPickerLarge,
+ resetKeyboardInput,
+ measureParentContainer,
+ isComposerFocused,
+ }: SuggestionEmojiProps,
+ ref: ForwardedRef,
+) {
const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
- const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowSuggestionMenu;
+ const isEmojiSuggestionsMenuVisible = suggestionValues.suggestedEmojis.length > 0 && suggestionValues.shouldShowSuggestionMenu;
const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useArrowKeyFocusManager({
isActive: isEmojiSuggestionsMenuVisible,
@@ -81,10 +80,10 @@ function SuggestionEmoji({
* @param {Number} selectedEmoji
*/
const insertSelectedEmoji = useCallback(
- (highlightedEmojiIndexInner) => {
+ (highlightedEmojiIndexInner: number) => {
const commentBeforeColon = value.slice(0, suggestionValues.colonIndex);
const emojiObject = suggestionValues.suggestedEmojis[highlightedEmojiIndexInner];
- const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code;
+ const emojiCode = emojiObject.types?.[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code;
const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end);
updateComment(`${commentBeforeColon}${emojiCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true);
@@ -92,7 +91,7 @@ function SuggestionEmoji({
// In some Android phones keyboard, the text to search for the emoji is not cleared
// will be added after the user starts typing again on the keyboard. This package is
// a workaround to reset the keyboard natively.
- resetKeyboardInput();
+ resetKeyboardInput?.();
setSelection({
start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH,
@@ -121,11 +120,9 @@ function SuggestionEmoji({
/**
* Listens for keyboard shortcuts and applies the action
- *
- * @param {Object} e
*/
const triggerHotkeyActions = useCallback(
- (e) => {
+ (e: KeyboardEvent) => {
const suggestionsExist = suggestionValues.suggestedEmojis.length > 0;
if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) {
@@ -153,7 +150,7 @@ function SuggestionEmoji({
* Calculates and cares about the content of an Emoji Suggester
*/
const calculateEmojiSuggestion = useCallback(
- (selectionEnd) => {
+ (selectionEnd: number) => {
if (shouldBlockCalc.current || !value) {
shouldBlockCalc.current = false;
resetSuggestions();
@@ -163,16 +160,16 @@ function SuggestionEmoji({
const colonIndex = leftString.lastIndexOf(':');
const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd);
- const nextState = {
+ const nextState: SuggestionsValue = {
suggestedEmojis: [],
colonIndex,
shouldShowSuggestionMenu: false,
};
const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale);
- if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) {
+ if (newSuggestedEmojis?.length && isCurrentlyShowingEmojiSuggestion) {
nextState.suggestedEmojis = newSuggestedEmojis;
- nextState.shouldShowSuggestionMenu = !_.isEmpty(newSuggestedEmojis);
+ nextState.shouldShowSuggestionMenu = !isEmptyObject(newSuggestedEmojis);
}
setSuggestionValues((prevState) => ({...prevState, ...nextState}));
@@ -189,7 +186,7 @@ function SuggestionEmoji({
}, [selection, calculateEmojiSuggestion, isComposerFocused]);
const onSelectionChange = useCallback(
- (e) => {
+ (e: NativeSyntheticEvent) => {
/**
* we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion
* because in other case calculateEmojiSuggestion will have an old calculation value
@@ -201,7 +198,7 @@ function SuggestionEmoji({
);
const setShouldBlockSuggestionCalc = useCallback(
- (shouldBlockSuggestionCalc) => {
+ (shouldBlockSuggestionCalc: boolean) => {
shouldBlockCalc.current = shouldBlockSuggestionCalc;
},
[shouldBlockCalc],
@@ -209,12 +206,8 @@ function SuggestionEmoji({
const getSuggestions = useCallback(() => suggestionValues.suggestedEmojis, [suggestionValues]);
- const resetEmojiSuggestions = useCallback(() => {
- setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}));
- }, []);
-
useImperativeHandle(
- forwardedRef,
+ ref,
() => ({
resetSuggestions,
onSelectionChange,
@@ -232,39 +225,22 @@ function SuggestionEmoji({
return (
);
}
-SuggestionEmoji.propTypes = propTypes;
-SuggestionEmoji.defaultProps = defaultProps;
SuggestionEmoji.displayName = 'SuggestionEmoji';
-const SuggestionEmojiWithRef = React.forwardRef((props, ref) => (
-
-));
-
-SuggestionEmojiWithRef.displayName = 'SuggestionEmojiWithRef';
-
-export default withOnyx({
+export default withOnyx, SuggestionEmojiOnyxProps>({
preferredSkinTone: {
key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
selector: EmojiUtils.getPreferredSkinToneIndex,
},
-})(SuggestionEmojiWithRef);
+})(forwardRef(SuggestionEmoji));
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
similarity index 72%
rename from src/pages/home/report/ReportActionCompose/SuggestionMention.js
rename to src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
index b2963aad85c8..5f07dc66ea4d 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
@@ -1,12 +1,13 @@
import Str from 'expensify-common/lib/str';
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
-import _ from 'underscore';
+import lodashSortBy from 'lodash/sortBy';
+import type {ForwardedRef} from 'react';
+import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import * as Expensicons from '@components/Icon/Expensicons';
+import type {Mention} from '@components/MentionSuggestions';
import MentionSuggestions from '@components/MentionSuggestions';
import {usePersonalDetails} from '@components/OnyxProvider';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import * as LoginUtils from '@libs/LoginUtils';
@@ -14,56 +15,40 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as SuggestionsUtils from '@libs/SuggestionUtils';
import * as UserUtils from '@libs/UserUtils';
import CONST from '@src/CONST';
-import * as SuggestionProps from './suggestionProps';
+import type {PersonalDetailsList} from '@src/types/onyx';
+import type {SuggestionsRef} from './ReportActionCompose';
+import type {SuggestionProps} from './Suggestions';
+
+type SuggestionValues = {
+ suggestedMentions: Mention[];
+ atSignIndex: number;
+ shouldShowSuggestionMenu: boolean;
+ mentionPrefix: string;
+};
/**
* Check if this piece of string looks like a mention
- * @param {String} str
- * @returns {Boolean}
*/
-const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str);
+const isMentionCode = (str: string): boolean => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str);
-const defaultSuggestionsValues = {
+const defaultSuggestionsValues: SuggestionValues = {
suggestedMentions: [],
atSignIndex: -1,
shouldShowSuggestionMenu: false,
mentionPrefix: '',
};
-const propTypes = {
- /** A ref to this component */
- forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
-
- ...SuggestionProps.implementationBaseProps,
-
- ...withCurrentUserPersonalDetailsPropTypes,
-};
-
-const defaultProps = {
- forwardedRef: null,
- ...withCurrentUserPersonalDetailsDefaultProps,
-};
-
-function SuggestionMention({
- value,
- setValue,
- selection,
- setSelection,
- isComposerFullSize,
- updateComment,
- composerHeight,
- forwardedRef,
- isAutoSuggestionPickerLarge,
- measureParentContainer,
- isComposerFocused,
- currentUserPersonalDetails,
-}) {
- const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
+function SuggestionMention(
+ {value, selection, setSelection, updateComment, isAutoSuggestionPickerLarge, measureParentContainer, isComposerFocused}: SuggestionProps,
+ ref: ForwardedRef,
+) {
+ const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT;
const {translate, formatPhoneNumber} = useLocalize();
const previousValue = usePrevious(value);
const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
- const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu;
+ const currentUserPersonalDetails = useCurrentUserPersonalDetails();
+ const isMentionSuggestionsMenuVisible = !!suggestionValues.suggestedMentions.length && suggestionValues.shouldShowSuggestionMenu;
const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({
isActive: isMentionSuggestionsMenuVisible,
@@ -75,12 +60,12 @@ function SuggestionMention({
const shouldBlockCalc = useRef(false);
const formatLoginPrivateDomain = useCallback(
- (displayText, userLogin = '') => {
+ (displayText = '', userLogin = '') => {
if (userLogin !== displayText) {
return displayText;
}
// If the emails are not in the same private domain, we also return the displayText
- if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, currentUserPersonalDetails.login)) {
+ if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, currentUserPersonalDetails.login ?? '')) {
return Str.removeSMSDomain(displayText);
}
@@ -92,10 +77,9 @@ function SuggestionMention({
/**
* Replace the code of mention and update selection
- * @param {Number} highlightedMentionIndex
*/
const insertSelectedMention = useCallback(
- (highlightedMentionIndexInner) => {
+ (highlightedMentionIndexInner: number) => {
const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex);
const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner];
const mentionCode =
@@ -126,23 +110,21 @@ function SuggestionMention({
/**
* Listens for keyboard shortcuts and applies the action
- *
- * @param {Object} e
*/
const triggerHotkeyActions = useCallback(
- (e) => {
+ (event: KeyboardEvent) => {
const suggestionsExist = suggestionValues.suggestedMentions.length > 0;
- if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) {
- e.preventDefault();
+ if (((!event.shiftKey && event.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) {
+ event.preventDefault();
if (suggestionValues.suggestedMentions.length > 0) {
insertSelectedMention(highlightedMentionIndex);
return true;
}
}
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) {
- e.preventDefault();
+ if (event.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) {
+ event.preventDefault();
if (suggestionsExist) {
resetSuggestions();
@@ -155,7 +137,7 @@ function SuggestionMention({
);
const getMentionOptions = useCallback(
- (personalDetailsParam, searchValue = '') => {
+ (personalDetailsParam: PersonalDetailsList, searchValue = ''): Mention[] => {
const suggestions = [];
if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) {
@@ -165,15 +147,15 @@ function SuggestionMention({
icons: [
{
source: Expensicons.Megaphone,
- type: 'avatar',
+ type: CONST.ICON_TYPE_AVATAR,
},
],
});
}
- const filteredPersonalDetails = _.filter(_.values(personalDetailsParam), (detail) => {
+ const filteredPersonalDetails = Object.values(personalDetailsParam ?? {}).filter((detail) => {
// If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned
- if (!detail.login || detail.isOptimisticPersonalDetail) {
+ if (!detail?.login || detail.isOptimisticPersonalDetail) {
return false;
}
// We don't want to mention system emails like notifications@expensify.com
@@ -188,18 +170,19 @@ function SuggestionMention({
return true;
});
- const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login);
- _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing cannot be used if left side can be empty string
+ const sortedPersonalDetails = lodashSortBy(filteredPersonalDetails, (detail) => detail?.displayName || detail?.login);
+ sortedPersonalDetails.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length).forEach((detail) => {
suggestions.push({
- text: formatLoginPrivateDomain(PersonalDetailsUtils.getDisplayNameOrDefault(detail), detail.login),
- alternateText: `@${formatLoginPrivateDomain(detail.login, detail.login)}`,
- login: detail.login,
+ text: formatLoginPrivateDomain(PersonalDetailsUtils.getDisplayNameOrDefault(detail), detail?.login),
+ alternateText: `@${formatLoginPrivateDomain(detail?.login, detail?.login)}`,
+ login: detail?.login,
icons: [
{
- name: detail.login,
- source: UserUtils.getAvatar(detail.avatar, detail.accountID),
- type: 'avatar',
- fallbackIcon: detail.fallbackIcon,
+ name: detail?.login,
+ source: UserUtils.getAvatar(detail?.avatar, detail?.accountID),
+ type: CONST.ICON_TYPE_AVATAR,
+ fallbackIcon: detail?.fallbackIcon,
},
],
});
@@ -211,7 +194,7 @@ function SuggestionMention({
);
const calculateMentionSuggestion = useCallback(
- (selectionEnd) => {
+ (selectionEnd: number) => {
if (shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) {
shouldBlockCalc.current = false;
resetSuggestions();
@@ -232,12 +215,12 @@ function SuggestionMention({
const afterLastBreakLineIndex = value.lastIndexOf('\n', selectionEnd - 1) + 1;
const leftString = value.substring(afterLastBreakLineIndex, suggestionEndIndex);
const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI);
- const lastWord = _.last(words);
+ const lastWord: string = words.at(-1) ?? '';
const secondToLastWord = words[words.length - 3];
- let atSignIndex;
- let suggestionWord;
- let prefix;
+ let atSignIndex: number | undefined;
+ let suggestionWord = '';
+ let prefix: string;
// Detect if the last two words contain a mention (two words are needed to detect a mention with a space in it)
if (lastWord.startsWith('@')) {
@@ -254,7 +237,7 @@ function SuggestionMention({
prefix = lastWord.substring(1);
}
- const nextState = {
+ const nextState: Partial = {
suggestedMentions: [],
atSignIndex,
mentionPrefix: prefix,
@@ -266,7 +249,7 @@ function SuggestionMention({
const suggestions = getMentionOptions(personalDetails, prefix);
nextState.suggestedMentions = suggestions;
- nextState.shouldShowSuggestionMenu = !_.isEmpty(suggestions);
+ nextState.shouldShowSuggestionMenu = !!suggestions.length;
}
setSuggestionValues((prevState) => ({
@@ -299,20 +282,16 @@ function SuggestionMention({
}, []);
const setShouldBlockSuggestionCalc = useCallback(
- (shouldBlockSuggestionCalc) => {
+ (shouldBlockSuggestionCalc: boolean) => {
shouldBlockCalc.current = shouldBlockSuggestionCalc;
},
[shouldBlockCalc],
);
- const onClose = useCallback(() => {
- setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []}));
- }, []);
-
const getSuggestions = useCallback(() => suggestionValues.suggestedMentions, [suggestionValues]);
useImperativeHandle(
- forwardedRef,
+ ref,
() => ({
resetSuggestions,
triggerHotkeyActions,
@@ -329,34 +308,16 @@ function SuggestionMention({
return (
);
}
-SuggestionMention.propTypes = propTypes;
-SuggestionMention.defaultProps = defaultProps;
SuggestionMention.displayName = 'SuggestionMention';
-const SuggestionMentionWithRef = React.forwardRef((props, ref) => (
-
-));
-
-SuggestionMentionWithRef.displayName = 'SuggestionMentionWithRef';
-
-export default withCurrentUserPersonalDetails(SuggestionMentionWithRef);
+export default forwardRef(SuggestionMention);
diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js
deleted file mode 100644
index 5dc71fec6419..000000000000
--- a/src/pages/home/report/ReportActionCompose/Suggestions.js
+++ /dev/null
@@ -1,169 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {useCallback, useContext, useEffect, useImperativeHandle, useRef} from 'react';
-import {View} from 'react-native';
-import {DragAndDropContext} from '@components/DragAndDrop/Provider';
-import usePrevious from '@hooks/usePrevious';
-import SuggestionEmoji from './SuggestionEmoji';
-import SuggestionMention from './SuggestionMention';
-import * as SuggestionProps from './suggestionProps';
-
-const propTypes = {
- /** A ref to this component */
- forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
-
- /** Function to clear the input */
- resetKeyboardInput: PropTypes.func.isRequired,
-
- /** Is auto suggestion picker large */
- isAutoSuggestionPickerLarge: PropTypes.bool,
-
- ...SuggestionProps.baseProps,
-};
-
-const defaultProps = {
- forwardedRef: null,
- isAutoSuggestionPickerLarge: true,
-};
-
-/**
- * This component contains the individual suggestion components.
- * If you want to add a new suggestion type, add it here.
- *
- * @returns {React.Component}
- */
-function Suggestions({
- isComposerFullSize,
- value,
- setValue,
- selection,
- setSelection,
- updateComment,
- composerHeight,
- forwardedRef,
- resetKeyboardInput,
- measureParentContainer,
- isAutoSuggestionPickerLarge,
- isComposerFocused,
-}) {
- const suggestionEmojiRef = useRef(null);
- const suggestionMentionRef = useRef(null);
- const {isDraggingOver} = useContext(DragAndDropContext);
- const prevIsDraggingOver = usePrevious(isDraggingOver);
-
- const getSuggestions = useCallback(() => {
- if (suggestionEmojiRef.current && suggestionEmojiRef.current.getSuggestions) {
- const emojiSuggestions = suggestionEmojiRef.current.getSuggestions();
- if (emojiSuggestions.length > 0) {
- return emojiSuggestions;
- }
- }
-
- if (suggestionMentionRef.current && suggestionMentionRef.current.getSuggestions) {
- const mentionSuggestions = suggestionMentionRef.current.getSuggestions();
- if (mentionSuggestions.length > 0) {
- return mentionSuggestions;
- }
- }
-
- return [];
- }, []);
-
- /**
- * Clean data related to EmojiSuggestions
- */
- const resetSuggestions = useCallback(() => {
- suggestionEmojiRef.current.resetSuggestions();
- suggestionMentionRef.current.resetSuggestions();
- }, []);
-
- /**
- * Listens for keyboard shortcuts and applies the action
- *
- * @param {Object} e
- */
- const triggerHotkeyActions = useCallback((e) => {
- const emojiHandler = suggestionEmojiRef.current.triggerHotkeyActions(e);
- const mentionHandler = suggestionMentionRef.current.triggerHotkeyActions(e);
- return emojiHandler || mentionHandler;
- }, []);
-
- const onSelectionChange = useCallback((e) => {
- const emojiHandler = suggestionEmojiRef.current.onSelectionChange(e);
- return emojiHandler;
- }, []);
-
- const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
- suggestionEmojiRef.current.updateShouldShowSuggestionMenuToFalse();
- suggestionMentionRef.current.updateShouldShowSuggestionMenuToFalse();
- }, []);
-
- const setShouldBlockSuggestionCalc = useCallback((shouldBlock) => {
- suggestionEmojiRef.current.setShouldBlockSuggestionCalc(shouldBlock);
- suggestionMentionRef.current.setShouldBlockSuggestionCalc(shouldBlock);
- }, []);
-
- useImperativeHandle(
- forwardedRef,
- () => ({
- resetSuggestions,
- onSelectionChange,
- triggerHotkeyActions,
- updateShouldShowSuggestionMenuToFalse,
- setShouldBlockSuggestionCalc,
- getSuggestions,
- }),
- [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions],
- );
-
- useEffect(() => {
- if (!(!prevIsDraggingOver && isDraggingOver)) {
- return;
- }
- updateShouldShowSuggestionMenuToFalse();
- }, [isDraggingOver, prevIsDraggingOver, updateShouldShowSuggestionMenuToFalse]);
-
- const baseProps = {
- value,
- setValue,
- setSelection,
- selection,
- isComposerFullSize,
- updateComment,
- composerHeight,
- isAutoSuggestionPickerLarge,
- measureParentContainer,
- isComposerFocused,
- };
-
- return (
-
-
-
-
- );
-}
-
-Suggestions.propTypes = propTypes;
-Suggestions.defaultProps = defaultProps;
-Suggestions.displayName = 'Suggestions';
-
-const SuggestionsWithRef = React.forwardRef((props, ref) => (
-
-));
-
-SuggestionsWithRef.displayName = 'SuggestionsWithRef';
-
-export default SuggestionsWithRef;
diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx
new file mode 100644
index 000000000000..61026a792919
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/Suggestions.tsx
@@ -0,0 +1,181 @@
+import type {ForwardedRef} from 'react';
+import React, {forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef} from 'react';
+import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
+import {View} from 'react-native';
+import {DragAndDropContext} from '@components/DragAndDrop/Provider';
+import usePrevious from '@hooks/usePrevious';
+import type {SuggestionsRef} from './ReportActionCompose';
+import SuggestionEmoji from './SuggestionEmoji';
+import SuggestionMention from './SuggestionMention';
+
+type Selection = {
+ start: number;
+ end: number;
+};
+
+type SuggestionProps = {
+ /** The current input value */
+ value: string;
+
+ /** Callback to update the current input value */
+ setValue: (newValue: string) => void;
+
+ /** The current selection value */
+ selection: Selection;
+
+ /** Callback to update the current selection */
+ setSelection: (newSelection: Selection) => void;
+
+ /** Callback to update the comment draft */
+ updateComment: (newComment: string, shouldDebounceSaveComment?: boolean) => void;
+
+ /** Meaures the parent container's position and dimensions. */
+ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
+
+ /** Whether the composer is expanded */
+ isComposerFullSize: boolean;
+
+ /** Report composer focus state */
+ isComposerFocused?: boolean;
+
+ /** Callback to reset the keyboard input */
+ resetKeyboardInput?: () => void;
+
+ /** Whether the auto suggestion picker is large */
+ isAutoSuggestionPickerLarge?: boolean;
+
+ /** The height of the composer */
+ composerHeight?: number;
+};
+
+/**
+ * This component contains the individual suggestion components.
+ * If you want to add a new suggestion type, add it here.
+ *
+ */
+function Suggestions(
+ {
+ isComposerFullSize,
+ value,
+ setValue,
+ selection,
+ setSelection,
+ updateComment,
+ composerHeight,
+ resetKeyboardInput,
+ measureParentContainer,
+ isAutoSuggestionPickerLarge = true,
+ isComposerFocused,
+ }: SuggestionProps,
+ ref: ForwardedRef,
+) {
+ const suggestionEmojiRef = useRef(null);
+ const suggestionMentionRef = useRef(null);
+ const {isDraggingOver} = useContext(DragAndDropContext);
+ const prevIsDraggingOver = usePrevious(isDraggingOver);
+
+ const getSuggestions = useCallback(() => {
+ if (suggestionEmojiRef.current?.getSuggestions) {
+ const emojiSuggestions = suggestionEmojiRef.current.getSuggestions();
+ if (emojiSuggestions.length > 0) {
+ return emojiSuggestions;
+ }
+ }
+
+ if (suggestionMentionRef.current?.getSuggestions) {
+ const mentionSuggestions = suggestionMentionRef.current.getSuggestions();
+ if (mentionSuggestions.length > 0) {
+ return mentionSuggestions;
+ }
+ }
+
+ return [];
+ }, []);
+
+ /**
+ * Clean data related to EmojiSuggestions
+ */
+ const resetSuggestions = useCallback(() => {
+ suggestionEmojiRef.current?.resetSuggestions();
+ suggestionMentionRef.current?.resetSuggestions();
+ }, []);
+
+ /**
+ * Listens for keyboard shortcuts and applies the action
+ */
+ const triggerHotkeyActions = useCallback((e: KeyboardEvent) => {
+ const emojiHandler = suggestionEmojiRef.current?.triggerHotkeyActions(e);
+ const mentionHandler = suggestionMentionRef.current?.triggerHotkeyActions(e);
+ return emojiHandler ?? mentionHandler;
+ }, []);
+
+ const onSelectionChange = useCallback((e: NativeSyntheticEvent) => {
+ const emojiHandler = suggestionEmojiRef.current?.onSelectionChange?.(e);
+ return emojiHandler;
+ }, []);
+
+ const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
+ suggestionEmojiRef.current?.updateShouldShowSuggestionMenuToFalse();
+ suggestionMentionRef.current?.updateShouldShowSuggestionMenuToFalse();
+ }, []);
+
+ const setShouldBlockSuggestionCalc = useCallback((shouldBlock: boolean) => {
+ suggestionEmojiRef.current?.setShouldBlockSuggestionCalc(shouldBlock);
+ suggestionMentionRef.current?.setShouldBlockSuggestionCalc(shouldBlock);
+ }, []);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ resetSuggestions,
+ onSelectionChange,
+ triggerHotkeyActions,
+ updateShouldShowSuggestionMenuToFalse,
+ setShouldBlockSuggestionCalc,
+ getSuggestions,
+ }),
+ [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions],
+ );
+
+ useEffect(() => {
+ if (!(!prevIsDraggingOver && isDraggingOver)) {
+ return;
+ }
+ updateShouldShowSuggestionMenuToFalse();
+ }, [isDraggingOver, prevIsDraggingOver, updateShouldShowSuggestionMenuToFalse]);
+
+ const baseProps = {
+ value,
+ setValue,
+ setSelection,
+ selection,
+ isComposerFullSize,
+ updateComment,
+ composerHeight,
+ isAutoSuggestionPickerLarge,
+ measureParentContainer,
+ isComposerFocused,
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+Suggestions.displayName = 'Suggestions';
+
+export default forwardRef(Suggestions);
+
+export type {SuggestionProps};
diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js
deleted file mode 100644
index 62c29f3d418e..000000000000
--- a/src/pages/home/report/ReportActionCompose/suggestionProps.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import PropTypes from 'prop-types';
-
-const baseProps = {
- /** The current input value */
- value: PropTypes.string.isRequired,
-
- /** Callback to update the current input value */
- setValue: PropTypes.func.isRequired,
-
- /** The current selection value */
- selection: PropTypes.shape({
- start: PropTypes.number.isRequired,
- end: PropTypes.number.isRequired,
- }).isRequired,
-
- /** Callback to update the current selection */
- setSelection: PropTypes.func.isRequired,
-
- /** Whether the composer is expanded */
- isComposerFullSize: PropTypes.bool.isRequired,
-
- /** Callback to update the comment draft */
- updateComment: PropTypes.func.isRequired,
-
- /** Meaures the parent container's position and dimensions. */
- measureParentContainer: PropTypes.func.isRequired,
-
- /** Report composer focus state */
- isComposerFocused: PropTypes.bool,
-};
-
-const implementationBaseProps = {
- /** Whether to use the small or the big suggestion picker */
- isAutoSuggestionPickerLarge: PropTypes.bool.isRequired,
-
- ...baseProps,
-};
-
-export {baseProps, implementationBaseProps};
diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx
index 18f261024fd6..741422cc7e82 100644
--- a/src/pages/home/report/ReportActionItemSingle.tsx
+++ b/src/pages/home/report/ReportActionItemSingle.tsx
@@ -27,7 +27,7 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps';
import ReportActionItemDate from './ReportActionItemDate';
import ReportActionItemFragment from './ReportActionItemFragment';
-type ReportActionItemSingleProps = ChildrenProps & {
+type ReportActionItemSingleProps = Partial & {
/** All the data of the action */
action: ReportAction;
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index 1da806b9c269..3ace2ebeb436 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -31,6 +31,9 @@ const propTypes = {
/** The report currently being looked at */
report: reportPropTypes.isRequired,
+ /** The report's parentReportAction */
+ parentReportAction: PropTypes.shape(reportActionPropTypes),
+
/** Sorted actions prepared for display */
sortedReportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)).isRequired,
@@ -79,6 +82,7 @@ const defaultProps = {
isLoadingNewerReportActions: false,
...withCurrentUserPersonalDetailsDefaultProps,
policy: {},
+ parentReportAction: {},
};
const VERTICAL_OFFSET_THRESHOLD = 200;
@@ -123,6 +127,7 @@ function isMessageUnread(message, lastReadTime) {
function ReportActionsList({
report,
+ parentReportAction,
isLoadingInitialReportActions,
isLoadingOlderReportActions,
isLoadingNewerReportActions,
@@ -412,6 +417,7 @@ function ReportActionsList({
({item: reportAction, index}) => (
),
- [report, linkedReportActionID, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker],
+ [report, linkedReportActionID, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker, parentReportAction],
);
// Native mobile does not render updates flatlist the changes even though component did update called.
diff --git a/src/pages/home/report/ReportActionsListItemRenderer.js b/src/pages/home/report/ReportActionsListItemRenderer.js
index 3fd6ddcef750..bc8e6a94359f 100644
--- a/src/pages/home/report/ReportActionsListItemRenderer.js
+++ b/src/pages/home/report/ReportActionsListItemRenderer.js
@@ -13,6 +13,9 @@ const propTypes = {
/** All the data of the action item */
reportAction: PropTypes.shape(reportActionPropTypes).isRequired,
+ /** The report's parentReportAction */
+ parentReportAction: PropTypes.shape(reportActionPropTypes),
+
/** Position index of the report action in the overall report FlatList view */
index: PropTypes.number.isRequired,
@@ -38,10 +41,12 @@ const propTypes = {
const defaultProps = {
mostRecentIOUReportActionID: '',
linkedReportActionID: '',
+ parentReportAction: {},
};
function ReportActionsListItemRenderer({
reportAction,
+ parentReportAction,
index,
report,
displayAsGroup,
@@ -51,9 +56,7 @@ function ReportActionsListItemRenderer({
linkedReportActionID,
}) {
const shouldDisplayParentAction =
- reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED &&
- ReportUtils.isChatThread(report) &&
- !ReportActionsUtils.isTransactionThread(ReportActionsUtils.getParentReportAction(report));
+ reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && ReportUtils.isChatThread(report) && !ReportActionsUtils.isTransactionThread(parentReportAction);
/**
* Create a lightweight ReportAction so as to keep the re-rendering as light as possible by
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index 064187855b57..91a8810e91ff 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -34,6 +34,9 @@ const propTypes = {
/** Array of report actions for this report */
reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)),
+ /** The report's parentReportAction */
+ parentReportAction: PropTypes.shape(reportActionPropTypes),
+
/** The report metadata loading states */
isLoadingInitialReportActions: PropTypes.bool,
@@ -78,6 +81,7 @@ const defaultProps = {
session: {
authTokenType: '',
},
+ parentReportAction: {},
};
function ReportActionsView(props) {
@@ -254,6 +258,7 @@ function ReportActionsView(props) {
<>
void;
};
-
-function ReportDropUI({onDrop}) {
+function ReportDropUI({onDrop}: ReportDropUIProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
return (
@@ -33,6 +31,5 @@ function ReportDropUI({onDrop}) {
}
ReportDropUI.displayName = 'ReportDropUI';
-ReportDropUI.propTypes = propTypes;
export default ReportDropUI;
diff --git a/src/pages/home/report/ReportTypingIndicator.js b/src/pages/home/report/ReportTypingIndicator.tsx
similarity index 70%
rename from src/pages/home/report/ReportTypingIndicator.js
rename to src/pages/home/report/ReportTypingIndicator.tsx
index 41471eaa50de..3ff8f2b0eb8e 100755
--- a/src/pages/home/report/ReportTypingIndicator.js
+++ b/src/pages/home/report/ReportTypingIndicator.tsx
@@ -1,7 +1,6 @@
-import PropTypes from 'prop-types';
import React, {memo, useMemo} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import Text from '@components/Text';
import TextWithEllipsis from '@components/TextWithEllipsis';
import useLocalize from '@hooks/useLocalize';
@@ -9,28 +8,30 @@ import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ReportUtils from '@libs/ReportUtils';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {ReportUserIsTyping} from '@src/types/onyx';
-const propTypes = {
+type ReportTypingIndicatorOnyxProps = {
/** Key-value pairs of user accountIDs/logins and whether or not they are typing. Keys are accountIDs or logins. */
- userTypingStatuses: PropTypes.objectOf(PropTypes.bool),
+ userTypingStatuses: OnyxEntry;
};
-const defaultProps = {
- userTypingStatuses: {},
+type ReportTypingIndicatorProps = ReportTypingIndicatorOnyxProps & {
+ // eslint-disable-next-line react/no-unused-prop-types -- This is used by withOnyx
+ reportID: string;
};
-function ReportTypingIndicator({userTypingStatuses}) {
+function ReportTypingIndicator({userTypingStatuses}: ReportTypingIndicatorProps) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const styles = useThemeStyles();
- const usersTyping = useMemo(() => _.filter(_.keys(userTypingStatuses), (loginOrAccountID) => userTypingStatuses[loginOrAccountID]), [userTypingStatuses]);
+ const usersTyping = useMemo(() => Object.keys(userTypingStatuses ?? {}).filter((loginOrAccountID) => userTypingStatuses?.[loginOrAccountID]), [userTypingStatuses]);
const firstUserTyping = usersTyping[0];
const isUserTypingADisplayName = Number.isNaN(Number(firstUserTyping));
// If we are offline, the user typing statuses are not up-to-date so do not show them
- if (isOffline || !firstUserTyping) {
+ if (!!isOffline || !firstUserTyping) {
return null;
}
@@ -40,6 +41,7 @@ function ReportTypingIndicator({userTypingStatuses}) {
if (usersTyping.length === 1) {
return (
({
userTypingStatuses: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`,
- initialValue: {},
},
})(memo(ReportTypingIndicator));
diff --git a/src/pages/iou/request/IOURequestStartPage.js b/src/pages/iou/request/IOURequestStartPage.js
index 92a1ded0e9d6..8e50577ede1f 100644
--- a/src/pages/iou/request/IOURequestStartPage.js
+++ b/src/pages/iou/request/IOURequestStartPage.js
@@ -1,4 +1,4 @@
-import {useFocusEffect} from '@react-navigation/native';
+import {useFocusEffect, useNavigation} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useRef, useState} from 'react';
@@ -71,6 +71,7 @@ function IOURequestStartPage({
}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const navigation = useNavigation();
const [isDraggingOver, setIsDraggingOver] = useState(false);
const tabTitles = {
[CONST.IOU.TYPE.REQUEST]: translate('iou.requestMoney'),
@@ -120,10 +121,13 @@ function IOURequestStartPage({
if (newIouType === previousIOURequestType) {
return;
}
+ if (iouType === CONST.IOU.TYPE.SPLIT && transaction.isFromGlobalCreate) {
+ IOU.updateMoneyRequestTypeParams(navigation.getState().routes, CONST.IOU.TYPE.REQUEST, newIouType);
+ }
IOU.initMoneyRequest(reportID, isFromGlobalCreate, newIouType);
transactionRequestType.current = newIouType;
},
- [previousIOURequestType, reportID, isFromGlobalCreate],
+ [previousIOURequestType, reportID, isFromGlobalCreate, iouType, navigation, transaction.isFromGlobalCreate],
);
if (!transaction.transactionID) {
diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js
index 238b66c0e727..2865316b7fd5 100644
--- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js
+++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js
@@ -234,7 +234,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({
];
}
- onParticipantsAdded(newSelectedOptions);
+ onParticipantsAdded(newSelectedOptions, newSelectedOptions.length !== 0 ? CONST.IOU.TYPE.SPLIT : undefined);
},
[participants, onParticipantsAdded],
);
@@ -263,7 +263,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({
return;
}
- onFinish();
+ onFinish(CONST.IOU.TYPE.SPLIT);
}, [shouldShowSplitBillErrorMessage, onFinish]);
const footerContent = useMemo(
diff --git a/src/pages/iou/request/step/IOURequestStepCategory.js b/src/pages/iou/request/step/IOURequestStepCategory.js
index 3e0feec02854..1945edbc24c4 100644
--- a/src/pages/iou/request/step/IOURequestStepCategory.js
+++ b/src/pages/iou/request/step/IOURequestStepCategory.js
@@ -1,4 +1,5 @@
import lodashGet from 'lodash/get';
+import lodashIsEmpty from 'lodash/isEmpty';
import PropTypes from 'prop-types';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
@@ -72,7 +73,7 @@ function IOURequestStepCategory({
const {translate} = useLocalize();
const isEditing = action === CONST.IOU.ACTION.EDIT;
const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT;
- const {category: transactionCategory} = ReportUtils.getTransactionDetails(isEditingSplitBill ? splitDraftTransaction : transaction);
+ const {category: transactionCategory} = ReportUtils.getTransactionDetails(isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction);
const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report);
// eslint-disable-next-line rulesdir/no-negated-variables
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js
index 1ec793c96244..9904f64d6833 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.js
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js
@@ -155,11 +155,6 @@ function IOURequestStepConfirmation({
// If there is not a report attached to the IOU with a reportID, then the participants were manually selected and the user needs taken
// back to the participants step
if (!transaction.participantsAutoAssigned) {
- // When going back to the participants step, if the iou is a "request" (not a split), then the participants need to be cleared from the
- // transaction so that the participant can be selected again.
- if (iouType === CONST.IOU.TYPE.REQUEST) {
- IOU.setMoneyRequestParticipants_temporaryForRefactor(transactionID, []);
- }
Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
return;
}
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.js b/src/pages/iou/request/step/IOURequestStepParticipants.js
index 675eb0d03448..b2f5cbb68cd1 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.js
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.js
@@ -1,5 +1,7 @@
+import {useNavigation} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import React, {useCallback, useEffect, useRef} from 'react';
+import _ from 'underscore';
import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
import compose from '@libs/compose';
@@ -36,13 +38,16 @@ function IOURequestStepParticipants({
transaction: {participants = []},
}) {
const {translate} = useLocalize();
+ const navigation = useNavigation();
const selectedReportID = useRef(reportID);
const numberOfParticipants = useRef(participants.length);
const iouRequestType = TransactionUtils.getRequestType(transaction);
- const headerTitle = translate(TransactionUtils.getHeaderTitleTranslationKey(transaction));
+ const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT;
+ const headerTitle = isSplitRequest ? translate('iou.split') : translate(TransactionUtils.getHeaderTitleTranslationKey(transaction));
const receiptFilename = lodashGet(transaction, 'filename');
const receiptPath = lodashGet(transaction, 'receipt.source');
const receiptType = lodashGet(transaction, 'receipt.type');
+ const newIouType = useRef();
// When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow.
// This is because until the request is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then
@@ -51,8 +56,40 @@ function IOURequestStepParticipants({
IOU.navigateToStartStepIfScanFileCannotBeRead(receiptFilename, receiptPath, () => {}, iouRequestType, iouType, transactionID, reportID, receiptType);
}, [receiptType, receiptPath, receiptFilename, iouRequestType, iouType, transactionID, reportID]);
+ const updateRouteParams = useCallback(() => {
+ IOU.updateMoneyRequestTypeParams(navigation.getState().routes, newIouType.current);
+ }, [navigation]);
+
+ useEffect(() => {
+ if (!newIouType.current) {
+ return;
+ }
+ // Participants can be added as normal or split participants. We want to wait for the participants' data to be updated before
+ // updating the money request type route params reducing the overhead of the thread and preventing possible jitters in UI.
+ updateRouteParams();
+ newIouType.current = null;
+ }, [participants, updateRouteParams]);
+
const addParticipant = useCallback(
- (val) => {
+ (val, selectedIouType) => {
+ const isSplit = selectedIouType === CONST.IOU.TYPE.SPLIT;
+ // It's only possible to switch between REQUEST and SPLIT.
+ // We want to update the IOU type only if it's not updated yet to prevent unnecessary updates.
+ if (isSplit && iouType !== CONST.IOU.TYPE.SPLIT) {
+ newIouType.current = CONST.IOU.TYPE.SPLIT;
+ } else if (!isSplit && iouType === CONST.IOU.TYPE.SPLIT) {
+ // Non-split can be either REQUEST or SEND. Instead of checking whether
+ // the current IOU type is not a REQUEST (true for SEND), we check whether the current IOU type is a SPLIT.
+ newIouType.current = CONST.IOU.TYPE.REQUEST;
+ }
+
+ // If the Onyx participants has the same items as the selected participants (val), Onyx won't update it
+ // thus this component won't rerender, so we can immediately update the route params.
+ if (newIouType.current && _.isEqual(participants, val)) {
+ updateRouteParams();
+ newIouType.current = null;
+ }
+
IOU.setMoneyRequestParticipants_temporaryForRefactor(transactionID, val);
numberOfParticipants.current = val.length;
@@ -66,15 +103,19 @@ function IOURequestStepParticipants({
// When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step.
selectedReportID.current = lodashGet(val, '[0].reportID', reportID);
},
- [reportID, transactionID],
+ [reportID, transactionID, iouType, participants, updateRouteParams],
);
- const goToNextStep = useCallback(() => {
- const nextStepIOUType = numberOfParticipants.current === 1 ? iouType : CONST.IOU.TYPE.SPLIT;
- IOU.setMoneyRequestTag(transactionID, '');
- IOU.setMoneyRequestCategory(transactionID, '');
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(nextStepIOUType, transactionID, selectedReportID.current || reportID));
- }, [iouType, transactionID, reportID]);
+ const goToNextStep = useCallback(
+ (selectedIouType) => {
+ const isSplit = selectedIouType === CONST.IOU.TYPE.SPLIT;
+ const nextStepIOUType = !isSplit && iouType !== CONST.IOU.TYPE.REQUEST ? CONST.IOU.TYPE.REQUEST : iouType;
+ IOU.setMoneyRequestTag(transactionID, '');
+ IOU.setMoneyRequestCategory(transactionID, '');
+ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(nextStepIOUType, transactionID, selectedReportID.current || reportID));
+ },
+ [iouType, transactionID, reportID],
+ );
const navigateBack = useCallback(() => {
IOUUtils.navigateToStartMoneyRequestStep(iouRequestType, iouType, transactionID, reportID);
@@ -90,7 +131,7 @@ function IOURequestStepParticipants({
>
{({didScreenTransitionEnd}) => (
void;
+ onPress: (
+ event?: GestureResponderEvent | KeyboardEvent,
+ accountType?: string,
+ accountData?: AccountData,
+ icon?: FormattedSelectedPaymentMethodIcon,
+ isDefault?: boolean,
+ methodID?: number,
+ ) => void;
};
type PaymentMethodItem = PaymentMethod & {
@@ -236,12 +243,25 @@ function PaymentMethodList({
(paymentMethod) => paymentMethod.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(paymentMethod.errors),
);
}
-
combinedPaymentMethods = combinedPaymentMethods.map((paymentMethod) => {
const isMethodActive = isPaymentMethodActive(actionPaymentMethodType, activePaymentMethodID, paymentMethod);
return {
...paymentMethod,
- onPress: (e: GestureResponderEvent) => onPress(e, paymentMethod.accountType, paymentMethod.accountData, paymentMethod.icon, paymentMethod.isDefault, paymentMethod.methodID),
+ onPress: (e: GestureResponderEvent) =>
+ onPress(
+ e,
+ paymentMethod.accountType,
+ paymentMethod.accountData,
+ {
+ icon: paymentMethod.icon,
+ iconHeight: paymentMethod?.iconHeight,
+ iconWidth: paymentMethod?.iconWidth,
+ iconStyles: paymentMethod?.iconStyles,
+ iconSize: paymentMethod?.iconSize,
+ },
+ paymentMethod.isDefault,
+ paymentMethod.methodID,
+ ),
wrapperStyle: isMethodActive ? [StyleUtils.getButtonBackgroundColorStyle(CONST.BUTTON_STATES.PRESSED)] : null,
disabled: paymentMethod.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
isMethodActive,
diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx
index 13e2877d3ec7..b9f49049d51a 100644
--- a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx
+++ b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx
@@ -37,12 +37,11 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {AccountData} from '@src/types/onyx';
-import type IconAsset from '@src/types/utils/IconAsset';
-import type {WalletPageOnyxProps, WalletPageProps} from './types';
+import type {FormattedSelectedPaymentMethodIcon, WalletPageOnyxProps, WalletPageProps} from './types';
type FormattedSelectedPaymentMethod = {
title: string;
- icon?: IconAsset;
+ icon?: FormattedSelectedPaymentMethodIcon;
description?: string;
type?: string;
};
@@ -151,7 +150,7 @@ function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadi
nativeEvent?: GestureResponderEvent | KeyboardEvent,
accountType?: string,
account?: AccountData,
- icon?: IconAsset,
+ icon?: FormattedSelectedPaymentMethodIcon,
isDefault?: boolean,
methodID?: string | number,
) => {
@@ -538,10 +537,14 @@ function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadi
{isPopoverBottomMount && (
)}
{shouldShowMakeDefaultButton && (
diff --git a/src/pages/settings/Wallet/WalletPage/types.ts b/src/pages/settings/Wallet/WalletPage/types.ts
index 0c3db1a2df65..ffee0c677c33 100644
--- a/src/pages/settings/Wallet/WalletPage/types.ts
+++ b/src/pages/settings/Wallet/WalletPage/types.ts
@@ -1,5 +1,7 @@
+import type {ViewStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import type {BankAccountList, CardList, FundList, UserWallet, WalletTerms, WalletTransfer} from '@src/types/onyx';
+import type IconAsset from '@src/types/utils/IconAsset';
type WalletPageOnyxProps = {
/** Wallet balance transfer props */
@@ -28,4 +30,12 @@ type WalletPageProps = WalletPageOnyxProps & {
shouldListenForResize?: boolean;
};
-export type {WalletPageOnyxProps, WalletPageProps};
+type FormattedSelectedPaymentMethodIcon = {
+ icon: IconAsset;
+ iconHeight?: number;
+ iconWidth?: number;
+ iconStyles?: ViewStyle[];
+ iconSize?: number;
+};
+
+export type {WalletPageOnyxProps, WalletPageProps, FormattedSelectedPaymentMethodIcon};
diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx
index 1be4e6f486b0..b9236b0e7252 100644
--- a/src/pages/workspace/WorkspaceNewRoomPage.tsx
+++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx
@@ -266,7 +266,6 @@ function WorkspaceNewRoomPage({policies, reports, formState, session, activePoli
ref={inputCallbackRef}
inputID={INPUT_IDS.ROOM_NAME}
isFocused={isFocused}
- // @ts-expect-error TODO: Remove this once RoomNameInput (https://github.com/Expensify/App/issues/25090) is migrated to TypeScript.
shouldDelayFocus
autoFocus
/>
diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx
index b9b14e27d01d..32b76ef46202 100644
--- a/src/pages/workspace/WorkspacePageWithSections.tsx
+++ b/src/pages/workspace/WorkspacePageWithSections.tsx
@@ -1,3 +1,4 @@
+import {useIsFocused} from '@react-navigation/native';
import type {ReactNode} from 'react';
import React, {useEffect, useMemo, useRef} from 'react';
import {View} from 'react-native';
@@ -121,6 +122,7 @@ function WorkspacePageWithSections({
const content = typeof children === 'function' ? children(hasVBA, policyID, isUsingECard) : children;
const {isSmallScreenWidth} = useWindowDimensions();
const firstRender = useRef(true);
+ const isFocused = useIsFocused();
useEffect(() => {
// Because isLoading is false before merging in Onyx, we need firstRender ref to display loading page as well before isLoading is change to true
@@ -163,7 +165,7 @@ function WorkspacePageWithSections({
onBackButtonPress={() => Navigation.goBack(backButtonRoute ?? ROUTES.WORKSPACE_INITIAL.getRoute(policyID))}
icon={icon ?? undefined}
/>
- {(isLoading || firstRender.current) && shouldShowLoading ? (
+ {(isLoading || firstRender.current) && shouldShowLoading && isFocused ? (
) : (
<>
diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx
index c01df71255e0..f475664b3025 100644
--- a/src/pages/workspace/WorkspaceProfilePage.tsx
+++ b/src/pages/workspace/WorkspaceProfilePage.tsx
@@ -3,6 +3,7 @@ import type {ImageStyle, StyleProp} from 'react-native';
import {Image, ScrollView, StyleSheet, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
+import WorkspaceProfileLight from '@assets/images/workspace-profile-light.png';
import WorkspaceProfile from '@assets/images/workspace-profile.png';
import Avatar from '@components/Avatar';
import AvatarWithImagePicker from '@components/AvatarWithImagePicker';
@@ -14,6 +15,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import Section from '@components/Section';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
+import useThemePreference from '@hooks/useThemePreference';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import Navigation from '@libs/Navigation/Navigation';
@@ -42,6 +44,8 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
+ const themePreference = useThemePreference();
+ const isDarkTheme = themePreference === CONST.THEME.DARK;
const outputCurrency = policy?.outputCurrency ?? '';
const currencySymbol = currencyList?.[outputCurrency]?.symbol ?? '';
@@ -55,7 +59,7 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi
const policyName = policy?.name ?? '';
const policyDescription = policy?.description ?? '';
const readOnly = !PolicyUtils.isPolicyAdmin(policy);
- const imageStyle: StyleProp = isSmallScreenWidth ? [styles.mhv12, styles.mhn5] : [styles.mhv8, styles.mhn8];
+ const imageStyle: StyleProp = isSmallScreenWidth ? [styles.mhv12, styles.mhn5, styles.mbn5] : [styles.mhv8, styles.mhn8, styles.mbn5];
const DefaultAvatar = useCallback(
() => (
@@ -91,8 +95,8 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi
title=""
>
Policy.updateWorkspaceAvatar(policy?.id ?? '', file as File)}
onImageRemoved={() => Policy.deleteWorkspaceAvatar(policy?.id ?? '')}
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
index 7cd9972a6f57..721341073d72 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -129,7 +129,8 @@ function WorkspaceCategoriesPage({policyCategories, route}: WorkspaceCategoriesP
smallEditIcon: {
alignItems: 'center',
backgroundColor: theme.buttonDefaultBG,
- borderColor: theme.appBG,
+ borderColor: theme.cardBG,
borderRadius: 20,
borderWidth: 3,
color: theme.textReversed,
@@ -4630,9 +4630,8 @@ const styles = (theme: ThemeColors) =>
},
workspaceTitleStyle: {
- fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD,
- fontWeight: '500',
- fontSize: variables.workspaceProfileName,
+ ...headlineFont,
+ fontSize: variables.fontSizeXLarge,
},
} satisfies Styles);
diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts
index 3f59b08fc447..31716d75dd05 100644
--- a/src/styles/theme/themes/dark.ts
+++ b/src/styles/theme/themes/dark.ts
@@ -95,7 +95,7 @@ const darkTheme = {
// The screen name (see SCREENS.ts) is the name of the screen as far as react-navigation is concerned, and the linkingConfig maps screen names to URLs
PAGE_THEMES: {
[SCREENS.HOME]: {
- backgroundColor: colors.productDark200,
+ backgroundColor: colors.productDark100,
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
},
[SCREENS.REPORT]: {
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index c0b4a5fe3182..fecd8749aebb 100644
--- a/src/styles/theme/themes/light.ts
+++ b/src/styles/theme/themes/light.ts
@@ -95,7 +95,7 @@ const lightTheme = {
// The screen name (see SCREENS.ts) is the name of the screen as far as react-navigation is concerned, and the linkingConfig maps screen names to URLs
PAGE_THEMES: {
[SCREENS.HOME]: {
- backgroundColor: colors.productLight200,
+ backgroundColor: colors.productLight100,
statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
},
[SCREENS.REPORT]: {
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index 0249b3c6dfc0..b807ee2bdf24 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -315,6 +315,10 @@ export default {
marginBottom: -4,
},
+ mbn5: {
+ marginBottom: -20,
+ },
+
p0: {
padding: 0,
paddingHorizontal: 0,
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index f7c9bd055041..3d5115a31f54 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -216,7 +216,6 @@ export default {
updateAnimationH: 240,
updateTextViewContainerWidth: 310,
updateViewHeaderHeight: 70,
- workspaceProfileName: 20,
mushroomTopHatWidth: 138,
mushroomTopHatHeight: 128,
diff --git a/src/types/modules/pusher.d.ts b/src/types/modules/pusher.d.ts
index 676d7a7ee2fc..e9aa50085e8d 100644
--- a/src/types/modules/pusher.d.ts
+++ b/src/types/modules/pusher.d.ts
@@ -9,6 +9,6 @@ declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface File {
source: string;
- uri: string;
+ uri?: string;
}
}
diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts
index 6300d416035a..21f1d620b14f 100644
--- a/src/types/modules/react-native.d.ts
+++ b/src/types/modules/react-native.d.ts
@@ -283,14 +283,9 @@ declare module 'react-native' {
* Extracted from react-native-web, packages/react-native-web/src/exports/TextInput/types.js
*/
interface WebTextInputProps extends WebSharedProps {
- dir?: 'auto' | 'ltr' | 'rtl';
disabled?: boolean;
- enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
- readOnly?: boolean;
}
interface TextInputProps extends WebTextInputProps {
- // TODO: remove once the app is updated to RN 0.73
- smartInsertDelete?: boolean;
isFullComposerAvailable?: boolean;
}
diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts
index 1c5d46610286..8b96a89a2a1b 100644
--- a/src/types/onyx/OnyxCommon.ts
+++ b/src/types/onyx/OnyxCommon.ts
@@ -31,7 +31,7 @@ type Icon = {
type: AvatarType;
/** Owner of the avatar. If user, displayName. If workspace, policy name */
- name: string;
+ name?: string;
/** Avatar id */
id?: number | string;
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 06c2d2e6abce..198c606cf9f2 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -3,7 +3,6 @@ import type CONST from '@src/CONST';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
type PaymentMethodType = DeepValueOf;
-
type ActionName = DeepValueOf;
type OriginalMessageActionName =
| 'ADDCOMMENT'
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 1662a76c02df..1eece2d3a1e0 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -17,6 +17,12 @@ type Attributes = {
unit: Unit;
};
+type MileageRate = {
+ unit: Unit;
+ rate?: number;
+ currency: string;
+};
+
type CustomUnit = OnyxCommon.OnyxValueWithOfflineFeedback<{
name: string;
customUnitID: string;
@@ -223,10 +229,28 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
/** All the integration connections attached to the policy */
connections?: Record;
+
+ /** Whether the Categories feature is enabled */
+ areCategoriesEnabled?: boolean;
+
+ /** Whether the Tags feature is enabled */
+ areTagsEnabled?: boolean;
+
+ /** Whether the Distance Rates feature is enabled */
+ areDistanceRatesEnabled?: boolean;
+
+ /** Whether the workflows feature is enabled */
+ areWorkflowsEnabled?: boolean;
+
+ /** Whether the Report Fields feature is enabled */
+ areReportFieldsEnabled?: boolean;
+
+ /** Whether the Connections feature is enabled */
+ areConnectionsEnabled?: boolean;
},
'generalSettings' | 'addWorkspaceRoom'
>;
export default Policy;
-export type {Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault};
+export type {Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault, MileageRate};
diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
index bb86d2cf4ae4..22618bb357d0 100644
--- a/src/types/onyx/Report.ts
+++ b/src/types/onyx/Report.ts
@@ -1,5 +1,7 @@
import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
+import type ONYXKEYS from '@src/ONYXKEYS';
+import type CollectionDataSet from '@src/types/utils/CollectionDataSet';
import type * as OnyxCommon from './OnyxCommon';
import type PersonalDetails from './PersonalDetails';
import type {PolicyReportField} from './PolicyReportField';
@@ -174,6 +176,8 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
PolicyReportField['fieldID']
>;
+type ReportCollectionDataSet = CollectionDataSet;
+
export default Report;
-export type {NotificationPreference, RoomVisibility, WriteCapability, Note};
+export type {NotificationPreference, RoomVisibility, WriteCapability, Note, ReportCollectionDataSet};
diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts
index 9d24a5048455..bb5bf50ec6cf 100644
--- a/src/types/onyx/ReportAction.ts
+++ b/src/types/onyx/ReportAction.ts
@@ -1,4 +1,5 @@
import type {ValueOf} from 'type-fest';
+import type {FileObject} from '@components/AttachmentModal';
import type {AvatarSource} from '@libs/UserUtils';
import type CONST from '@src/CONST';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
@@ -123,6 +124,9 @@ type ReportActionBase = OnyxCommon.OnyxValueWithOfflineFeedback<{
actorAccountID?: number;
+ /** The account of the last message's actor */
+ actor?: string;
+
/** Person who created the action */
person?: Person[];
@@ -181,7 +185,7 @@ type ReportActionBase = OnyxCommon.OnyxValueWithOfflineFeedback<{
isFirstItem?: boolean;
/** Informations about attachments of report action */
- attachmentInfo?: File | EmptyObject;
+ attachmentInfo?: FileObject | EmptyObject;
/** Receipt tied to report action */
receipt?: Receipt;
diff --git a/src/types/utils/CollectionDataSet.ts b/src/types/utils/CollectionDataSet.ts
new file mode 100644
index 000000000000..fc95a69dc9bf
--- /dev/null
+++ b/src/types/utils/CollectionDataSet.ts
@@ -0,0 +1,6 @@
+import type {OnyxCollectionKey, OnyxCollectionValuesMapping} from '@src/ONYXKEYS';
+
+/** Helps with typing a collection item update inside Onyx.multiSet call */
+type CollectionDataSet = Record<`${TCollectionKey}${string}`, OnyxCollectionValuesMapping[TCollectionKey]>;
+
+export default CollectionDataSet;
diff --git a/tests/actions/SessionTest.js b/tests/actions/SessionTest.ts
similarity index 74%
rename from tests/actions/SessionTest.js
rename to tests/actions/SessionTest.ts
index f7d7f8ed5835..f4c4d186ff50 100644
--- a/tests/actions/SessionTest.js
+++ b/tests/actions/SessionTest.ts
@@ -1,27 +1,27 @@
import {beforeEach, jest, test} from '@jest/globals';
import Onyx from 'react-native-onyx';
-import CONST from '../../src/CONST';
-import * as App from '../../src/libs/actions/App';
-import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager';
-import HttpUtils from '../../src/libs/HttpUtils';
-import PushNotification from '../../src/libs/Notification/PushNotification';
+import type {OnyxEntry} from 'react-native-onyx';
+import * as App from '@libs/actions/App';
+import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager';
+import HttpUtils from '@libs/HttpUtils';
+import PushNotification from '@libs/Notification/PushNotification';
// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
-// eslint-disable-next-line no-unused-vars
-import subscribePushNotification from '../../src/libs/Notification/PushNotification/subscribePushNotification';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import '@libs/Notification/PushNotification/subscribePushNotification';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Credentials, Session} from '@src/types/onyx';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
// We are mocking this method so that we can later test to see if it was called and what arguments it was called with.
// We test HttpUtils.xhr() since this means that our API command turned into a network request and isn't only queued.
-HttpUtils.xhr = jest.fn();
+HttpUtils.xhr = jest.fn();
// Mocked to ensure push notifications are subscribed/unsubscribed as the session changes
-jest.mock('../../src/libs/Notification/PushNotification');
+jest.mock('@libs/Notification/PushNotification');
Onyx.init({
keys: ONYXKEYS,
- registerStorageEventListener: () => {},
});
OnyxUpdateManager();
@@ -35,13 +35,13 @@ describe('Session', () => {
const TEST_INITIAL_AUTH_TOKEN = 'initialAuthToken';
const TEST_REFRESHED_AUTH_TOKEN = 'refreshedAuthToken';
- let credentials;
+ let credentials: OnyxEntry = null;
Onyx.connect({
key: ONYXKEYS.CREDENTIALS,
- callback: (val) => (credentials = val || {}),
+ callback: (val) => (credentials = val),
});
- let session;
+ let session: OnyxEntry = null;
Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (val) => (session = val),
@@ -53,19 +53,19 @@ describe('Session', () => {
.then(() => {
// Then our re-authentication credentials should be generated and our session data
// have the correct information + initial authToken.
- expect(credentials.login).toBe(TEST_USER_LOGIN);
- expect(credentials.autoGeneratedLogin).not.toBeUndefined();
- expect(credentials.autoGeneratedPassword).not.toBeUndefined();
- expect(session.authToken).toBe(TEST_INITIAL_AUTH_TOKEN);
- expect(session.accountID).toBe(TEST_USER_ACCOUNT_ID);
- expect(session.email).toBe(TEST_USER_LOGIN);
+ expect(credentials?.login).toBe(TEST_USER_LOGIN);
+ expect(credentials?.autoGeneratedLogin).not.toBeUndefined();
+ expect(credentials?.autoGeneratedPassword).not.toBeUndefined();
+ expect(session?.authToken).toBe(TEST_INITIAL_AUTH_TOKEN);
+ expect(session?.accountID).toBe(TEST_USER_ACCOUNT_ID);
+ expect(session?.email).toBe(TEST_USER_LOGIN);
// At this point we have an authToken. To simulate it expiring we'll just make another
// request and mock the response so it returns 407. Once this happens we should attempt
// to Re-Authenticate with the stored credentials. Our next call will be to Authenticate
// so we will mock that response with a new authToken and then verify that Onyx has our
// data.
- HttpUtils.xhr
+ (HttpUtils.xhr as jest.MockedFunction)
// This will make the call to OpenApp below return with an expired session code
.mockImplementationOnce(() =>
@@ -92,7 +92,7 @@ describe('Session', () => {
.then(() => {
// Then it should fail and reauthenticate the user adding the new authToken to the session
// data in Onyx
- expect(session.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN);
+ expect(session?.authToken).toBe(TEST_REFRESHED_AUTH_TOKEN);
});
});
diff --git a/tests/e2e/server/index.js b/tests/e2e/server/index.ts
similarity index 62%
rename from tests/e2e/server/index.js
rename to tests/e2e/server/index.ts
index 82d9f48e0269..7e7c34959655 100644
--- a/tests/e2e/server/index.js
+++ b/tests/e2e/server/index.ts
@@ -1,15 +1,42 @@
import {createServer} from 'http';
+import type {IncomingMessage, ServerResponse} from 'http';
+import type {NativeCommand, TestResult} from '@libs/E2E/client';
+import type {NetworkCacheMap, TestConfig} from '@libs/E2E/types';
import config from '../config';
import * as nativeCommands from '../nativeCommands';
import * as Logger from '../utils/logger';
import Routes from './routes';
-const PORT = process.env.PORT || config.SERVER_PORT;
+type NetworkCache = {
+ appInstanceId: string;
+ cache: NetworkCacheMap;
+};
+
+type RequestData = TestResult | NativeCommand | NetworkCache;
+
+type TestStartedListener = (testConfig?: TestConfig) => void;
+
+type TestDoneListener = () => void;
+
+type TestResultListener = (testResult: TestResult) => void;
+
+type AddListener = (listener: TListener) => void;
+
+type ServerInstance = {
+ setTestConfig: (testConfig: TestConfig) => void;
+ addTestStartedListener: AddListener;
+ addTestResultListener: AddListener;
+ addTestDoneListener: AddListener;
+ start: () => Promise;
+ stop: () => Promise;
+};
+
+const PORT = process.env.PORT ?? config.SERVER_PORT;
// Gets the request data as a string
-const getReqData = (req) => {
+const getReqData = (req: IncomingMessage): Promise => {
let data = '';
- req.on('data', (chunk) => {
+ req.on('data', (chunk: string) => {
data += chunk;
});
@@ -21,16 +48,16 @@ const getReqData = (req) => {
};
// Expects a POST request with JSON data. Returns parsed JSON data.
-const getPostJSONRequestData = (req, res) => {
+const getPostJSONRequestData = (req: IncomingMessage, res: ServerResponse): Promise | undefined => {
if (req.method !== 'POST') {
res.statusCode = 400;
res.end('Unsupported method');
return;
}
- return getReqData(req).then((data) => {
+ return getReqData(req).then((data): TRequestData | undefined => {
try {
- return JSON.parse(data);
+ return JSON.parse(data) as TRequestData;
} catch (e) {
Logger.info('❌ Failed to parse request data', data);
res.statusCode = 400;
@@ -39,9 +66,9 @@ const getPostJSONRequestData = (req, res) => {
});
};
-const createListenerState = () => {
- const listeners = [];
- const addListener = (listener) => {
+const createListenerState = (): [TListener[], AddListener] => {
+ const listeners: TListener[] = [];
+ const addListener = (listener: TListener) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
@@ -54,20 +81,6 @@ const createListenerState = () => {
return [listeners, addListener];
};
-/**
- * The test result object that a client might submit to the server.
- * @typedef TestResult
- * @property {string} name
- * @property {number} duration Milliseconds
- * @property {string} [error] Optional, if set indicates that the test run failed and has no valid results.
- */
-
-/**
- * @callback listener
- * @param {TestResult} testResult
- */
-
-// eslint-disable-next-line valid-jsdoc
/**
* Creates a new http server.
* The server just has two endpoints:
@@ -78,35 +91,32 @@ const createListenerState = () => {
*
* It returns an instance to which you can add listeners for the test results, and test done events.
*/
-const createServerInstance = () => {
- const [testStartedListeners, addTestStartedListener] = createListenerState();
- const [testResultListeners, addTestResultListener] = createListenerState();
- const [testDoneListeners, addTestDoneListener] = createListenerState();
-
- let activeTestConfig;
- const networkCache = {};
-
- /**
- * @param {TestConfig} testConfig
- */
- const setTestConfig = (testConfig) => {
+const createServerInstance = (): ServerInstance => {
+ const [testStartedListeners, addTestStartedListener] = createListenerState();
+ const [testResultListeners, addTestResultListener] = createListenerState();
+ const [testDoneListeners, addTestDoneListener] = createListenerState();
+
+ let activeTestConfig: TestConfig | undefined;
+ const networkCache: Record = {};
+
+ const setTestConfig = (testConfig: TestConfig) => {
activeTestConfig = testConfig;
};
- const server = createServer((req, res) => {
+ const server = createServer((req, res): ServerResponse | void => {
res.statusCode = 200;
switch (req.url) {
case Routes.testConfig: {
testStartedListeners.forEach((listener) => listener(activeTestConfig));
- if (activeTestConfig == null) {
+ if (!activeTestConfig) {
throw new Error('No test config set');
}
return res.end(JSON.stringify(activeTestConfig));
}
case Routes.testResults: {
- getPostJSONRequestData(req, res).then((data) => {
- if (data == null) {
+ getPostJSONRequestData(req, res)?.then((data) => {
+ if (!data) {
// The getPostJSONRequestData function already handled the response
return;
}
@@ -128,9 +138,9 @@ const createServerInstance = () => {
}
case Routes.testNativeCommand: {
- getPostJSONRequestData(req, res)
- .then((data) =>
- nativeCommands.executeFromPayload(data.actionName, data.payload).then((status) => {
+ getPostJSONRequestData(req, res)
+ ?.then((data) =>
+ nativeCommands.executeFromPayload(data?.actionName, data?.payload).then((status) => {
if (status) {
res.end('ok');
return;
@@ -148,8 +158,8 @@ const createServerInstance = () => {
}
case Routes.testGetNetworkCache: {
- getPostJSONRequestData(req, res).then((data) => {
- const appInstanceId = data && data.appInstanceId;
+ getPostJSONRequestData(req, res)?.then((data) => {
+ const appInstanceId = data?.appInstanceId;
if (!appInstanceId) {
res.statusCode = 400;
res.end('Invalid request missing appInstanceId');
@@ -164,9 +174,9 @@ const createServerInstance = () => {
}
case Routes.testUpdateNetworkCache: {
- getPostJSONRequestData(req, res).then((data) => {
- const appInstanceId = data && data.appInstanceId;
- const cache = data && data.cache;
+ getPostJSONRequestData(req, res)?.then((data) => {
+ const appInstanceId = data?.appInstanceId;
+ const cache = data?.cache;
if (!appInstanceId || !cache) {
res.statusCode = 400;
res.end('Invalid request missing appInstanceId or cache');
@@ -192,11 +202,11 @@ const createServerInstance = () => {
addTestResultListener,
addTestDoneListener,
start: () =>
- new Promise((resolve) => {
+ new Promise((resolve) => {
server.listen(PORT, resolve);
}),
stop: () =>
- new Promise((resolve) => {
+ new Promise((resolve) => {
server.close(resolve);
}),
};
diff --git a/tests/e2e/utils/getCurrentBranchName.js b/tests/e2e/utils/getCurrentBranchName.ts
similarity index 82%
rename from tests/e2e/utils/getCurrentBranchName.js
rename to tests/e2e/utils/getCurrentBranchName.ts
index 55df11010214..7ae958b08e13 100644
--- a/tests/e2e/utils/getCurrentBranchName.js
+++ b/tests/e2e/utils/getCurrentBranchName.ts
@@ -1,6 +1,6 @@
import {execSync} from 'child_process';
-const getCurrentBranchName = () => {
+const getCurrentBranchName = (): string => {
const stdout = execSync('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf8',
});
diff --git a/tests/perf-test/OptionsSelector.perf-test.js b/tests/perf-test/OptionsSelector.perf-test.js
index 260a9da06c6b..6104ded05c6a 100644
--- a/tests/perf-test/OptionsSelector.perf-test.js
+++ b/tests/perf-test/OptionsSelector.perf-test.js
@@ -5,20 +5,6 @@ import _ from 'underscore';
import OptionsSelector from '@src/components/OptionsSelector';
import variables from '@src/styles/variables';
-jest.mock('@react-navigation/native', () => {
- const actualNav = jest.requireActual('@react-navigation/native');
- return {
- ...actualNav,
- useNavigation: () => ({
- navigate: jest.fn(),
- addListener: () => jest.fn(),
- }),
- useIsFocused: () => ({
- navigate: jest.fn(),
- }),
- };
-});
-
jest.mock('../../src/components/withLocalize', () => (Component) => {
function WrappedComponent(props) {
return (
diff --git a/tests/unit/EmojiTest.js b/tests/unit/EmojiTest.ts
similarity index 88%
rename from tests/unit/EmojiTest.js
rename to tests/unit/EmojiTest.ts
index 40474d0331fe..954d561598e8 100644
--- a/tests/unit/EmojiTest.js
+++ b/tests/unit/EmojiTest.ts
@@ -1,20 +1,20 @@
import {getUnixTime} from 'date-fns';
-import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import Emoji from '../../assets/emojis';
-import CONST from '../../src/CONST';
-import * as User from '../../src/libs/actions/User';
-import * as EmojiUtils from '../../src/libs/EmojiUtils';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import Emojis from '@assets/emojis';
+import type {Emoji} from '@assets/emojis/types';
+import * as User from '@libs/actions/User';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {FrequentlyUsedEmoji} from '@src/types/onyx';
import * as TestHelper from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
describe('EmojiTest', () => {
it('matches all the emojis in the list', () => {
// Given the set of Emojis available in the application
- const emojiMatched = _.every(Emoji, (emoji) => {
- if (emoji.header === true || emoji.spacer) {
+ const emojiMatched = Emojis.every((emoji) => {
+ if (('header' in emoji && emoji.header) || ('spacer' in emoji && emoji.spacer)) {
return true;
}
@@ -22,9 +22,9 @@ describe('EmojiTest', () => {
const isEmojiMatched = EmojiUtils.containsOnlyEmojis(emoji.code);
let skinToneMatched = true;
- if (emoji.types) {
+ if ('types' in emoji && emoji.types) {
// and every skin tone variant of the Emoji code
- skinToneMatched = _.every(emoji.types, (emojiWithSkinTone) => EmojiUtils.containsOnlyEmojis(emojiWithSkinTone));
+ skinToneMatched = emoji.types.every((emojiWithSkinTone) => EmojiUtils.containsOnlyEmojis(emojiWithSkinTone));
}
return skinToneMatched && isEmojiMatched;
});
@@ -103,42 +103,42 @@ describe('EmojiTest', () => {
it('replaces an emoji code with an emoji and a space', () => {
const text = 'Hi :smile:';
- expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄 ');
+ expect(EmojiUtils.replaceEmojis(text).text).toBe('Hi 😄 ');
});
it('will add a space after the last emoji', () => {
const text = 'Hi :smile::wave:';
- expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 ');
+ expect(EmojiUtils.replaceEmojis(text).text).toBe('Hi 😄👋 ');
});
it('will add a space after the last emoji if there is text after it', () => {
const text = 'Hi :smile::wave:space after last emoji';
- expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space after last emoji');
+ expect(EmojiUtils.replaceEmojis(text).text).toBe('Hi 😄👋 space after last emoji');
});
it('will add a space after the last emoji if there is invalid emoji after it', () => {
const text = 'Hi :smile::wave:space when :invalidemoji: present';
- expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space when :invalidemoji: present');
+ expect(EmojiUtils.replaceEmojis(text).text).toBe('Hi 😄👋 space when :invalidemoji: present');
});
it('will not add a space after the last emoji if there if last emoji is immediately followed by a space', () => {
const text = 'Hi :smile::wave: space after last emoji';
- expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space after last emoji');
+ expect(EmojiUtils.replaceEmojis(text).text).toBe('Hi 😄👋 space after last emoji');
});
it('will return correct cursor position', () => {
const text = 'Hi :smile: there :wave:!';
- expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(15);
+ expect(EmojiUtils.replaceEmojis(text).cursorPosition).toBe(15);
});
it('will return correct cursor position when space is not added by space follows last emoji', () => {
const text = 'Hi :smile: there!';
- expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(6);
+ expect(EmojiUtils.replaceEmojis(text).cursorPosition).toBe(6);
});
it('will return undefined cursor position when no emoji is replaced', () => {
const text = 'Hi there!';
- expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(undefined);
+ expect(EmojiUtils.replaceEmojis(text).cursorPosition).toBe(undefined);
});
it('suggests emojis when typing emojis prefix after colon', () => {
@@ -149,11 +149,11 @@ describe('EmojiTest', () => {
it('suggests a limited number of matching emojis', () => {
const text = 'Hi :face';
const limit = 3;
- expect(EmojiUtils.suggestEmojis(text, 'en', limit).length).toBe(limit);
+ expect(EmojiUtils.suggestEmojis(text, 'en', limit)?.length).toBe(limit);
});
it('correct suggests emojis accounting for keywords', () => {
- const thumbEmojis = [
+ const thumbEmojis: Emoji[] = [
{
code: '👍',
name: '+1',
@@ -190,10 +190,11 @@ describe('EmojiTest', () => {
});
describe('update frequently used emojis', () => {
- let spy;
+ let spy: jest.SpyInstance;
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
+ // @ts-expect-error TODO: Remove this once TestHelper (https://github.com/Expensify/App/issues/25318) is migrated to TypeScript.
global.fetch = TestHelper.getGlobalFetchMock();
spy = jest.spyOn(User, 'updateFrequentlyUsedEmojis');
});
@@ -205,7 +206,7 @@ describe('EmojiTest', () => {
it('should put a less frequent and recent used emoji behind', () => {
// Given an existing frequently used emojis list with count > 1
- const frequentlyEmojisList = [
+ const frequentlyEmojisList: FrequentlyUsedEmoji[] = [
{
code: '👋',
name: 'wave',
@@ -237,12 +238,12 @@ describe('EmojiTest', () => {
return waitForBatchedUpdates().then(() => {
// When add a new emoji
const currentTime = getUnixTime(new Date());
- const smileEmoji = {code: '😄', name: 'smile'};
+ const smileEmoji: Emoji = {code: '😄', name: 'smile'};
const newEmoji = [smileEmoji];
User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(newEmoji));
// Then the new emoji should be at the last item of the list
- const expectedSmileEmoji = {...smileEmoji, count: 1, lastUpdatedAt: currentTime};
+ const expectedSmileEmoji: FrequentlyUsedEmoji = {...smileEmoji, count: 1, lastUpdatedAt: currentTime};
const expectedFrequentlyEmojisList = [...frequentlyEmojisList, expectedSmileEmoji];
expect(spy).toBeCalledWith(expectedFrequentlyEmojisList);
@@ -251,8 +252,8 @@ describe('EmojiTest', () => {
it('should put more frequent and recent used emoji to the front', () => {
// Given an existing frequently used emojis list
- const smileEmoji = {code: '😄', name: 'smile'};
- const frequentlyEmojisList = [
+ const smileEmoji: Emoji = {code: '😄', name: 'smile'};
+ const frequentlyEmojisList: FrequentlyUsedEmoji[] = [
{
code: '😠',
name: 'angry',
@@ -296,10 +297,10 @@ describe('EmojiTest', () => {
it('should sorted descending by count and lastUpdatedAt for multiple emoji added', () => {
// Given an existing frequently used emojis list
- const smileEmoji = {code: '😄', name: 'smile'};
- const zzzEmoji = {code: '💤', name: 'zzz'};
- const impEmoji = {code: '👿', name: 'imp'};
- const frequentlyEmojisList = [
+ const smileEmoji: Emoji = {code: '😄', name: 'smile'};
+ const zzzEmoji: Emoji = {code: '💤', name: 'zzz'};
+ const impEmoji: Emoji = {code: '👿', name: 'imp'};
+ const frequentlyEmojisList: FrequentlyUsedEmoji[] = [
{
code: '😠',
name: 'angry',
@@ -345,11 +346,11 @@ describe('EmojiTest', () => {
it('make sure the most recent new emoji is added to the list even it is full with count > 1', () => {
// Given an existing full (24 items) frequently used emojis list
- const smileEmoji = {code: '😄', name: 'smile'};
- const zzzEmoji = {code: '💤', name: 'zzz'};
- const impEmoji = {code: '👿', name: 'imp'};
- const bookEmoji = {code: '📚', name: 'books'};
- const frequentlyEmojisList = [
+ const smileEmoji: Emoji = {code: '😄', name: 'smile'};
+ const zzzEmoji: Emoji = {code: '💤', name: 'zzz'};
+ const impEmoji: Emoji = {code: '👿', name: 'imp'};
+ const bookEmoji: Emoji = {code: '📚', name: 'books'};
+ const frequentlyEmojisList: FrequentlyUsedEmoji[] = [
{
code: '😠',
name: 'angry',
diff --git a/tests/unit/SidebarFilterTest.js b/tests/unit/SidebarFilterTest.ts
similarity index 84%
rename from tests/unit/SidebarFilterTest.js
rename to tests/unit/SidebarFilterTest.ts
index 148710cb2d25..58ec66698b83 100644
--- a/tests/unit/SidebarFilterTest.js
+++ b/tests/unit/SidebarFilterTest.ts
@@ -1,17 +1,18 @@
import {cleanup, screen} from '@testing-library/react-native';
-import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
-import CONST from '../../src/CONST';
-import DateUtils from '../../src/libs/DateUtils';
-import * as Localize from '../../src/libs/Localize';
+import DateUtils from '@libs/DateUtils';
+import * as Localize from '@libs/Localize';
+import CONST from '@src/CONST';
+import type {Report} from '@src/types/onyx';
+import type {ReportCollectionDataSet} from '@src/types/onyx/Report';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
// Be sure to include the mocked permissions library, as some components that are rendered
// during the test depend on its methods.
-jest.mock('../../src/libs/Permissions');
-jest.mock('../../src/hooks/usePermissions.ts');
+jest.mock('@libs/Permissions');
+jest.mock('@hooks/usePermissions.ts');
const ONYXKEYS = {
PERSONAL_DETAILS_LIST: 'personalDetailsList',
@@ -25,14 +26,13 @@ const ONYXKEYS = {
POLICY: 'policy_',
},
NETWORK: 'network',
-};
+} as const;
// We need to fix this test as a follow up. There seems to be some problems with memory after filtering got more complicated.
xdescribe('Sidebar', () => {
beforeAll(() =>
Onyx.init({
keys: ONYXKEYS,
- registerStorageEventListener: () => {},
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
}),
);
@@ -57,14 +57,14 @@ xdescribe('Sidebar', () => {
// Given a report with no participants
const report = LHNTestUtils.getFakeReport([]);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that report
- .then(() =>
- Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
- }),
- )
+ .then(() => Onyx.multiSet(reportCollectionDataSet))
// Then no reports are rendered in the LHN
.then(() => {
@@ -79,15 +79,19 @@ xdescribe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given a new report
- const report = LHNTestUtils.getFakeReport(['emptychat+1@test.com', 'emptychat+2@test.com'], 0);
+ const report = LHNTestUtils.getFakeReport([1, 2], 0);
+
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that report
.then(() =>
Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
[ONYXKEYS.IS_LOADING_APP]: false,
+ ...reportCollectionDataSet,
}),
)
@@ -104,19 +108,23 @@ xdescribe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given a new report with a draft text
- const report = {
+ const report: Report = {
...LHNTestUtils.getFakeReport([1, 2], 0),
hasDraft: true,
};
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that report
.then(() =>
Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
+ ...reportCollectionDataSet,
}),
)
@@ -134,11 +142,15 @@ xdescribe('Sidebar', () => {
// Given a user created policy room report
// and the user not being in any betas
- const report = {
+ const report: Report = {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
};
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -147,7 +159,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: [],
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ ...reportCollectionDataSet,
}),
)
@@ -165,19 +177,25 @@ xdescribe('Sidebar', () => {
// Given three reports with the three different types of default policy rooms
// and the user not being in any betas
- const report1 = {
+ const report1: Report = {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
};
- const report2 = {
+ const report2: Report = {
...LHNTestUtils.getFakeReport([3, 4]),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE,
};
- const report3 = {
+ const report3: Report = {
...LHNTestUtils.getFakeReport([5, 6]),
chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL,
};
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -186,9 +204,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: [],
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -224,12 +240,16 @@ xdescribe('Sidebar', () => {
policyID: '1',
type: CONST.POLICY.TYPE.FREE,
};
- const report = {
+ const report: Report = {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
policyID: policy.policyID,
};
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -238,8 +258,8 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: [],
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
[`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
+ ...reportCollectionDataSet,
}),
)
@@ -299,7 +319,7 @@ xdescribe('Sidebar', () => {
const AMOUNT_OF_VARIABLES = 6;
// eslint-disable-next-line no-bitwise
for (let i = 0; i < 1 << AMOUNT_OF_VARIABLES; i++) {
- const boolArr = [];
+ const boolArr: boolean[] = [];
for (let j = AMOUNT_OF_VARIABLES - 1; j >= 0; j--) {
// eslint-disable-next-line no-bitwise
boolArr.push(Boolean(i & (1 << j)));
@@ -309,13 +329,19 @@ xdescribe('Sidebar', () => {
// for the specific case that's failing. You can then debug the code to see why the test is not passing.
// const boolArr = [false, false, false, false, false];
- it(`the booleans ${boolArr}`, () => {
- const report2 = {
- ...LHNTestUtils.getAdvancedFakeReport(...boolArr),
+ it(`the booleans ${JSON.stringify(boolArr)}`, () => {
+ const [isArchived, isUserCreatedPolicyRoom, hasAddWorkspaceError, isUnread, isPinned, hasDraft] = boolArr;
+ const report2: Report = {
+ ...LHNTestUtils.getAdvancedFakeReport(isArchived, isUserCreatedPolicyRoom, hasAddWorkspaceError, isUnread, isPinned, hasDraft),
policyID: policy.policyID,
};
LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -325,9 +351,8 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
+ ...reportCollectionDataSet,
}),
)
// Then depending on the outcome, either one or two reports are visible
@@ -339,7 +364,7 @@ xdescribe('Sidebar', () => {
const navigatesToChatHintText = Localize.translateLocal('accessibilityHints.navigatesToChat');
expect(screen.queryAllByAccessibilityHint(navigatesToChatHintText)).toHaveLength(1);
expect(displayNames).toHaveLength(1);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Three, Four');
} else {
// Both reports visible
const navigatesToChatHintText = Localize.translateLocal('accessibilityHints.navigatesToChat');
@@ -361,6 +386,12 @@ xdescribe('Sidebar', () => {
const report3 = LHNTestUtils.getFakeReport([5, 6]);
LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -369,9 +400,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -380,8 +409,8 @@ xdescribe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(2);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('One, Two');
+ expect(displayNames[1].props.children[0]).toBe('Three, Four');
})
// When report3 becomes unread
@@ -432,6 +461,11 @@ xdescribe('Sidebar', () => {
};
LHNTestUtils.getDefaultRenderedSidebarLinks(draftReport.reportID);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${draftReport.reportID}`]: draftReport,
+ [`${ONYXKEYS.COLLECTION.REPORT}${pinnedReport.reportID}`]: pinnedReport,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -440,8 +474,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${draftReport.reportID}`]: draftReport,
- [`${ONYXKEYS.COLLECTION.REPORT}${pinnedReport.reportID}`]: pinnedReport,
+ ...reportCollectionDataSet,
}),
)
@@ -450,26 +483,26 @@ xdescribe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(2);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Three, Four');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('One, Two');
+ expect(displayNames[0].props.children[0]).toBe('Three, Four');
+ expect(displayNames[1].props.children[0]).toBe('One, Two');
})
);
});
it('archived rooms are displayed only when they have unread messages', () => {
// Given an archived chat report, an archived default policy room, and an archived user created policy room
- const archivedReport = {
+ const archivedReport: Report = {
...LHNTestUtils.getFakeReport([1, 2]),
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
};
- const archivedPolicyRoomReport = {
+ const archivedPolicyRoomReport: Report = {
...LHNTestUtils.getFakeReport([1, 2]),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE,
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
};
- const archivedUserCreatedPolicyRoomReport = {
+ const archivedUserCreatedPolicyRoomReport: Report = {
...LHNTestUtils.getFakeReport([1, 2]),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
@@ -479,6 +512,12 @@ xdescribe('Sidebar', () => {
const betas = [CONST.BETAS.DEFAULT_ROOMS];
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${archivedReport.reportID}`]: archivedReport,
+ [`${ONYXKEYS.COLLECTION.REPORT}${archivedPolicyRoomReport.reportID}`]: archivedPolicyRoomReport,
+ [`${ONYXKEYS.COLLECTION.REPORT}${archivedUserCreatedPolicyRoomReport.reportID}`]: archivedUserCreatedPolicyRoomReport,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -488,9 +527,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${archivedReport.reportID}`]: archivedReport,
- [`${ONYXKEYS.COLLECTION.REPORT}${archivedPolicyRoomReport.reportID}`]: archivedPolicyRoomReport,
- [`${ONYXKEYS.COLLECTION.REPORT}${archivedUserCreatedPolicyRoomReport.reportID}`]: archivedUserCreatedPolicyRoomReport,
+ ...reportCollectionDataSet,
}),
)
@@ -530,11 +567,11 @@ xdescribe('Sidebar', () => {
it('policy rooms are displayed only when they have unread messages', () => {
// Given a default policy room and user created policy room
- const policyRoomReport = {
+ const policyRoomReport: Report = {
...LHNTestUtils.getFakeReport([1, 2]),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE,
};
- const userCreatedPolicyRoomReport = {
+ const userCreatedPolicyRoomReport: Report = {
...LHNTestUtils.getFakeReport([1, 2]),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
};
@@ -542,6 +579,11 @@ xdescribe('Sidebar', () => {
const betas = [CONST.BETAS.DEFAULT_ROOMS];
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${policyRoomReport.reportID}`]: policyRoomReport,
+ [`${ONYXKEYS.COLLECTION.REPORT}${userCreatedPolicyRoomReport.reportID}`]: userCreatedPolicyRoomReport,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -551,8 +593,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${policyRoomReport.reportID}`]: policyRoomReport,
- [`${ONYXKEYS.COLLECTION.REPORT}${userCreatedPolicyRoomReport.reportID}`]: userCreatedPolicyRoomReport,
+ ...reportCollectionDataSet,
}),
)
@@ -622,7 +663,7 @@ xdescribe('Sidebar', () => {
const AMOUNT_OF_VARIABLES = 6;
// eslint-disable-next-line no-bitwise
for (let i = 0; i < 1 << AMOUNT_OF_VARIABLES; i++) {
- const boolArr = [];
+ const boolArr: boolean[] = [];
for (let j = AMOUNT_OF_VARIABLES - 1; j >= 0; j--) {
// eslint-disable-next-line no-bitwise
boolArr.push(Boolean(i & (1 << j)));
@@ -633,12 +674,18 @@ xdescribe('Sidebar', () => {
// const boolArr = [false, false, false, true, false, false, false];
it(`the booleans ${JSON.stringify(boolArr)}`, () => {
+ const [isArchived, isUserCreatedPolicyRoom, hasAddWorkspaceError, isUnread, isPinned, hasDraft] = boolArr;
const report2 = {
- ...LHNTestUtils.getAdvancedFakeReport(...boolArr),
+ ...LHNTestUtils.getAdvancedFakeReport(isArchived, isUserCreatedPolicyRoom, hasAddWorkspaceError, isUnread, isPinned, hasDraft),
policyID: policy.policyID,
};
LHNTestUtils.getDefaultRenderedSidebarLinks(report1.reportID);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -648,9 +695,8 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
+ ...reportCollectionDataSet,
}),
)
@@ -663,7 +709,7 @@ xdescribe('Sidebar', () => {
const navigatesToChatHintText = Localize.translateLocal('accessibilityHints.navigatesToChat');
expect(screen.queryAllByAccessibilityHint(navigatesToChatHintText)).toHaveLength(1);
expect(displayNames).toHaveLength(1);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Three, Four');
} else {
// Both reports visible
const navigatesToChatHintText = Localize.translateLocal('accessibilityHints.navigatesToChat');
@@ -681,7 +727,7 @@ xdescribe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given an archived report with no comments
- const report = {
+ const report: Report = {
...LHNTestUtils.getFakeReport(),
lastVisibleActionCreated: '2022-11-22 03:48:27.267',
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
@@ -691,6 +737,10 @@ xdescribe('Sidebar', () => {
// Given the user is in all betas
const betas = [CONST.BETAS.DEFAULT_ROOMS];
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -700,7 +750,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ ...reportCollectionDataSet,
}),
)
@@ -733,7 +783,7 @@ xdescribe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given an archived report that has all comments read
- const report = {
+ const report: Report = {
...LHNTestUtils.getFakeReport(),
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
@@ -742,6 +792,10 @@ xdescribe('Sidebar', () => {
// Given the user is in all betas
const betas = [CONST.BETAS.DEFAULT_ROOMS];
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -751,7 +805,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ ...reportCollectionDataSet,
}),
)
@@ -781,7 +835,7 @@ xdescribe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given an archived report that is not pinned
- const report = {
+ const report: Report = {
...LHNTestUtils.getFakeReport(),
isPinned: false,
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
@@ -791,6 +845,10 @@ xdescribe('Sidebar', () => {
// Given the user is in all betas
const betas = [CONST.BETAS.DEFAULT_ROOMS];
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -800,7 +858,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ ...reportCollectionDataSet,
}),
)
@@ -827,7 +885,7 @@ xdescribe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given an archived report that is not the active report
- const report = {
+ const report: Report = {
...LHNTestUtils.getFakeReport(),
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
@@ -836,6 +894,10 @@ xdescribe('Sidebar', () => {
// Given the user is in all betas
const betas = [CONST.BETAS.DEFAULT_ROOMS];
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated to contain that data and the sidebar re-renders
@@ -845,7 +907,7 @@ xdescribe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ ...reportCollectionDataSet,
}),
)
diff --git a/tests/unit/SidebarOrderTest.js b/tests/unit/SidebarOrderTest.ts
similarity index 73%
rename from tests/unit/SidebarOrderTest.js
rename to tests/unit/SidebarOrderTest.ts
index 7ae8c4e1e9b3..27da8348f43d 100644
--- a/tests/unit/SidebarOrderTest.js
+++ b/tests/unit/SidebarOrderTest.ts
@@ -1,18 +1,19 @@
import {cleanup, screen} from '@testing-library/react-native';
-import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
-import CONST from '../../src/CONST';
-import * as Report from '../../src/libs/actions/Report';
-import DateUtils from '../../src/libs/DateUtils';
-import * as Localize from '../../src/libs/Localize';
+import * as Report from '@libs/actions/Report';
+import DateUtils from '@libs/DateUtils';
+import * as Localize from '@libs/Localize';
+import CONST from '@src/CONST';
+import type * as OnyxTypes from '@src/types/onyx';
+import type {ReportCollectionDataSet} from '@src/types/onyx/Report';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
// Be sure to include the mocked Permissions and Expensicons libraries as well as the usePermissions hook or else the beta tests won't work
-jest.mock('../../src/libs/Permissions');
-jest.mock('../../src/hooks/usePermissions.ts');
-jest.mock('../../src/components/Icon/Expensicons');
+jest.mock('@libs/Permissions');
+jest.mock('@hooks/usePermissions.ts');
+jest.mock('@components/Icon/Expensicons');
const ONYXKEYS = {
PERSONAL_DETAILS_LIST: 'personalDetailsList',
@@ -23,15 +24,16 @@ const ONYXKEYS = {
COLLECTION: {
REPORT: 'report_',
REPORT_ACTIONS: 'reportActions_',
+ POLICY: 'policy_',
},
NETWORK: 'network',
-};
+ IS_LOADING_REPORT_DATA: 'isLoadingReportData',
+} as const;
describe('Sidebar', () => {
beforeAll(() =>
Onyx.init({
keys: ONYXKEYS,
- registerStorageEventListener: () => {},
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
}),
);
@@ -88,6 +90,10 @@ describe('Sidebar', () => {
const report = LHNTestUtils.getFakeReport([1, 2]);
LHNTestUtils.getDefaultRenderedSidebarLinks(report.reportID);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -96,7 +102,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ ...reportCollectionDataSet,
}),
)
@@ -120,6 +126,12 @@ describe('Sidebar', () => {
Report.addComment(report2.reportID, 'Hi, this is a comment');
Report.addComment(report3.reportID, 'Hi, this is a comment');
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -128,9 +140,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -140,9 +150,9 @@ describe('Sidebar', () => {
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Three, Four');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('One, Two');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('Three, Four');
+ expect(displayNames[2].props.children[0]).toBe('One, Two');
})
);
});
@@ -166,6 +176,12 @@ describe('Sidebar', () => {
const currentReportId = report1.reportID;
LHNTestUtils.getDefaultRenderedSidebarLinks(currentReportId);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -174,9 +190,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -189,9 +203,9 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('One, Two'); // this has `hasDraft` flag enabled so it will be on top
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('One, Two'); // this has `hasDraft` flag enabled so it will be on top
+ expect(displayNames[1].props.children[0]).toBe('Five, Six');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
})
);
});
@@ -209,6 +223,12 @@ describe('Sidebar', () => {
Report.addComment(report2.reportID, 'Hi, this is a comment');
Report.addComment(report3.reportID, 'Hi, this is a comment');
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -217,9 +237,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -236,9 +254,9 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('One, Two');
+ expect(displayNames[1].props.children[0]).toBe('Five, Six');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
})
);
});
@@ -250,7 +268,7 @@ describe('Sidebar', () => {
const report3 = LHNTestUtils.getFakeReport([5, 6], 2);
const taskReportName = 'Buy Grocery';
- const taskReport = {
+ const taskReport: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([7, 8], 1),
type: CONST.REPORT.TYPE.TASK,
reportName: taskReportName,
@@ -266,6 +284,13 @@ describe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks(taskReport.reportID);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ [`${ONYXKEYS.COLLECTION.REPORT}${taskReport.reportID}`]: taskReport,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -274,10 +299,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
- [`${ONYXKEYS.COLLECTION.REPORT}${taskReport.reportID}`]: taskReport,
+ ...reportCollectionDataSet,
}),
)
@@ -286,10 +308,10 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(4);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe(taskReportName);
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
- expect(lodashGet(displayNames, [3, 'props', 'children', 0])).toBe('One, Two');
+ expect(displayNames[0].props.children[0]).toBe(taskReportName);
+ expect(displayNames[1].props.children[0]).toBe('Five, Six');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
+ expect(displayNames[3].props.children[0]).toBe('One, Two');
})
);
});
@@ -298,14 +320,14 @@ describe('Sidebar', () => {
// Given three reports in the recently updated order of 3, 2, 1
const report1 = LHNTestUtils.getFakeReport([1, 2], 4);
const report2 = LHNTestUtils.getFakeReport([3, 4], 3);
- const report3 = {
+ const report3: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([5, 6], 2),
hasOutstandingChildRequest: false,
// This has to be added after the IOU report is generated
- iouReportID: null,
+ iouReportID: undefined,
};
- const iouReport = {
+ const iouReport: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([7, 8], 1),
type: CONST.REPORT.TYPE.IOU,
ownerAccountID: 2,
@@ -326,6 +348,13 @@ describe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks(report3.reportID);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ [`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`]: iouReport,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -334,10 +363,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
- [`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`]: iouReport,
+ ...reportCollectionDataSet,
}),
)
@@ -346,10 +372,10 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(4);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Email Two owes $100.00');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
- expect(lodashGet(displayNames, [3, 'props', 'children', 0])).toBe('One, Two');
+ expect(displayNames[0].props.children[0]).toBe('Email Two owes $100.00');
+ expect(displayNames[1].props.children[0]).toBe('Five, Six');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
+ expect(displayNames[3].props.children[0]).toBe('One, Two');
})
);
});
@@ -360,14 +386,14 @@ describe('Sidebar', () => {
const report2 = LHNTestUtils.getFakeReport([3, 4], 3);
const fakeReport = LHNTestUtils.getFakeReportWithPolicy([5, 6], 2);
const fakePolicy = LHNTestUtils.getFakePolicy(fakeReport.policyID);
- const report3 = {
+ const report3: OnyxTypes.Report = {
...fakeReport,
hasOutstandingChildRequest: false,
// This has to be added after the IOU report is generated
- iouReportID: null,
+ iouReportID: undefined,
};
- const expenseReport = {
+ const expenseReport: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([7, 8], 1),
type: CONST.REPORT.TYPE.EXPENSE,
ownerAccountID: 7,
@@ -389,6 +415,13 @@ describe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks(report3.reportID);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ [`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`]: expenseReport,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -398,10 +431,7 @@ describe('Sidebar', () => {
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.POLICY}${fakeReport.policyID}`]: fakePolicy,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
- [`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`]: expenseReport,
+ ...reportCollectionDataSet,
}),
)
@@ -410,10 +440,10 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(4);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Workspace owes $100.00');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Email Five');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
- expect(lodashGet(displayNames, [3, 'props', 'children', 0])).toBe('One, Two');
+ expect(displayNames[0].props.children[0]).toBe('Workspace owes $100.00');
+ expect(displayNames[1].props.children[0]).toBe('Email Five');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
+ expect(displayNames[3].props.children[0]).toBe('One, Two');
})
);
});
@@ -423,7 +453,7 @@ describe('Sidebar', () => {
// And the second report has a draft
// And the currently viewed report is the second report
const report1 = LHNTestUtils.getFakeReport([1, 2], 3);
- const report2 = {
+ const report2: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([3, 4], 2),
hasDraft: true,
};
@@ -437,6 +467,12 @@ describe('Sidebar', () => {
const currentReportId = report2.reportID;
LHNTestUtils.getDefaultRenderedSidebarLinks(currentReportId);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -445,9 +481,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -465,9 +499,9 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Three, Four');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('One, Two');
+ expect(displayNames[0].props.children[0]).toBe('Three, Four');
+ expect(displayNames[1].props.children[0]).toBe('Five, Six');
+ expect(displayNames[2].props.children[0]).toBe('One, Two');
})
);
});
@@ -477,11 +511,15 @@ describe('Sidebar', () => {
// Given a single report
// And the report has a draft
- const report = {
+ const report: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([1, 2]),
hasDraft: true,
};
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -490,7 +528,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ ...reportCollectionDataSet,
}),
)
@@ -514,11 +552,15 @@ describe('Sidebar', () => {
// Given a single report
// And the report is pinned
- const report = {
+ const report: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([1, 2]),
isPinned: true,
};
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -527,7 +569,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ ...reportCollectionDataSet,
}),
)
@@ -551,22 +593,22 @@ describe('Sidebar', () => {
// with the current user set to email9@ (someone not participating in any of the chats)
// with a report that has a draft, a report that is pinned, and
// an outstanding IOU report that doesn't belong to the current user
- const report1 = {
+ const report1: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([1, 2], 3),
isPinned: true,
};
- const report2 = {
+ const report2: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([3, 4], 2),
hasDraft: true,
};
- const report3 = {
+ const report3: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([5, 6], 1),
hasOutstandingChildRequest: false,
// This has to be added after the IOU report is generated
- iouReportID: null,
+ iouReportID: undefined,
};
- const iouReport = {
+ const iouReport: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([7, 8]),
type: CONST.REPORT.TYPE.IOU,
ownerAccountID: 2,
@@ -579,9 +621,19 @@ describe('Sidebar', () => {
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};
report3.iouReportID = iouReport.reportID;
+
const currentReportId = report2.reportID;
const currentlyLoggedInUserAccountID = 9;
+
LHNTestUtils.getDefaultRenderedSidebarLinks(currentReportId);
+
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ [`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`]: iouReport,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -591,10 +643,7 @@ describe('Sidebar', () => {
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
[ONYXKEYS.SESSION]: {accountID: currentlyLoggedInUserAccountID},
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
- [`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`]: iouReport,
+ ...reportCollectionDataSet,
}),
)
@@ -607,9 +656,9 @@ describe('Sidebar', () => {
expect(displayNames).toHaveLength(3);
expect(screen.queryAllByTestId('Pin Icon')).toHaveLength(1);
expect(screen.queryAllByTestId('Pencil Icon')).toHaveLength(1);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Email Two owes $100.00');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Email Two owes $100.00');
+ expect(displayNames[1].props.children[0]).toBe('One, Two');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
})
);
});
@@ -617,23 +666,31 @@ describe('Sidebar', () => {
it('alphabetizes all the chats that are pinned', () => {
// Given three reports in the recently updated order of 3, 2, 1
// and they are all pinned
- const report1 = {
+ const report1: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([1, 2], 3),
isPinned: true,
};
- const report2 = {
+ const report2: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([3, 4], 2),
isPinned: true,
};
- const report3 = {
+ const report3: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([5, 6], 1),
isPinned: true,
};
- const report4 = {
+ const report4: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([7, 8], 0),
isPinned: true,
};
+
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -642,9 +699,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -653,9 +708,9 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('One, Two');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
})
// When a new report is added
@@ -666,10 +721,10 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(4);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Seven, Eight');
- expect(lodashGet(displayNames, [3, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('One, Two');
+ expect(displayNames[2].props.children[0]).toBe('Seven, Eight');
+ expect(displayNames[3].props.children[0]).toBe('Three, Four');
})
);
});
@@ -677,23 +732,31 @@ describe('Sidebar', () => {
it('alphabetizes all the chats that have drafts', () => {
// Given three reports in the recently updated order of 3, 2, 1
// and they all have drafts
- const report1 = {
+ const report1: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([1, 2], 3),
hasDraft: true,
};
- const report2 = {
+ const report2: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([3, 4], 2),
hasDraft: true,
};
- const report3 = {
+ const report3: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([5, 6], 1),
hasDraft: true,
};
- const report4 = {
+ const report4: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([7, 8], 0),
hasDraft: true,
};
+
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -702,9 +765,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -713,9 +774,9 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('One, Two');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
})
// When a new report is added
@@ -726,17 +787,17 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(4);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Seven, Eight');
- expect(lodashGet(displayNames, [3, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('One, Two');
+ expect(displayNames[2].props.children[0]).toBe('Seven, Eight');
+ expect(displayNames[3].props.children[0]).toBe('Three, Four');
})
);
});
it('puts archived chats last', () => {
// Given three reports, with the first report being archived
- const report1 = {
+ const report1: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([1, 2]),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
@@ -752,7 +813,15 @@ describe('Sidebar', () => {
// Given the user is in all betas
const betas = [CONST.BETAS.DEFAULT_ROOMS];
+
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -762,9 +831,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -773,9 +840,9 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Three, Four');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Report (archived)');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('Three, Four');
+ expect(displayNames[2].props.children[0]).toBe('Report (archived)');
})
);
});
@@ -790,6 +857,12 @@ describe('Sidebar', () => {
const report3 = LHNTestUtils.getFakeReport([5, 6], 1, true);
const report4 = LHNTestUtils.getFakeReport([7, 8], 0, true);
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// Given the sidebar is rendered in #focus mode (hides read chats)
@@ -799,9 +872,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -810,9 +881,9 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('One, Two');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
})
// When a new report is added
@@ -823,10 +894,10 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(4);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Seven, Eight');
- expect(lodashGet(displayNames, [3, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('One, Two');
+ expect(displayNames[2].props.children[0]).toBe('Seven, Eight');
+ expect(displayNames[3].props.children[0]).toBe('Three, Four');
})
);
});
@@ -844,7 +915,15 @@ describe('Sidebar', () => {
// Given the user is in all betas
const betas = [CONST.BETAS.DEFAULT_ROOMS];
+
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -854,9 +933,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -865,46 +942,46 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Three, Four');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Report (archived)');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('Three, Four');
+ expect(displayNames[2].props.children[0]).toBe('Report (archived)');
})
);
});
it('orders IOU reports by displayName if amounts are the same', () => {
// Given three IOU reports containing the same IOU amounts
- const report1 = {
+ const report1: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([1, 2]),
// This has to be added after the IOU report is generated
- iouReportID: null,
+ iouReportID: undefined,
};
- const report2 = {
+ const report2: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([3, 4]),
// This has to be added after the IOU report is generated
- iouReportID: null,
+ iouReportID: undefined,
};
- const report3 = {
+ const report3: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([5, 6]),
// This has to be added after the IOU report is generated
- iouReportID: null,
+ iouReportID: undefined,
};
- const report4 = {
+ const report4: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([5, 6]),
// This has to be added after the IOU report is generated
- iouReportID: null,
+ iouReportID: undefined,
};
- const report5 = {
+ const report5: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([5, 6]),
// This has to be added after the IOU report is generated
- iouReportID: null,
+ iouReportID: undefined,
};
- const iouReport1 = {
+ const iouReport1: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([7, 8]),
type: CONST.REPORT.TYPE.IOU,
ownerAccountID: 2,
@@ -916,7 +993,7 @@ describe('Sidebar', () => {
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};
- const iouReport2 = {
+ const iouReport2: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([9, 10]),
type: CONST.REPORT.TYPE.IOU,
ownerAccountID: 2,
@@ -928,7 +1005,7 @@ describe('Sidebar', () => {
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};
- const iouReport3 = {
+ const iouReport3: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([11, 12]),
type: CONST.REPORT.TYPE.IOU,
ownerAccountID: 2,
@@ -940,7 +1017,7 @@ describe('Sidebar', () => {
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};
- const iouReport4 = {
+ const iouReport4: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([11, 12]),
type: CONST.REPORT.TYPE.IOU,
ownerAccountID: 2,
@@ -952,7 +1029,7 @@ describe('Sidebar', () => {
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};
- const iouReport5 = {
+ const iouReport5: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([11, 12]),
type: CONST.REPORT.TYPE.IOU,
ownerAccountID: 2,
@@ -972,7 +1049,22 @@ describe('Sidebar', () => {
report5.iouReportID = iouReport5.reportID;
const currentlyLoggedInUserAccountID = 13;
+
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report4.reportID}`]: report4,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report5.reportID}`]: report5,
+ [`${ONYXKEYS.COLLECTION.REPORT}${iouReport1.reportID}`]: iouReport1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${iouReport2.reportID}`]: iouReport2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${iouReport3.reportID}`]: iouReport3,
+ [`${ONYXKEYS.COLLECTION.REPORT}${iouReport4.reportID}`]: iouReport4,
+ [`${ONYXKEYS.COLLECTION.REPORT}${iouReport5.reportID}`]: iouReport5,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -982,16 +1074,7 @@ describe('Sidebar', () => {
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
[ONYXKEYS.SESSION]: {accountID: currentlyLoggedInUserAccountID},
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
- [`${ONYXKEYS.COLLECTION.REPORT}${report4.reportID}`]: report4,
- [`${ONYXKEYS.COLLECTION.REPORT}${report5.reportID}`]: report5,
- [`${ONYXKEYS.COLLECTION.REPORT}${iouReport1.reportID}`]: iouReport1,
- [`${ONYXKEYS.COLLECTION.REPORT}${iouReport2.reportID}`]: iouReport2,
- [`${ONYXKEYS.COLLECTION.REPORT}${iouReport3.reportID}`]: iouReport3,
- [`${ONYXKEYS.COLLECTION.REPORT}${iouReport4.reportID}`]: iouReport4,
- [`${ONYXKEYS.COLLECTION.REPORT}${iouReport5.reportID}`]: iouReport5,
+ ...reportCollectionDataSet,
}),
)
@@ -1000,11 +1083,11 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(5);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Email Five owes $100.00');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('Email Four owes $1,000.00');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Email Six owes $100.00');
- expect(lodashGet(displayNames, [3, 'props', 'children', 0])).toBe('Email Three owes $100.00');
- expect(lodashGet(displayNames, [4, 'props', 'children', 0])).toBe('Email Two owes $100.00');
+ expect(displayNames[0].props.children[0]).toBe('Email Five owes $100.00');
+ expect(displayNames[1].props.children[0]).toBe('Email Four owes $1,000.00');
+ expect(displayNames[2].props.children[0]).toBe('Email Six owes $100.00');
+ expect(displayNames[3].props.children[0]).toBe('Email Three owes $100.00');
+ expect(displayNames[4].props.children[0]).toBe('Email Two owes $100.00');
})
);
});
@@ -1012,15 +1095,15 @@ describe('Sidebar', () => {
it('orders nonArchived reports by displayName if created timestamps are the same', () => {
// Given three nonArchived reports created at the same time
const lastVisibleActionCreated = DateUtils.getDBTime();
- const report1 = {
+ const report1: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([1, 2]),
lastVisibleActionCreated,
};
- const report2 = {
+ const report2: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([3, 4]),
lastVisibleActionCreated,
};
- const report3 = {
+ const report3: OnyxTypes.Report = {
...LHNTestUtils.getFakeReport([5, 6]),
lastVisibleActionCreated,
};
@@ -1031,6 +1114,13 @@ describe('Sidebar', () => {
Report.addComment(report3.reportID, 'Hi, this is a comment');
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
+
+ const reportCollectionDataSet: ReportCollectionDataSet = {
+ [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ };
+
return (
waitForBatchedUpdates()
// When Onyx is updated with the data and the sidebar re-renders
@@ -1039,9 +1129,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
- [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
- [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ ...reportCollectionDataSet,
}),
)
@@ -1050,9 +1138,9 @@ describe('Sidebar', () => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(3);
- expect(lodashGet(displayNames, [0, 'props', 'children', 0])).toBe('Five, Six');
- expect(lodashGet(displayNames, [1, 'props', 'children', 0])).toBe('One, Two');
- expect(lodashGet(displayNames, [2, 'props', 'children', 0])).toBe('Three, Four');
+ expect(displayNames[0].props.children[0]).toBe('Five, Six');
+ expect(displayNames[1].props.children[0]).toBe('One, Two');
+ expect(displayNames[2].props.children[0]).toBe('Three, Four');
})
);
});
diff --git a/tests/unit/loginTest.js b/tests/unit/loginTest.tsx
similarity index 84%
rename from tests/unit/loginTest.js
rename to tests/unit/loginTest.tsx
index b63564ff2812..d5084299bb08 100644
--- a/tests/unit/loginTest.js
+++ b/tests/unit/loginTest.tsx
@@ -1,14 +1,12 @@
-/**
- * @format
- */
import React from 'react';
import 'react-native';
// Note: `react-test-renderer` renderer must be required after react-native.
import renderer from 'react-test-renderer';
-import App from '../../src/App';
+import App from '@src/App';
// Needed for: https://stackoverflow.com/questions/76903168/mocking-libraries-in-jest
jest.mock('react-native/Libraries/LogBox/LogBox', () => ({
+ // eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
default: {
ignoreLogs: jest.fn(),
diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.tsx
similarity index 68%
rename from tests/utils/LHNTestUtils.js
rename to tests/utils/LHNTestUtils.tsx
index d44a63d51821..80f28002f975 100644
--- a/tests/utils/LHNTestUtils.js
+++ b/tests/utils/LHNTestUtils.tsx
@@ -1,21 +1,41 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type {NavigationProp} from '@react-navigation/core/src/types';
+import type * as Navigation from '@react-navigation/native';
+import type {ParamListBase} from '@react-navigation/routers';
import {render} from '@testing-library/react-native';
-import PropTypes from 'prop-types';
+import type {ReactElement} from 'react';
import React from 'react';
-import ComposeProviders from '../../src/components/ComposeProviders';
-import {LocaleContextProvider} from '../../src/components/LocaleContextProvider';
-import OnyxProvider from '../../src/components/OnyxProvider';
-import {CurrentReportIDContextProvider} from '../../src/components/withCurrentReportID';
-import {EnvironmentProvider} from '../../src/components/withEnvironment';
-import CONST from '../../src/CONST';
-import DateUtils from '../../src/libs/DateUtils';
-import ReportActionItemSingle from '../../src/pages/home/report/ReportActionItemSingle';
-import reportActionPropTypes from '../../src/pages/home/report/reportActionPropTypes';
-import SidebarLinksData from '../../src/pages/home/sidebar/SidebarLinksData';
-import reportPropTypes from '../../src/pages/reportPropTypes';
+import ComposeProviders from '@components/ComposeProviders';
+import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import OnyxProvider from '@components/OnyxProvider';
+import {CurrentReportIDContextProvider} from '@components/withCurrentReportID';
+import {EnvironmentProvider} from '@components/withEnvironment';
+import DateUtils from '@libs/DateUtils';
+import ReportActionItemSingle from '@pages/home/report/ReportActionItemSingle';
+import SidebarLinksData from '@pages/home/sidebar/SidebarLinksData';
+import CONST from '@src/CONST';
+import type {PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx';
+import type {ActionName} from '@src/types/onyx/OriginalMessage';
+
+type MockedReportActionItemSingleProps = {
+ /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */
+ shouldShowSubscriptAvatar?: boolean;
+
+ /** Report for this action */
+ report: Report;
+
+ /** All the data of the action */
+ reportAction: ReportAction;
+};
+
+type MockedSidebarLinksProps = {
+ /** Current report id */
+ currentReportID?: string;
+};
// we have to mock `useIsFocused` because it's used in the SidebarLinks component
-const mockedNavigate = jest.fn();
-jest.mock('@react-navigation/native', () => {
+const mockedNavigate: jest.MockedFn['navigate']> = jest.fn();
+jest.mock('@react-navigation/native', (): typeof Navigation => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
@@ -28,10 +48,10 @@ jest.mock('@react-navigation/native', () => {
addListener: jest.fn(),
}),
createNavigationContainerRef: jest.fn(),
- };
+ } as typeof Navigation;
});
-const fakePersonalDetails = {
+const fakePersonalDetails: PersonalDetailsList = {
1: {
accountID: 1,
login: 'email1@test.com',
@@ -101,13 +121,11 @@ let lastFakeReportID = 0;
let lastFakeReportActionID = 0;
/**
- * @param {Number[]} participantAccountIDs
- * @param {Number} millisecondsInThePast the number of milliseconds in the past for the last message timestamp (to order reports by most recent messages)
- * @param {boolean} isUnread
- * @returns {Object}
+ * @param millisecondsInThePast the number of milliseconds in the past for the last message timestamp (to order reports by most recent messages)
*/
-function getFakeReport(participantAccountIDs = [1, 2], millisecondsInThePast = 0, isUnread = false) {
+function getFakeReport(participantAccountIDs = [1, 2], millisecondsInThePast = 0, isUnread = false): Report {
const lastVisibleActionCreated = DateUtils.getDBTime(Date.now() - millisecondsInThePast);
+
return {
type: CONST.REPORT.TYPE.CHAT,
reportID: `${++lastFakeReportID}`,
@@ -119,12 +137,11 @@ function getFakeReport(participantAccountIDs = [1, 2], millisecondsInThePast = 0
}
/**
- * @param {String} actor
- * @param {Number} millisecondsInThePast the number of milliseconds in the past for the last message timestamp (to order reports by most recent messages)
- * @returns {Object}
+ * @param millisecondsInThePast the number of milliseconds in the past for the last message timestamp (to order reports by most recent messages)
*/
-function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast = 0) {
- const timestamp = DateUtils.getDBTime(Date.now() - millisecondsInThePast);
+function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast = 0): ReportAction {
+ const timestamp = Date.now() - millisecondsInThePast;
+ const created = DateUtils.getDBTime(timestamp);
return {
actor,
@@ -132,6 +149,7 @@ function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast =
reportActionID: `${++lastFakeReportActionID}`,
actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
shouldShow: true,
+ created,
timestamp,
reportActionTimestamp: timestamp,
person: [
@@ -195,35 +213,23 @@ function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast =
};
}
-/**
- * @param {boolean} isArchived
- * @param {boolean} isUserCreatedPolicyRoom
- * @param {boolean} hasAddWorkspaceError
- * @param {boolean} isUnread
- * @param {boolean} isPinned
- * @param {boolean} hasDraft
- * @returns {Object}
- */
-function getAdvancedFakeReport(isArchived, isUserCreatedPolicyRoom, hasAddWorkspaceError, isUnread, isPinned, hasDraft) {
+function getAdvancedFakeReport(isArchived: boolean, isUserCreatedPolicyRoom: boolean, hasAddWorkspaceError: boolean, isUnread: boolean, isPinned: boolean, hasDraft: boolean): Report {
return {
...getFakeReport([1, 2], 0, isUnread),
type: CONST.REPORT.TYPE.CHAT,
chatType: isUserCreatedPolicyRoom ? CONST.REPORT.CHAT_TYPE.POLICY_ROOM : CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
statusNum: isArchived ? CONST.REPORT.STATUS_NUM.CLOSED : 0,
stateNum: isArchived ? CONST.REPORT.STATE_NUM.APPROVED : 0,
- errorFields: hasAddWorkspaceError ? {addWorkspaceRoom: 'blah'} : null,
+ errorFields: hasAddWorkspaceError ? {1708946640843000: {addWorkspaceRoom: 'blah'}} : undefined,
isPinned,
hasDraft,
};
}
/**
- * @param {Number[]} [participantAccountIDs]
- * @param {Number} [millisecondsInThePast] the number of milliseconds in the past for the last message timestamp (to order reports by most recent messages)
- * @param {boolean} [isUnread]
- * @returns {Object}
+ * @param millisecondsInThePast the number of milliseconds in the past for the last message timestamp (to order reports by most recent messages)
*/
-function getFakeReportWithPolicy(participantAccountIDs = [1, 2], millisecondsInThePast = 0, isUnread = false) {
+function getFakeReportWithPolicy(participantAccountIDs = [1, 2], millisecondsInThePast = 0, isUnread = false): Report {
return {
...getFakeReport(participantAccountIDs, millisecondsInThePast, isUnread),
type: CONST.REPORT.TYPE.CHAT,
@@ -235,12 +241,7 @@ function getFakeReportWithPolicy(participantAccountIDs = [1, 2], millisecondsInT
};
}
-/**
- * @param {Number} [id]
- * @param {String} [name]
- * @returns {Object}
- */
-function getFakePolicy(id = 1, name = 'Workspace-Test-001') {
+function getFakePolicy(id = '1', name = 'Workspace-Test-001'): Policy {
return {
id,
name,
@@ -252,7 +253,7 @@ function getFakePolicy(id = 1, name = 'Workspace-Test-001') {
avatar: '',
employeeList: [],
isPolicyExpenseChatEnabled: true,
- lastModified: 1697323926777105,
+ lastModified: '1697323926777105',
autoReporting: true,
autoReportingFrequency: 'immediate',
harvesting: {
@@ -268,47 +269,20 @@ function getFakePolicy(id = 1, name = 'Workspace-Test-001') {
}
/**
- * @param {String} actionName
- * @param {String} actor
- * @param {Number} millisecondsInThePast the number of milliseconds in the past for the last message timestamp (to order reports by most recent messages)
- * @returns {Object}
+ * @param millisecondsInThePast the number of milliseconds in the past for the last message timestamp (to order reports by most recent messages)
*/
-function getFakeAdvancedReportAction(actionName = 'IOU', actor = 'email1@test.com', millisecondsInThePast = 0) {
+function getFakeAdvancedReportAction(actionName: ActionName = 'IOU', actor = 'email1@test.com', millisecondsInThePast = 0): ReportAction {
return {
...getFakeReportAction(actor, millisecondsInThePast),
actionName,
- };
+ } as ReportAction;
}
-/**
- * @param {String} [currentReportID]
- */
-function getDefaultRenderedSidebarLinks(currentReportID = '') {
- // A try-catch block needs to be added to the rendering so that any errors that happen while the component
- // renders are caught and logged to the console. Without the try-catch block, Jest might only report the error
- // as "The above error occurred in your component", without providing specific details. By using a try-catch block,
- // any errors are caught and logged, allowing you to identify the exact error that might be causing a rendering issue
- // when developing tests.
-
- try {
- // Wrap the SideBarLinks inside of LocaleContextProvider so that all the locale props
- // are passed to the component. If this is not done, then all the locale props are missing
- // and there are a lot of render warnings. It needs to be done like this because normally in
- // our app (App.js) is when the react application is wrapped in the context providers
- render();
- } catch (error) {
- console.error(error);
- }
-}
-
-/**
- * @param {String} [currentReportID]
- * @returns {JSX.Element}
- */
-function MockedSidebarLinks({currentReportID}) {
+function MockedSidebarLinks({currentReportID = ''}: MockedSidebarLinksProps) {
return (
{}}
insets={{
top: 0,
@@ -323,18 +297,7 @@ function MockedSidebarLinks({currentReportID}) {
);
}
-MockedSidebarLinks.propTypes = {
- currentReportID: PropTypes.string,
-};
-
-MockedSidebarLinks.defaultProps = {
- currentReportID: '',
-};
-
-/**
- * @param {React.ReactElement} component
- */
-function internalRender(component) {
+function getDefaultRenderedSidebarLinks(currentReportID = '') {
// A try-catch block needs to be added to the rendering so that any errors that happen while the component
// renders are caught and logged to the console. Without the try-catch block, Jest might only report the error
// as "The above error occurred in your component", without providing specific details. By using a try-catch block,
@@ -342,54 +305,36 @@ function internalRender(component) {
// when developing tests.
try {
- render(component);
+ // Wrap the SideBarLinks inside of LocaleContextProvider so that all the locale props
+ // are passed to the component. If this is not done, then all the locale props are missing
+ // and there are a lot of render warnings. It needs to be done like this because normally in
+ // our app (App.js) is when the react application is wrapped in the context providers
+ render();
} catch (error) {
console.error(error);
}
}
-/**
- * @param {Boolean} [shouldShowSubscriptAvatar]
- * @param {Object} [report]
- * @param {Object} [reportAction]
- */
-function getDefaultRenderedReportActionItemSingle(shouldShowSubscriptAvatar = true, report = null, reportAction = null) {
- const currentReport = report || getFakeReport();
- const currentReportAction = reportAction || getFakeAdvancedReportAction();
+function internalRender(component: ReactElement) {
+ // A try-catch block needs to be added to the rendering so that any errors that happen while the component
+ // renders are caught and logged to the console. Without the try-catch block, Jest might only report the error
+ // as "The above error occurred in your component", without providing specific details. By using a try-catch block,
+ // any errors are caught and logged, allowing you to identify the exact error that might be causing a rendering issue
+ // when developing tests.
- internalRender(
- ,
- );
+ try {
+ render(component);
+ } catch (error) {
+ console.error(error);
+ }
}
-/**
- * @param {Boolean} shouldShowSubscriptAvatar
- * @param {Object} report
- * @param {Object} reportAction
- * @returns {JSX.Element}
- */
-function MockedReportActionItemSingle({shouldShowSubscriptAvatar, report, reportAction}) {
- const personalDetailsList = {
- [reportAction.actorAccountID]: {
- accountID: reportAction.actorAccountID,
- login: 'email1@test.com',
- displayName: 'Email One',
- avatar: 'https://example.com/avatar.png',
- firstName: 'One',
- },
- };
-
+function MockedReportActionItemSingle({shouldShowSubscriptAvatar = true, report, reportAction}: MockedReportActionItemSingleProps) {
return (
,
+ );
+}
export {
fakePersonalDetails,