From 1b5c5902d1344fcb5ba6071c39c22f49ae9f7c40 Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 21 Oct 2024 17:50:19 +0700 Subject: [PATCH 001/165] fix: 10371 auto focus input --- .../Composer/implementation/index.native.tsx | 17 +- .../Composer/implementation/index.tsx | 40 ++-- src/components/Composer/types.ts | 3 + src/pages/home/ReportScreen.tsx | 2 - .../ComposerWithSuggestions.tsx | 204 +++++++----------- .../ReportActionCompose.tsx | 13 +- src/pages/home/report/ReportFooter.tsx | 6 - 7 files changed, 122 insertions(+), 163 deletions(-) diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 9f237dd02424..4de6e9280401 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,7 +1,7 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import mimeDb from 'mime-db'; import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {FileObject} from '@components/AttachmentModal'; @@ -9,6 +9,7 @@ import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useKeyboardState from '@hooks/useKeyboardState'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useResetComposerFocus from '@hooks/useResetComposerFocus'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -38,6 +39,7 @@ function Composer( selection, value, isGroupPolicyReport = false, + showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -50,7 +52,11 @@ function Composer( const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const [contextMenuHidden, setContextMenuHidden] = useState(true); + const {inputCallbackRef, inputRef: autoFocusInputRef} = useAutoFocusInput(); + const keyboardState = useKeyboardState(); + const isKeyboardShown = keyboardState?.isKeyboardShown ?? false; useEffect(() => { if (autoFocus === !!autoFocusInputRef.current) { @@ -59,6 +65,13 @@ function Composer( inputCallbackRef(autoFocus ? textInput.current : null); }, [autoFocus, inputCallbackRef, autoFocusInputRef]); + useEffect(() => { + if (!showSoftInputOnFocus || !isKeyboardShown) { + return; + } + setContextMenuHidden(false); + }, [showSoftInputOnFocus, isKeyboardShown]); + /** * Set the TextInput Ref * @param {Element} el @@ -137,6 +150,8 @@ function Composer( props?.onBlur?.(e); }} onClear={onClear} + showSoftInputOnFocus={showSoftInputOnFocus} + contextMenuHidden={contextMenuHidden} /> ); } diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 4431007793cb..838a2f6a869d 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -50,6 +50,7 @@ function Composer( isComposerFullSize = false, shouldContainScroll = true, isGroupPolicyReport = false, + showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -280,28 +281,24 @@ function Composer( onClear(currentText); }, [onClear, onSelectionChange]); - useImperativeHandle( - ref, - () => { - const textInputRef = textInput.current; - if (!textInputRef) { - throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); - } + useImperativeHandle(ref, () => { + const textInputRef = textInput.current; + if (!textInputRef) { + throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); + } - return { - ...textInputRef, - // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works - clear, - // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly - blur: () => textInputRef.blur(), - focus: () => textInputRef.focus(), - get scrollTop() { - return textInputRef.scrollTop; - }, - }; - }, - [clear], - ); + return { + ...textInputRef, + // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works + clear, + // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly + blur: () => textInputRef.blur(), + focus: () => textInputRef.focus(), + get scrollTop() { + return textInputRef.scrollTop; + }, + }; + }, [clear]); const handleKeyPress = useCallback( (e: NativeSyntheticEvent) => { @@ -349,6 +346,7 @@ function Composer( value={value} defaultValue={defaultValue} autoFocus={autoFocus} + inputMode={showSoftInputOnFocus ? 'text' : 'none'} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} onSelectionChange={addCursorPositionToSelectionChange} diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index ef497dd52e47..7f54c7486e8d 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -74,6 +74,9 @@ type ComposerProps = Omit & { /** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */ isGroupPolicyReport?: boolean; + + /** Whether the soft keyboard is open */ + showSoftInputOnFocus?: boolean; }; export type {TextSelection, ComposerProps, CustomSelectionChangeEvent}; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 4a87d51e3c82..ed4a47f4ff27 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -238,7 +238,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; - const isEmptyChat = useMemo(() => ReportUtils.isEmptyReport(report), [report]); const isOptimisticDelete = report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; const indexOfLinkedMessage = useMemo( (): number => reportActions.findIndex((obj) => String(obj.reportActionID) === String(reportActionIDFromRoute)), @@ -811,7 +810,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro policy={policy} pendingAction={reportPendingAction} isComposerFullSize={!!isComposerFullSize} - isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} workspaceTooltip={workspaceTooltip} /> diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index e63bd952b4ab..12b145a78e87 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -1,6 +1,6 @@ import {useIsFocused, useNavigation} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; -import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react'; +import type {ForwardedRef, MutableRefObject, RefObject} from 'react'; import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type { LayoutChangeEvent, @@ -14,7 +14,7 @@ import type { import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, View} from 'react-native'; import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; @@ -29,7 +29,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {forceClearInput} from '@libs/ComponentUtils'; import * as ComposerUtils from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; @@ -40,7 +39,6 @@ import getPlatform from '@libs/getPlatform'; import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener'; import Parser from '@libs/Parser'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; @@ -65,113 +63,85 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; -type ComposerWithSuggestionsOnyxProps = { - /** The parent report actions for the report */ - parentReportActions: OnyxEntry; +type ComposerWithSuggestionsProps = Partial & { + /** Report ID */ + reportID: string; - /** The modal state */ - modal: OnyxEntry; + /** Callback to focus composer */ + onFocus: () => void; - /** The preferred skin tone of the user */ - preferredSkinTone: number; + /** Callback to blur composer */ + onBlur: (event: NativeSyntheticEvent) => void; - /** 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 when layout of composer changes */ - onLayout?: (event: LayoutChangeEvent) => void; - - /** Callback to update the value of the composer */ - onValueChange: (value: string) => void; + /** Callback when layout of composer changes */ + onLayout?: (event: LayoutChangeEvent) => void; - /** Callback when the composer got cleared on the UI thread */ - onCleared?: (text: string) => void; + /** Callback to update the value of the composer */ + onValueChange: (value: string) => void; - /** Whether the composer is full size */ - isComposerFullSize: boolean; + /** Callback when the composer got cleared on the UI thread */ + onCleared?: (text: string) => void; - /** Whether the menu is visible */ - isMenuVisible: boolean; + /** Whether the composer is full size */ + isComposerFullSize: boolean; - /** The placeholder for the input */ - inputPlaceholder: string; + /** Whether the menu is visible */ + isMenuVisible: boolean; - /** Function to display a file in a modal */ - displayFileInModal: (file: FileObject) => void; + /** The placeholder for the input */ + inputPlaceholder: string; - /** Whether the user is blocked from concierge */ - isBlockedFromConcierge: boolean; + /** Function to display a file in a modal */ + displayFileInModal: (file: FileObject) => void; - /** Whether the input is disabled */ - disabled: boolean; + /** Whether the user is blocked from concierge */ + isBlockedFromConcierge: boolean; - /** Whether the full composer is available */ - isFullComposerAvailable: boolean; + /** Whether the input is disabled */ + disabled: boolean; - /** Function to set whether the full composer is available */ - setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; + /** Whether the full composer is available */ + isFullComposerAvailable: boolean; - /** Function to set whether the comment is empty */ - setIsCommentEmpty: (isCommentEmpty: boolean) => void; + /** Function to set whether the full composer is available */ + setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; - /** Function to handle sending a message */ - handleSendMessage: () => void; + /** Function to set whether the comment is empty */ + setIsCommentEmpty: (isCommentEmpty: boolean) => void; - /** Whether the compose input should show */ - shouldShowComposeInput: OnyxEntry; + /** Function to handle sending a message */ + handleSendMessage: () => void; - /** Function to measure the parent container */ - measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + /** Whether the compose input should show */ + shouldShowComposeInput: OnyxEntry; - /** Whether the scroll is likely to trigger a layout */ - isScrollLikelyLayoutTriggered: RefObject; + /** Function to measure the parent container */ + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; - /** Function to raise the scroll is likely layout triggered */ - raiseIsScrollLikelyLayoutTriggered: () => void; + /** Whether the scroll is likely to trigger a layout */ + isScrollLikelyLayoutTriggered: RefObject; - /** The ref to the suggestions */ - suggestionsRef: React.RefObject; + /** Function to raise the scroll is likely layout triggered */ + raiseIsScrollLikelyLayoutTriggered: () => void; - /** The ref to the next modal will open */ - isNextModalWillOpenRef: MutableRefObject; + /** The ref to the suggestions */ + suggestionsRef: React.RefObject; - /** Whether the edit is focused */ - editFocused: boolean; + /** The ref to the next modal will open */ + isNextModalWillOpenRef: MutableRefObject; - /** Wheater chat is empty */ - isEmptyChat?: boolean; + /** The last report action */ + lastReportAction?: OnyxEntry; - /** The last report action */ - lastReportAction?: OnyxEntry; + /** Whether to include chronos */ + includeChronos?: boolean; - /** Whether to include chronos */ - includeChronos?: boolean; + /** Whether report is from group policy */ + isGroupPolicyReport: 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; - - /** Whether report is from group policy */ - isGroupPolicyReport: boolean; - - /** policy ID of the report */ - policyID: string; - }; + /** policy ID of the report */ + policyID: string; +}; type SwitchToCurrentReportProps = { preexistingReportID: string; @@ -211,10 +181,6 @@ const debouncedBroadcastUserIsTyping = lodashDebounce( const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); -// 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 -const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); - /** * This component holds the value and selection state. * If a component really needs access to these state values it should be put here. @@ -223,17 +189,10 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); */ function ComposerWithSuggestions( { - // Onyx - modal, - preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, - parentReportActions, - // Props: Report reportID, includeChronos, - isEmptyChat, lastReportAction, - parentReportActionID, isGroupPolicyReport, policyID, @@ -263,7 +222,6 @@ function ComposerWithSuggestions( // Refs suggestionsRef, isNextModalWillOpenRef, - editFocused, // For testing children, @@ -288,6 +246,15 @@ function ComposerWithSuggestions( } return draftComment; }); + + const [modal] = useOnyx(ONYXKEYS.MODAL); + const [preferredSkinTone] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, { + selector: EmojiUtils.getPreferredSkinToneIndex, + initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE, + }); + + const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED); + const commentRef = useRef(value); const lastTextRef = useRef(value); @@ -298,13 +265,7 @@ function ComposerWithSuggestions( const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const parentReportAction = parentReportActions?.[parentReportActionID ?? '-1']; - const shouldAutoFocus = - !modal?.isVisible && - Modal.areAllModalsHidden() && - isFocused && - (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && - shouldShowComposeInput; + const shouldAutoFocus = !modal?.isVisible && shouldShowComposeInput && Modal.areAllModalsHidden() && isFocused; const valueRef = useRef(value); valueRef.current = value; @@ -313,6 +274,8 @@ function ComposerWithSuggestions( const [composerHeight, setComposerHeight] = useState(0); + const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); + const textInputRef = useRef(null); const syncSelectionWithOnChangeTextRef = useRef(null); @@ -800,6 +763,19 @@ function ComposerWithSuggestions( onScroll={hideSuggestionMenu} shouldContainScroll={Browser.isMobileSafari()} isGroupPolicyReport={isGroupPolicyReport} + showSoftInputOnFocus={showSoftInputOnFocus} + onTouchStart={() => { + if (showSoftInputOnFocus) { + return; + } + if (Browser.isMobileSafari()) { + setTimeout(() => { + setShowSoftInputOnFocus(true); + }, CONST.ANIMATED_TRANSITION); + return; + } + setShowSoftInputOnFocus(true); + }} /> @@ -837,22 +813,6 @@ ComposerWithSuggestions.displayName = 'ComposerWithSuggestions'; const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions); -export default withOnyx, ComposerWithSuggestionsOnyxProps>({ - 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 default memo(ComposerWithSuggestionsWithRef); export type {ComposerWithSuggestionsProps, ComposerRef}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 9d34fe86c092..f469d91dbf2d 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -28,7 +28,6 @@ import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import {getDraftComment} from '@libs/DraftCommentUtils'; @@ -64,7 +63,7 @@ type SuggestionsRef = { getIsSuggestionsMenuVisible: () => boolean; }; -type ReportActionComposeProps = Pick & { +type ReportActionComposeProps = Pick & { /** A method to call when the form is submitted */ onSubmit: (newComment: string) => void; @@ -90,10 +89,6 @@ type ReportActionComposeProps = Pick { const initialModalState = getModalState(); - return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + return shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; }); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const [shouldHideEducationalTooltip, setShouldHideEducationalTooltip] = useState(false); @@ -468,11 +462,8 @@ function ReportActionCompose({ raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered} reportID={reportID} policyID={report?.policyID ?? '-1'} - parentReportID={report?.parentReportID} - parentReportActionID={report?.parentReportActionID} includeChronos={ReportUtils.chatIncludesChronos(report)} isGroupPolicyReport={isGroupPolicyReport} - isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} isMenuVisible={isMenuVisible} inputPlaceholder={inputPlaceholder} diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 7c4ec786b633..90746efa3b68 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -48,9 +48,6 @@ type ReportFooterProps = { /** Whether to show educational tooltip in workspace chat for first-time user */ workspaceTooltip: OnyxEntry; - /** Whether the chat is empty */ - isEmptyChat?: boolean; - /** The pending action when we are adding a chat */ pendingAction?: PendingAction; @@ -73,7 +70,6 @@ function ReportFooter({ report = {reportID: '-1'}, reportMetadata, policy, - isEmptyChat = true, isReportReadyForDisplay = true, isComposerFullSize = false, workspaceTooltip, @@ -224,7 +220,6 @@ function ReportFooter({ onComposerBlur={onComposerBlur} reportID={report.reportID} report={report} - isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} pendingAction={pendingAction} isComposerFullSize={isComposerFullSize} @@ -246,7 +241,6 @@ export default memo( lodashIsEqual(prevProps.report, nextProps.report) && prevProps.pendingAction === nextProps.pendingAction && prevProps.isComposerFullSize === nextProps.isComposerFullSize && - prevProps.isEmptyChat === nextProps.isEmptyChat && prevProps.lastReportAction === nextProps.lastReportAction && prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow && From 1af3ada68d4a1f75c3798773b93b74fbbb7af1e0 Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 21 Oct 2024 17:54:13 +0700 Subject: [PATCH 002/165] refactor --- .../Composer/implementation/index.tsx | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 838a2f6a869d..e40bb716e0a0 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -281,24 +281,28 @@ function Composer( onClear(currentText); }, [onClear, onSelectionChange]); - useImperativeHandle(ref, () => { - const textInputRef = textInput.current; - if (!textInputRef) { - throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); - } + useImperativeHandle( + ref, + () => { + const textInputRef = textInput.current; + if (!textInputRef) { + throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); + } - return { - ...textInputRef, - // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works - clear, - // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly - blur: () => textInputRef.blur(), - focus: () => textInputRef.focus(), - get scrollTop() { - return textInputRef.scrollTop; - }, - }; - }, [clear]); + return { + ...textInputRef, + // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works + clear, + // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly + blur: () => textInputRef.blur(), + focus: () => textInputRef.focus(), + get scrollTop() { + return textInputRef.scrollTop; + }, + }; + }, + [clear], + ); const handleKeyPress = useCallback( (e: NativeSyntheticEvent) => { From 62513eef4806ad194c134b38644a41206c256afc Mon Sep 17 00:00:00 2001 From: christianwen Date: Thu, 31 Oct 2024 14:48:36 +0700 Subject: [PATCH 003/165] fix bugs flicker --- src/pages/home/ReportScreen.tsx | 5 ++++- .../ComposerWithSuggestions.tsx | 10 ++++++++-- .../ReportActionCompose/ReportActionCompose.tsx | 10 ++++++++++ src/pages/home/report/ReportFooter.tsx | 12 ++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 1027f4c05d3c..5de2652632a8 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -266,6 +266,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`]; const isTopMostReportId = currentReportID === reportIDFromRoute; const didSubscribeToReportLeavingEvents = useRef(false); + const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); useEffect(() => { if (!report?.reportID || shouldHideReport) { @@ -711,7 +712,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro ) : null} diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 12b145a78e87..41fd13aff99b 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -141,6 +141,12 @@ type ComposerWithSuggestionsProps = Partial & { /** policy ID of the report */ policyID: string; + + /** Whether the soft keyboard is open */ + showSoftInputOnFocus: boolean; + + /** A method to update showSoftInputOnFocus */ + setShowSoftInputOnFocus: (value: boolean) => void; }; type SwitchToCurrentReportProps = { @@ -225,6 +231,8 @@ function ComposerWithSuggestions( // For testing children, + showSoftInputOnFocus, + setShowSoftInputOnFocus, }: ComposerWithSuggestionsProps, ref: ForwardedRef, ) { @@ -274,8 +282,6 @@ function ComposerWithSuggestions( const [composerHeight, setComposerHeight] = useState(0); - const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); - const textInputRef = useRef(null); const syncSelectionWithOnChangeTextRef = useRef(null); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index aee324cd745f..16973a5cea41 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -87,6 +87,12 @@ type ReportActionComposeProps = Pick void; }; const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); @@ -104,8 +110,10 @@ function ReportActionCompose({ isReportReadyForDisplay = true, lastReportAction, shouldShowEducationalTooltip, + showSoftInputOnFocus, onComposerFocus, onComposerBlur, + setShowSoftInputOnFocus, }: ReportActionComposeProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -487,6 +495,8 @@ function ReportActionCompose({ } validateCommentMaxLength(value, {reportID}); }} + showSoftInputOnFocus={showSoftInputOnFocus} + setShowSoftInputOnFocus={setShowSoftInputOnFocus} /> { diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index ac2456a5b4d5..1e782e4acdee 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -62,6 +62,12 @@ type ReportFooterProps = { /** A method to call when the input is blur */ onComposerBlur: () => void; + + /** Whether the soft keyboard is open */ + showSoftInputOnFocus: boolean; + + /** A method to update showSoftInputOnFocus */ + setShowSoftInputOnFocus: (value: boolean) => void; }; function ReportFooter({ @@ -73,8 +79,10 @@ function ReportFooter({ isReportReadyForDisplay = true, isComposerFullSize = false, workspaceTooltip, + showSoftInputOnFocus, onComposerBlur, onComposerFocus, + setShowSoftInputOnFocus, }: ReportFooterProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -179,6 +187,7 @@ function ReportFooter({ [report.reportID, handleCreateTask], ); + console.log('9999', showSoftInputOnFocus); return ( <> {!!shouldHideComposer && ( @@ -225,6 +234,8 @@ function ReportFooter({ isComposerFullSize={isComposerFullSize} isReportReadyForDisplay={isReportReadyForDisplay} shouldShowEducationalTooltip={didScreenTransitionEnd && shouldShowEducationalTooltip} + showSoftInputOnFocus={showSoftInputOnFocus} + setShowSoftInputOnFocus={setShowSoftInputOnFocus} /> @@ -244,6 +255,7 @@ export default memo( prevProps.lastReportAction === nextProps.lastReportAction && prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow && + prevProps.showSoftInputOnFocus === nextProps.showSoftInputOnFocus && lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) && lodashIsEqual(prevProps.policy?.employeeList, nextProps.policy?.employeeList) && lodashIsEqual(prevProps.policy?.role, nextProps.policy?.role), From e4823f07f53dc85f09453d56f687fb5c54825c1f Mon Sep 17 00:00:00 2001 From: christianwen Date: Thu, 31 Oct 2024 14:52:01 +0700 Subject: [PATCH 004/165] remove console --- src/pages/home/report/ReportFooter.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 1e782e4acdee..1e5a2f4c0283 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -187,7 +187,6 @@ function ReportFooter({ [report.reportID, handleCreateTask], ); - console.log('9999', showSoftInputOnFocus); return ( <> {!!shouldHideComposer && ( From f8c18261fea2fb263d8638005fb8dba4e6bd3c34 Mon Sep 17 00:00:00 2001 From: christianwen Date: Thu, 31 Oct 2024 15:02:07 +0700 Subject: [PATCH 005/165] fix ts --- tests/perf-test/ReportActionCompose.perf-test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx index 845727c75c97..1827e23ffe4b 100644 --- a/tests/perf-test/ReportActionCompose.perf-test.tsx +++ b/tests/perf-test/ReportActionCompose.perf-test.tsx @@ -96,6 +96,8 @@ function ReportActionComposeWrapper() { disabled={false} report={LHNTestUtils.getFakeReport()} isComposerFullSize + showSoftInputOnFocus={false} + setShowSoftInputOnFocus={() => {}} /> ); From e90556ddfc055271e17482b61960285ebce9a5f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 29 Oct 2024 14:43:47 +0100 Subject: [PATCH 006/165] Revert "Revert "Revert "Revert "Search suffix tree implementation"""" This reverts commit 20af9f625b5e636f3e2cce928d4eb5ab5954f8ee. --- src/CONST.ts | 3 + .../Search/SearchRouter/SearchRouter.tsx | 62 ++++- src/libs/FastSearch.ts | 140 ++++++++++++ src/libs/OptionsListUtils.ts | 38 +++- src/libs/SuffixUkkonenTree/index.ts | 211 ++++++++++++++++++ src/libs/SuffixUkkonenTree/utils.ts | 115 ++++++++++ tests/unit/FastSearchTest.ts | 118 ++++++++++ tests/unit/SuffixUkkonenTreeTest.ts | 63 ++++++ 8 files changed, 736 insertions(+), 14 deletions(-) create mode 100644 src/libs/FastSearch.ts create mode 100644 src/libs/SuffixUkkonenTree/index.ts create mode 100644 src/libs/SuffixUkkonenTree/utils.ts create mode 100644 tests/unit/FastSearchTest.ts create mode 100644 tests/unit/SuffixUkkonenTreeTest.ts diff --git a/src/CONST.ts b/src/CONST.ts index 437ee4e7fd42..871f7730a03d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1262,6 +1262,9 @@ const CONST = { SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, + SEARCH_CONVERT_SEARCH_VALUES: 'search_convert_search_values', + SEARCH_MAKE_TREE: 'search_make_tree', + SEARCH_BUILD_TREE: 'search_build_tree', SEARCH_FILTER_OPTIONS: 'search_filter_options', USE_DEBOUNCED_STATE_DELAY: 300, }, diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 83d7d5d89b20..6f5481a17983 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -15,6 +15,7 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import FastSearch from '@libs/FastSearch'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -105,6 +106,49 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return OptionsListUtils.getSearchOptions(options, '', betas ?? []); }, [areOptionsInitialized, betas, options]); + /** + * Builds a suffix tree and returns a function to search in it. + */ + const findInSearchTree = useMemo(() => { + const fastSearch = FastSearch.createFastSearch([ + { + data: searchOptions.personalDetails, + toSearchableString: (option) => { + const displayName = option.participantsList?.[0]?.displayName ?? ''; + return [option.login ?? '', option.login !== displayName ? displayName : ''].join(); + }, + }, + { + data: searchOptions.recentReports, + toSearchableString: (option) => { + const searchStringForTree = [option.text ?? '', option.login ?? '']; + + if (option.isThread) { + if (option.alternateText) { + searchStringForTree.push(option.alternateText); + } + } else if (!!option.isChatRoom || !!option.isPolicyExpenseChat) { + if (option.subtitle) { + searchStringForTree.push(option.subtitle); + } + } + + return searchStringForTree.join(); + }, + }, + ]); + function search(searchInput: string) { + const [personalDetails, recentReports] = fastSearch.search(searchInput); + + return { + personalDetails, + recentReports, + }; + } + + return search; + }, [searchOptions.personalDetails, searchOptions.recentReports]); + const filteredOptions = useMemo(() => { if (debouncedInputValue.trim() === '') { return { @@ -115,15 +159,25 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const newOptions = findInSearchTree(debouncedInputValue); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); - return { + const recentReports = newOptions.recentReports.concat(newOptions.personalDetails); + + const userToInvite = OptionsListUtils.pickUserToInvite({ + canInviteUser: true, recentReports: newOptions.recentReports, personalDetails: newOptions.personalDetails, - userToInvite: newOptions.userToInvite, + searchValue: debouncedInputValue, + optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], + }); + + return { + recentReports, + personalDetails: [], + userToInvite, }; - }, [debouncedInputValue, searchOptions]); + }, [debouncedInputValue, findInSearchTree]); const recentReports: OptionData[] = useMemo(() => { if (debouncedInputValue === '') { diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts new file mode 100644 index 000000000000..59d28dedd449 --- /dev/null +++ b/src/libs/FastSearch.ts @@ -0,0 +1,140 @@ +/* eslint-disable rulesdir/prefer-at */ +import CONST from '@src/CONST'; +import Timing from './actions/Timing'; +import SuffixUkkonenTree from './SuffixUkkonenTree'; + +type SearchableData = { + /** + * The data that should be searchable + */ + data: T[]; + /** + * A function that generates a string from a data entry. The string's value is used for searching. + * If you have multiple fields that should be searchable, simply concat them to the string and return it. + */ + toSearchableString: (data: T) => string; +}; + +// There are certain characters appear very often in our search data (email addresses), which we don't need to search for. +const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ']); + +/** + * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings. + * You can provide multiple datasets. The search results will be returned for each dataset. + * + * Note: Creating a FastSearch instance with a lot of data is computationally expensive. You should create an instance once and reuse it. + * Searches will be very fast though, even with a lot of data. + */ +function createFastSearch(dataSets: Array>) { + Timing.start(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES); + const maxNumericListSize = 400_000; + // The user might provide multiple data sets, but internally, the search values will be stored in this one list: + let concatenatedNumericList = new Uint8Array(maxNumericListSize); + // Here we store the index of the data item in the original data list, so we can map the found occurrences back to the original data: + const occurrenceToIndex = new Uint32Array(maxNumericListSize * 4); + // As we are working with ArrayBuffers, we need to keep track of the current offset: + const offset = {value: 1}; + // We store the last offset for a dataSet, so we can map the found occurrences to the correct dataSet: + const listOffsets: number[] = []; + + for (const {data, toSearchableString} of dataSets) { + // Performance critical: the array parameters are passed by reference, so we don't have to create new arrays every time: + dataToNumericRepresentation(concatenatedNumericList, occurrenceToIndex, offset, {data, toSearchableString}); + listOffsets.push(offset.value); + } + concatenatedNumericList[offset.value++] = SuffixUkkonenTree.END_CHAR_CODE; + listOffsets[listOffsets.length - 1] = offset.value; + Timing.end(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES); + + // The list might be larger than necessary, so we clamp it to the actual size: + concatenatedNumericList = concatenatedNumericList.slice(0, offset.value); + + // Create & build the suffix tree: + Timing.start(CONST.TIMING.SEARCH_MAKE_TREE); + const tree = SuffixUkkonenTree.makeTree(concatenatedNumericList); + Timing.end(CONST.TIMING.SEARCH_MAKE_TREE); + + Timing.start(CONST.TIMING.SEARCH_BUILD_TREE); + tree.build(); + Timing.end(CONST.TIMING.SEARCH_BUILD_TREE); + + /** + * Searches for the given input and returns results for each dataset. + */ + function search(searchInput: string): T[][] { + const cleanedSearchString = cleanString(searchInput); + const {numeric} = SuffixUkkonenTree.stringToNumeric(cleanedSearchString, { + charSetToSkip, + // stringToNumeric might return a list that is larger than necessary, so we clamp it to the actual size + // (otherwise the search could fail as we include in our search empty array values): + clamp: true, + }); + const result = tree.findSubstring(Array.from(numeric)); + + const resultsByDataSet = Array.from({length: dataSets.length}, () => new Set()); + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < result.length; i++) { + const occurrenceIndex = result[i]; + const itemIndexInDataSet = occurrenceToIndex[occurrenceIndex]; + const dataSetIndex = listOffsets.findIndex((listOffset) => occurrenceIndex < listOffset); + + if (dataSetIndex === -1) { + throw new Error(`[FastSearch] The occurrence index ${occurrenceIndex} is not in any dataset`); + } + const item = dataSets[dataSetIndex].data[itemIndexInDataSet]; + if (!item) { + throw new Error(`[FastSearch] The item with index ${itemIndexInDataSet} in dataset ${dataSetIndex} is not defined`); + } + resultsByDataSet[dataSetIndex].add(item); + } + + return resultsByDataSet.map((set) => Array.from(set)); + } + + return { + search, + }; +} + +/** + * The suffix tree can only store string like values, and internally stores those as numbers. + * This function converts the user data (which are most likely objects) to a numeric representation. + * Additionally a list of the original data and their index position in the numeric list is created, which is used to map the found occurrences back to the original data. + */ +function dataToNumericRepresentation(concatenatedNumericList: Uint8Array, occurrenceToIndex: Uint32Array, offset: {value: number}, {data, toSearchableString}: SearchableData): void { + data.forEach((option, index) => { + const searchStringForTree = toSearchableString(option); + const cleanedSearchStringForTree = cleanString(searchStringForTree); + + if (cleanedSearchStringForTree.length === 0) { + return; + } + + SuffixUkkonenTree.stringToNumeric(cleanedSearchStringForTree, { + charSetToSkip, + out: { + outArray: concatenatedNumericList, + offset, + outOccurrenceToIndex: occurrenceToIndex, + index, + }, + }); + // eslint-disable-next-line no-param-reassign + occurrenceToIndex[offset.value] = index; + // eslint-disable-next-line no-param-reassign + concatenatedNumericList[offset.value++] = SuffixUkkonenTree.DELIMITER_CHAR_CODE; + }); +} + +/** + * Everything in the tree is treated as lowercase. + */ +function cleanString(input: string) { + return input.toLowerCase(); +} + +const FastSearch = { + createFastSearch, +}; + +export default FastSearch; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 497a2d33cf56..f414d2328ef6 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2477,6 +2477,31 @@ function getPersonalDetailSearchTerms(item: Partial) { function getCurrentUserSearchTerms(item: ReportUtils.OptionData) { return [item.text ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '']; } + +type PickUserToInviteParams = { + canInviteUser: boolean; + recentReports: ReportUtils.OptionData[]; + personalDetails: ReportUtils.OptionData[]; + searchValue: string; + config?: FilterOptionsConfig; + optionsToExclude: Option[]; +}; + +const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}: PickUserToInviteParams) => { + let userToInvite = null; + if (canInviteUser) { + if (recentReports.length === 0 && personalDetails.length === 0) { + userToInvite = getUserToInviteOption({ + searchValue, + selectedOptions: config?.selectedOptions, + optionsToExclude, + }); + } + } + + return userToInvite; +}; + /** * Filters options based on the search input value */ @@ -2564,16 +2589,7 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt recentReports = orderOptions(recentReports, searchValue); } - let userToInvite = null; - if (canInviteUser) { - if (recentReports.length === 0 && personalDetails.length === 0) { - userToInvite = getUserToInviteOption({ - searchValue, - selectedOptions: config?.selectedOptions, - optionsToExclude, - }); - } - } + const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}); if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) { recentReports.splice(maxRecentReportsToShow); @@ -2643,6 +2659,7 @@ export { formatMemberForList, formatSectionsFromSearchTerm, getShareLogOptions, + orderOptions, filterOptions, createOptionList, createOptionFromReport, @@ -2657,6 +2674,7 @@ export { shouldUseBoldText, getAttendeeOptions, getAlternateText, + pickUserToInvite, hasReportErrors, }; diff --git a/src/libs/SuffixUkkonenTree/index.ts b/src/libs/SuffixUkkonenTree/index.ts new file mode 100644 index 000000000000..bcefd1008493 --- /dev/null +++ b/src/libs/SuffixUkkonenTree/index.ts @@ -0,0 +1,211 @@ +/* eslint-disable rulesdir/prefer-at */ +// .at() has a performance overhead we explicitly want to avoid here + +/* eslint-disable no-continue */ +import {ALPHABET_SIZE, DELIMITER_CHAR_CODE, END_CHAR_CODE, SPECIAL_CHAR_CODE, stringToNumeric} from './utils'; + +/** + * This implements a suffix tree using Ukkonen's algorithm. + * A good visualization to learn about the algorithm can be found here: https://brenden.github.io/ukkonen-animation/ + * A good video explaining Ukkonen's algorithm can be found here: https://www.youtube.com/watch?v=ALEV0Hc5dDk + * Note: This implementation is optimized for performance, not necessarily for readability. + * + * You probably don't want to use this directly, but rather use @libs/FastSearch.ts as a easy to use wrapper around this. + */ + +/** + * Creates a new tree instance that can be used to build a suffix tree and search in it. + * The input is a numeric representation of the search string, which can be created using {@link stringToNumeric}. + * Separate search values must be separated by the {@link DELIMITER_CHAR_CODE}. The search string must end with the {@link END_CHAR_CODE}. + * + * The tree will be built using the Ukkonen's algorithm: https://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf + */ +function makeTree(numericSearchValues: Uint8Array) { + // Every leaf represents a suffix. There can't be more than n suffixes. + // Every internal node has to have at least 2 children. So the total size of ukkonen tree is not bigger than 2n - 1. + // + 1 is because an extra character at the beginning to offset the 1-based indexing. + const maxNodes = 2 * numericSearchValues.length + 1; + /* + This array represents all internal nodes in the suffix tree. + When building this tree, we'll be given a character in the string, and we need to be able to lookup in constant time + if there's any edge connected to a node starting with that character. For example, given a tree like this: + + root + / | \ + a b c + + and the next character in our string is 'd', we need to be able do check if any of the edges from the root node + start with the letter 'd', without looping through all the edges. + + To accomplish this, each node gets an array matching the alphabet size. + So you can imagine if our alphabet was just [a,b,c,d], then each node would get an array like [0,0,0,0]. + If we add an edge starting with 'a', then the root node would be [1,0,0,0] + So given an arbitrary letter such as 'd', then we can take the position of that letter in its alphabet (position 3 in our example) + and check whether that index in the array is 0 or 1. If it's a 1, then there's an edge starting with the letter 'd'. + + Note that for efficiency, all nodes are stored in a single flat array. That's how we end up with (maxNodes * alphabet_size). + In the example of a 4-character alphabet, we'd have an array like this: + + root root.left root.right last possible node + / \ / \ / \ / \ + [0,0,0,0, 0,0,0,0, 0,0,0,0, ................. 0,0,0,0] + */ + const transitionNodes = new Uint32Array(maxNodes * ALPHABET_SIZE); + + // Storing the range of the original string that each node represents: + const rangeStart = new Uint32Array(maxNodes); + const rangeEnd = new Uint32Array(maxNodes); + + const parent = new Uint32Array(maxNodes); + const suffixLink = new Uint32Array(maxNodes); + + let currentNode = 1; + let currentPosition = 1; + let nodeCounter = 3; + let currentIndex = 1; + + function initializeTree() { + rangeEnd.fill(numericSearchValues.length); + rangeEnd[1] = 0; + rangeEnd[2] = 0; + suffixLink[1] = 2; + for (let i = 0; i < ALPHABET_SIZE; ++i) { + transitionNodes[ALPHABET_SIZE * 2 + i] = 1; + } + } + + function processCharacter(char: number) { + // eslint-disable-next-line no-constant-condition + while (true) { + if (rangeEnd[currentNode] < currentPosition) { + if (transitionNodes[currentNode * ALPHABET_SIZE + char] === 0) { + createNewLeaf(char); + continue; + } + currentNode = transitionNodes[currentNode * ALPHABET_SIZE + char]; + currentPosition = rangeStart[currentNode]; + } + if (currentPosition === 0 || char === numericSearchValues[currentPosition]) { + currentPosition++; + } else { + splitEdge(char); + continue; + } + break; + } + } + + function createNewLeaf(c: number) { + transitionNodes[currentNode * ALPHABET_SIZE + c] = nodeCounter; + rangeStart[nodeCounter] = currentIndex; + parent[nodeCounter++] = currentNode; + currentNode = suffixLink[currentNode]; + + currentPosition = rangeEnd[currentNode] + 1; + } + + function splitEdge(c: number) { + rangeStart[nodeCounter] = rangeStart[currentNode]; + rangeEnd[nodeCounter] = currentPosition - 1; + parent[nodeCounter] = parent[currentNode]; + + transitionNodes[nodeCounter * ALPHABET_SIZE + numericSearchValues[currentPosition]] = currentNode; + transitionNodes[nodeCounter * ALPHABET_SIZE + c] = nodeCounter + 1; + rangeStart[nodeCounter + 1] = currentIndex; + parent[nodeCounter + 1] = nodeCounter; + rangeStart[currentNode] = currentPosition; + parent[currentNode] = nodeCounter; + + transitionNodes[parent[nodeCounter] * ALPHABET_SIZE + numericSearchValues[rangeStart[nodeCounter]]] = nodeCounter; + nodeCounter += 2; + handleDescent(nodeCounter); + } + + function handleDescent(latestNodeIndex: number) { + currentNode = suffixLink[parent[latestNodeIndex - 2]]; + currentPosition = rangeStart[latestNodeIndex - 2]; + while (currentPosition <= rangeEnd[latestNodeIndex - 2]) { + currentNode = transitionNodes[currentNode * ALPHABET_SIZE + numericSearchValues[currentPosition]]; + currentPosition += rangeEnd[currentNode] - rangeStart[currentNode] + 1; + } + if (currentPosition === rangeEnd[latestNodeIndex - 2] + 1) { + suffixLink[latestNodeIndex - 2] = currentNode; + } else { + suffixLink[latestNodeIndex - 2] = latestNodeIndex; + } + currentPosition = rangeEnd[currentNode] - (currentPosition - rangeEnd[latestNodeIndex - 2]) + 2; + } + + function build() { + initializeTree(); + for (currentIndex = 1; currentIndex < numericSearchValues.length; ++currentIndex) { + const c = numericSearchValues[currentIndex]; + processCharacter(c); + } + } + + /** + * Returns all occurrences of the given (sub)string in the input string. + * + * You can think of the tree that we create as a big string that looks like this: + * + * "banana$pancake$apple|" + * The example delimiter character '$' is used to separate the different strings. + * The end character '|' is used to indicate the end of our search string. + * + * This function will return the index(es) of found occurrences within this big string. + * So, when searching for "an", it would return [1, 3, 8]. + */ + function findSubstring(searchValue: number[]) { + const occurrences: number[] = []; + + function dfs(node: number, depth: number) { + const leftRange = rangeStart[node]; + const rightRange = rangeEnd[node]; + const rangeLen = node === 1 ? 0 : rightRange - leftRange + 1; + + for (let i = 0; i < rangeLen && depth + i < searchValue.length && leftRange + i < numericSearchValues.length; i++) { + if (searchValue[depth + i] !== numericSearchValues[leftRange + i]) { + return; + } + } + + let isLeaf = true; + for (let i = 0; i < ALPHABET_SIZE; ++i) { + const tNode = transitionNodes[node * ALPHABET_SIZE + i]; + + // Search speed optimization: don't go through the edge if it's different than the next char: + const correctChar = depth + rangeLen >= searchValue.length || i === searchValue[depth + rangeLen]; + + if (tNode !== 0 && tNode !== 1 && correctChar) { + isLeaf = false; + dfs(tNode, depth + rangeLen); + } + } + + if (isLeaf && depth + rangeLen >= searchValue.length) { + occurrences.push(numericSearchValues.length - (depth + rangeLen) + 1); + } + } + + dfs(1, 0); + return occurrences; + } + + return { + build, + findSubstring, + }; +} + +const SuffixUkkonenTree = { + makeTree, + + // Re-exported from utils: + DELIMITER_CHAR_CODE, + SPECIAL_CHAR_CODE, + END_CHAR_CODE, + stringToNumeric, +}; + +export default SuffixUkkonenTree; diff --git a/src/libs/SuffixUkkonenTree/utils.ts b/src/libs/SuffixUkkonenTree/utils.ts new file mode 100644 index 000000000000..96ee35b15796 --- /dev/null +++ b/src/libs/SuffixUkkonenTree/utils.ts @@ -0,0 +1,115 @@ +/* eslint-disable rulesdir/prefer-at */ // .at() has a performance overhead we explicitly want to avoid here +/* eslint-disable no-continue */ + +const CHAR_CODE_A = 'a'.charCodeAt(0); +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'; +const LETTER_ALPHABET_SIZE = ALPHABET.length; +const ALPHABET_SIZE = LETTER_ALPHABET_SIZE + 3; // +3: special char, delimiter char, end char +const SPECIAL_CHAR_CODE = ALPHABET_SIZE - 3; +const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; +const END_CHAR_CODE = ALPHABET_SIZE - 1; + +// Store the results for a char code in a lookup table to avoid recalculating the same values (performance optimization) +const base26LookupTable = new Array(); + +/** + * Converts a number to a base26 representation. + */ +function convertToBase26(num: number): number[] { + if (base26LookupTable[num]) { + return base26LookupTable[num]; + } + if (num < 0) { + throw new Error('convertToBase26: Input must be a non-negative integer'); + } + + const result: number[] = []; + + do { + // eslint-disable-next-line no-param-reassign + num--; + result.unshift(num % 26); + // eslint-disable-next-line no-bitwise, no-param-reassign + num >>= 5; // Equivalent to Math.floor(num / 26), but faster + } while (num > 0); + + base26LookupTable[num] = result; + return result; +} + +/** + * Converts a string to an array of numbers representing the characters of the string. + * Every number in the array is in the range [0, ALPHABET_SIZE-1] (0-28). + * + * The numbers are offset by the character code of 'a' (97). + * - This is so that the numbers from a-z are in the range 0-28. + * - 26 is for encoding special characters. Character numbers that are not within the range of a-z will be encoded as "specialCharacter + base26(charCode)" + * - 27 is for the delimiter character + * - 28 is for the end character + * + * Note: The string should be converted to lowercase first (otherwise uppercase letters get base26'ed taking more space than necessary). + */ +function stringToNumeric( + // The string we want to convert to a numeric representation + input: string, + options?: { + // A set of characters that should be skipped and not included in the numeric representation + charSetToSkip?: Set; + // When out is provided, the function will write the result to the provided arrays instead of creating new ones (performance) + out?: { + outArray: Uint8Array; + // As outArray is a ArrayBuffer we need to keep track of the current offset + offset: {value: number}; + // A map of to map the found occurrences to the correct data set + // As the search string can be very long for high traffic accounts (500k+), this has to be big enough, thus its a Uint32Array + outOccurrenceToIndex?: Uint32Array; + // The index that will be used in the outOccurrenceToIndex array (this is the index of your original data position) + index?: number; + }; + // By default false. By default the outArray may be larger than necessary. If clamp is set to true the outArray will be clamped to the actual size. + clamp?: boolean; + }, +): { + numeric: Uint8Array; + occurrenceToIndex: Uint32Array; + offset: {value: number}; +} { + // The out array might be longer than our input string length, because we encode special characters as multiple numbers using the base26 encoding. + // * 6 is because the upper limit of encoding any char in UTF-8 to base26 is at max 6 numbers. + const outArray = options?.out?.outArray ?? new Uint8Array(input.length * 6); + const offset = options?.out?.offset ?? {value: 0}; + const occurrenceToIndex = options?.out?.outOccurrenceToIndex ?? new Uint32Array(input.length * 16 * 4); + const index = options?.out?.index ?? 0; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + if (options?.charSetToSkip?.has(char)) { + continue; + } + + if (char >= 'a' && char <= 'z') { + // char is an alphabet character + occurrenceToIndex[offset.value] = index; + outArray[offset.value++] = char.charCodeAt(0) - CHAR_CODE_A; + } else { + const charCode = input.charCodeAt(i); + occurrenceToIndex[offset.value] = index; + outArray[offset.value++] = SPECIAL_CHAR_CODE; + const asBase26Numeric = convertToBase26(charCode); + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let j = 0; j < asBase26Numeric.length; j++) { + occurrenceToIndex[offset.value] = index; + outArray[offset.value++] = asBase26Numeric[j]; + } + } + } + + return { + numeric: options?.clamp ? outArray.slice(0, offset.value) : outArray, + occurrenceToIndex, + offset, + }; +} + +export {stringToNumeric, ALPHABET, ALPHABET_SIZE, SPECIAL_CHAR_CODE, DELIMITER_CHAR_CODE, END_CHAR_CODE}; diff --git a/tests/unit/FastSearchTest.ts b/tests/unit/FastSearchTest.ts new file mode 100644 index 000000000000..029e05e15b1f --- /dev/null +++ b/tests/unit/FastSearchTest.ts @@ -0,0 +1,118 @@ +import FastSearch from '../../src/libs/FastSearch'; + +describe('FastSearch', () => { + it('should insert, and find the word', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana'], + toSearchableString: (data) => data, + }, + ]); + expect(search('an')).toEqual([['banana']]); + }); + + it('should work with multiple words', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana', 'test'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('es')).toEqual([['test']]); + }); + + it('should work when providing two data sets', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['erica', 'banana'], + toSearchableString: (data) => data, + }, + { + data: ['banana', 'test'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('es')).toEqual([[], ['test']]); + }); + + it('should work with numbers', () => { + const {search} = FastSearch.createFastSearch([ + { + data: [1, 2, 3, 4, 5], + toSearchableString: (data) => String(data), + }, + ]); + + expect(search('2')).toEqual([[2]]); + }); + + it('should work with unicodes', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana', 'ñèşťǒř', 'test'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('èşť')).toEqual([['ñèşťǒř']]); + }); + + it('should work with words containing "reserved special characters"', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['ba|nana', 'te{st', 'he}llo'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('st')).toEqual([['te{st']]); + expect(search('llo')).toEqual([['he}llo']]); + expect(search('nana')).toEqual([['ba|nana']]); + }); + + it('should be case insensitive', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana', 'TeSt', 'TEST', 'X'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('test')).toEqual([['TeSt', 'TEST']]); + }); + + it('should work with large random data sets', () => { + const data = Array.from({length: 1000}, () => { + return Array.from({length: Math.floor(Math.random() * 22 + 9)}, () => { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789@-_.'; + return alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + }).join(''); + }); + + const {search} = FastSearch.createFastSearch([ + { + data, + toSearchableString: (x) => x, + }, + ]); + + data.forEach((word) => { + expect(search(word)).toEqual([expect.arrayContaining([word])]); + }); + }); + + it('should find email addresses without dots', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['test.user@example.com', 'unrelated'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('testuser')).toEqual([['test.user@example.com']]); + expect(search('test.user')).toEqual([['test.user@example.com']]); + expect(search('examplecom')).toEqual([['test.user@example.com']]); + }); +}); diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts new file mode 100644 index 000000000000..c0c556c16e14 --- /dev/null +++ b/tests/unit/SuffixUkkonenTreeTest.ts @@ -0,0 +1,63 @@ +import SuffixUkkonenTree from '@libs/SuffixUkkonenTree/index'; + +describe('SuffixUkkonenTree', () => { + // The suffix tree doesn't take strings, but expects an array buffer, where strings have been separated by a delimiter. + function helperStringsToNumericForTree(strings: string[]) { + const numericLists = strings.map((s) => SuffixUkkonenTree.stringToNumeric(s, {clamp: true})); + const numericList = numericLists.reduce( + (acc, {numeric}) => { + acc.push(...numeric, SuffixUkkonenTree.DELIMITER_CHAR_CODE); + return acc; + }, + // The value we pass to makeTree needs to be offset by one + [0], + ); + numericList.push(SuffixUkkonenTree.END_CHAR_CODE); + return Uint8Array.from(numericList); + } + + it('should insert, build, and find all occurrences', () => { + const strings = ['banana', 'pancake']; + const numericIntArray = helperStringsToNumericForTree(strings); + + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9])); + }); + + it('should find by first character', () => { + const strings = ['pancake', 'banana']; + const numericIntArray = helperStringsToNumericForTree(strings); + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('p', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([1])); + }); + + it('should handle identical words', () => { + const strings = ['banana', 'banana', 'x']; + const numericIntArray = helperStringsToNumericForTree(strings); + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9, 11])); + }); + + it('should convert string to numeric with a list of chars to skip', () => { + const {numeric} = SuffixUkkonenTree.stringToNumeric('abcabc', { + charSetToSkip: new Set(['b']), + clamp: true, + }); + expect(Array.from(numeric)).toEqual([0, 2, 0, 2]); + }); + + it('should convert string outside of a-z to numeric with clamping', () => { + const {numeric} = SuffixUkkonenTree.stringToNumeric('2', { + clamp: true, + }); + + // "2" in ASCII is 50, so base26(50) = [0, 23] + expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]); + }); +}); From caa7dc5fcba285de19651acdf38900325c7b5874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 24 Oct 2024 10:53:58 +0200 Subject: [PATCH 007/165] exclude comma from search values --- src/libs/FastSearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts index 59d28dedd449..d514c269320c 100644 --- a/src/libs/FastSearch.ts +++ b/src/libs/FastSearch.ts @@ -16,7 +16,7 @@ type SearchableData = { }; // There are certain characters appear very often in our search data (email addresses), which we don't need to search for. -const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ']); +const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ', ',']); /** * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings. From a2d8012769451cccaea1e27f2ddf4154abcad201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 5 Nov 2024 09:59:18 +0100 Subject: [PATCH 008/165] wip: refactoring test to be reusable --- tests/unit/OptionsListUtilsTest.ts | 741 +------------------------- tests/utils/OptionListUtilsHelper.ts | 765 +++++++++++++++++++++++++++ 2 files changed, 783 insertions(+), 723 deletions(-) create mode 100644 tests/utils/OptionListUtilsHelper.ts diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 5a0cd6638a07..76ed984d9194 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -1,405 +1,37 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import type {SelectedTagOption} from '@components/TagPicker'; -import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; +import type {Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {PersonalDetailsList} from '../utils/OptionListUtilsHelper'; +import createOptionsListUtilsHelper from '../utils/OptionListUtilsHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -type PersonalDetailsList = Record; - describe('OptionsListUtils', () => { - // Given a set of reports with both single participants and multiple participants some pinned and some not - const REPORTS: OnyxCollection = { - '1': { - lastReadTime: '2021-01-14 11:25:39.295', - lastVisibleActionCreated: '2022-11-22 03:26:02.015', - isPinned: false, - reportID: '1', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Iron Man, Mister Fantastic, Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '2': { - lastReadTime: '2021-01-14 11:25:39.296', - lastVisibleActionCreated: '2022-11-22 03:26:02.016', - isPinned: false, - reportID: '2', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This is the only report we are pinning in this test - '3': { - lastReadTime: '2021-01-14 11:25:39.297', - lastVisibleActionCreated: '2022-11-22 03:26:02.170', - isPinned: true, - reportID: '3', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Fantastic', - type: CONST.REPORT.TYPE.CHAT, - }, - '4': { - lastReadTime: '2021-01-14 11:25:39.298', - lastVisibleActionCreated: '2022-11-22 03:26:02.180', - isPinned: false, - reportID: '4', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Black Panther', - type: CONST.REPORT.TYPE.CHAT, - }, - '5': { - lastReadTime: '2021-01-14 11:25:39.299', - lastVisibleActionCreated: '2022-11-22 03:26:02.019', - isPinned: false, - reportID: '5', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '6': { - lastReadTime: '2021-01-14 11:25:39.300', - lastVisibleActionCreated: '2022-11-22 03:26:02.020', - isPinned: false, - reportID: '6', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Thor', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has the largest lastVisibleActionCreated - '7': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:03.999', - isPinned: false, - reportID: '7', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Captain America', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has no lastVisibleActionCreated - '8': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '8', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Silver Surfer', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has an IOU - '9': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.998', - isPinned: false, - reportID: '9', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Sinister', - iouReportID: '100', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This report is an archived room – it does not have a name and instead falls back on oldPolicyName - '10': { - lastReadTime: '2021-01-14 11:25:39.200', - lastVisibleActionCreated: '2022-11-22 03:26:02.001', - reportID: '10', - isPinned: false, - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: "SHIELD's workspace", - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - - // This indicates that the report is archived - stateNum: 2, - statusNum: 2, - // eslint-disable-next-line @typescript-eslint/naming-convention - private_isArchived: DateUtils.getDBTime(), - }, - }; - - // And a set of personalDetails some with existing reports and some without - const PERSONAL_DETAILS: PersonalDetailsList = { - // These exist in our reports - '1': { - accountID: 1, - displayName: 'Mister Fantastic', - login: 'reedrichards@expensify.com', - isSelected: true, - reportID: '1', - }, - '2': { - accountID: 2, - displayName: 'Iron Man', - login: 'tonystark@expensify.com', - reportID: '1', - }, - '3': { - accountID: 3, - displayName: 'Spider-Man', - login: 'peterparker@expensify.com', - reportID: '1', - }, - '4': { - accountID: 4, - displayName: 'Black Panther', - login: 'tchalla@expensify.com', - reportID: '1', - }, - '5': { - accountID: 5, - displayName: 'Invisible Woman', - login: 'suestorm@expensify.com', - reportID: '1', - }, - '6': { - accountID: 6, - displayName: 'Thor', - login: 'thor@expensify.com', - reportID: '1', - }, - '7': { - accountID: 7, - displayName: 'Captain America', - login: 'steverogers@expensify.com', - reportID: '1', - }, - '8': { - accountID: 8, - displayName: 'Mr Sinister', - login: 'mistersinister@marauders.com', - reportID: '1', - }, - - // These do not exist in reports at all - '9': { - accountID: 9, - displayName: 'Black Widow', - login: 'natasharomanoff@expensify.com', - reportID: '', - }, - '10': { - accountID: 10, - displayName: 'The Incredible Hulk', - login: 'brucebanner@expensify.com', - reportID: '', - }, - }; - - const REPORTS_WITH_CONCIERGE: OnyxCollection = { - ...REPORTS, - - '11': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '11', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Concierge', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_CHRONOS: OnyxCollection = { - ...REPORTS, - '12': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '12', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Chronos', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_RECEIPTS: OnyxCollection = { - ...REPORTS, - '13': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '13', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Receipts', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { - ...REPORTS, - '14': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '14', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: 'Avengers Room', - chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { - ...REPORTS, - 15: { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '15', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man, Black Panther', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, - }, - }; - - const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { - ...PERSONAL_DETAILS, - '999': { - accountID: 999, - displayName: 'Concierge', - login: 'concierge@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1000': { - accountID: 1000, - displayName: 'Chronos', - login: 'chronos@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1001': { - accountID: 1001, - displayName: 'Receipts', - login: 'receipts@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1002': { - accountID: 1002, - displayName: 'The Flash', - login: 'barry.allen@expensify.com', - reportID: '', - }, - }; - - const policyID = 'ABC123'; - - const POLICY: Policy = { - id: policyID, - name: 'Hero Policy', - role: 'user', - type: CONST.POLICY.TYPE.TEAM, - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }; - - // Set the currently logged in user, report data, and personal details - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, - [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { - reportID: '', - ownerAccountID: 8, - total: 1000, - }, - [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, - }, - }); - Onyx.registerLogger(() => {}); - return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); - }); - let OPTIONS: OptionsListUtils.OptionList; let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; + const optionListUtilsHelper = createOptionsListUtilsHelper(); + + beforeAll(() => { + return optionListUtilsHelper.beforeAll(); + }); + beforeEach(() => { - OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); - OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); - OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); - OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); - OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); + const options = optionListUtilsHelper.generateOptions(); + OPTIONS = options.OPTIONS; + OPTIONS_WITH_CONCIERGE = options.OPTIONS_WITH_CONCIERGE; + OPTIONS_WITH_CHRONOS = options.OPTIONS_WITH_CHRONOS; + OPTIONS_WITH_RECEIPTS = options.OPTIONS_WITH_RECEIPTS; + OPTIONS_WITH_WORKSPACE_ROOM = options.OPTIONS_WITH_WORKSPACE_ROOM; }); it('getSearchOptions()', () => { @@ -2544,7 +2176,7 @@ describe('OptionsListUtils', () => { }); it('formatMemberForList()', () => { - const formattedMembers = Object.values(PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); + const formattedMembers = Object.values(optionListUtilsHelper.PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); // We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array expect(formattedMembers.at(0)?.text).toBe('Mister Fantastic'); @@ -2561,344 +2193,7 @@ describe('OptionsListUtils', () => { expect(formattedMembers.every((personalDetail) => !personalDetail.isDisabled)).toBe(true); }); - describe('filterOptions', () => { - it('should return all options when search is empty', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, ''); - - expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); - }); - - it('should return filtered options in correct order', () => { - const searchText = 'man'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(4); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); - expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should filter users by email', () => { - const searchText = 'mistersinister@marauders.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); - }); - - it('should find archived chats', () => { - const searchText = 'Archived'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); - }); - - it('should filter options by email if dot is skipped in the email', () => { - const searchText = 'barryallen'; - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); - }); - - it('should include workspace rooms in the search results', () => { - const searchText = 'avengers'; - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); - }); - - it('should put exact match by login on the top of the list', () => { - const searchText = 'reedrichards@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); - }); - - it('should prioritize options with matching display name over chatrooms', () => { - const searchText = 'spider'; - const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); - - const filterOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filterOptions.recentReports.length).toBe(2); - expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); - }); - - it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { - const searchText = 'fantastic'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should return the user to invite when the search value is a valid, non-existent email', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should not return any results if the search value is on an exluded logins list', () => { - const searchText = 'admin@expensify.com'; - - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); - const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - expect(filterOptions.recentReports.length).toBe(0); - }); - - it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should return limited amount of recent reports if the limit is set', () => { - const searchText = ''; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); - - expect(filteredOptions.recentReports.length).toBe(2); - }); - - it('should not return any user to invite if email exists on the personal details list', () => { - const searchText = 'natasharomanoff@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); - }); - - it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { - // Filter current REPORTS as we do in the component, before getting share destination options - const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { - const report = option.item; - if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); - - expect(filteredOptions.recentReports.length).toBe(1); - }); - - it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); - - expect(filteredOptions.recentReports.length).toBe(0); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - }); - - it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); - }); - - it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); - }); - - it('should return matching option when searching (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - }); - - it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - - return waitForBatchedUpdates() - .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) - .then(() => { - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); - const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); - - expect(filteredResults.recentReports.length).toBe(1); - expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); - }); - }); - }); + describe('filterOptions', () => optionListUtilsHelper.createFilterTests()); describe('canCreateOptimisticPersonalDetailOption', () => { const VALID_EMAIL = 'valid@email.com'; diff --git a/tests/utils/OptionListUtilsHelper.ts b/tests/utils/OptionListUtilsHelper.ts new file mode 100644 index 000000000000..d5055aebc5e5 --- /dev/null +++ b/tests/utils/OptionListUtilsHelper.ts @@ -0,0 +1,765 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import Onyx from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; +import * as OptionsListUtils from '@src/libs/OptionsListUtils'; +import * as ReportUtils from '@src/libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; +import waitForBatchedUpdates from './waitForBatchedUpdates'; + +type PersonalDetailsList = Record; + +const createOptionsListUtilsHelper = () => { + // Given a set of reports with both single participants and multiple participants some pinned and some not + const REPORTS: OnyxCollection = { + '1': { + lastReadTime: '2021-01-14 11:25:39.295', + lastVisibleActionCreated: '2022-11-22 03:26:02.015', + isPinned: false, + reportID: '1', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Iron Man, Mister Fantastic, Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '2': { + lastReadTime: '2021-01-14 11:25:39.296', + lastVisibleActionCreated: '2022-11-22 03:26:02.016', + isPinned: false, + reportID: '2', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This is the only report we are pinning in this test + '3': { + lastReadTime: '2021-01-14 11:25:39.297', + lastVisibleActionCreated: '2022-11-22 03:26:02.170', + isPinned: true, + reportID: '3', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Fantastic', + type: CONST.REPORT.TYPE.CHAT, + }, + '4': { + lastReadTime: '2021-01-14 11:25:39.298', + lastVisibleActionCreated: '2022-11-22 03:26:02.180', + isPinned: false, + reportID: '4', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Black Panther', + type: CONST.REPORT.TYPE.CHAT, + }, + '5': { + lastReadTime: '2021-01-14 11:25:39.299', + lastVisibleActionCreated: '2022-11-22 03:26:02.019', + isPinned: false, + reportID: '5', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '6': { + lastReadTime: '2021-01-14 11:25:39.300', + lastVisibleActionCreated: '2022-11-22 03:26:02.020', + isPinned: false, + reportID: '6', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Thor', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has the largest lastVisibleActionCreated + '7': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:03.999', + isPinned: false, + reportID: '7', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Captain America', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has no lastVisibleActionCreated + '8': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '8', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Silver Surfer', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has an IOU + '9': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.998', + isPinned: false, + reportID: '9', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Sinister', + iouReportID: '100', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This report is an archived room – it does not have a name and instead falls back on oldPolicyName + '10': { + lastReadTime: '2021-01-14 11:25:39.200', + lastVisibleActionCreated: '2022-11-22 03:26:02.001', + reportID: '10', + isPinned: false, + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: "SHIELD's workspace", + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + + // This indicates that the report is archived + stateNum: 2, + statusNum: 2, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), + }, + }; + + // And a set of personalDetails some with existing reports and some without + const PERSONAL_DETAILS: PersonalDetailsList = { + // These exist in our reports + '1': { + accountID: 1, + displayName: 'Mister Fantastic', + login: 'reedrichards@expensify.com', + isSelected: true, + reportID: '1', + }, + '2': { + accountID: 2, + displayName: 'Iron Man', + login: 'tonystark@expensify.com', + reportID: '1', + }, + '3': { + accountID: 3, + displayName: 'Spider-Man', + login: 'peterparker@expensify.com', + reportID: '1', + }, + '4': { + accountID: 4, + displayName: 'Black Panther', + login: 'tchalla@expensify.com', + reportID: '1', + }, + '5': { + accountID: 5, + displayName: 'Invisible Woman', + login: 'suestorm@expensify.com', + reportID: '1', + }, + '6': { + accountID: 6, + displayName: 'Thor', + login: 'thor@expensify.com', + reportID: '1', + }, + '7': { + accountID: 7, + displayName: 'Captain America', + login: 'steverogers@expensify.com', + reportID: '1', + }, + '8': { + accountID: 8, + displayName: 'Mr Sinister', + login: 'mistersinister@marauders.com', + reportID: '1', + }, + + // These do not exist in reports at all + '9': { + accountID: 9, + displayName: 'Black Widow', + login: 'natasharomanoff@expensify.com', + reportID: '', + }, + '10': { + accountID: 10, + displayName: 'The Incredible Hulk', + login: 'brucebanner@expensify.com', + reportID: '', + }, + }; + + const REPORTS_WITH_CONCIERGE: OnyxCollection = { + ...REPORTS, + + '11': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '11', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Concierge', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_CHRONOS: OnyxCollection = { + ...REPORTS, + '12': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '12', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Chronos', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_RECEIPTS: OnyxCollection = { + ...REPORTS, + '13': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '13', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Receipts', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { + ...REPORTS, + '14': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '14', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: 'Avengers Room', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { + ...PERSONAL_DETAILS, + '999': { + accountID: 999, + displayName: 'Concierge', + login: 'concierge@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1000': { + accountID: 1000, + displayName: 'Chronos', + login: 'chronos@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1001': { + accountID: 1001, + displayName: 'Receipts', + login: 'receipts@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1002': { + accountID: 1002, + displayName: 'The Flash', + login: 'barry.allen@expensify.com', + reportID: '', + }, + }; + + const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { + ...REPORTS, + 15: { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '15', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man, Black Panther', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + }, + }; + + const policyID = 'ABC123'; + + const POLICY: Policy = { + id: policyID, + name: 'Hero Policy', + role: 'user', + type: CONST.POLICY.TYPE.TEAM, + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }; + + // Set the currently logged in user, report data, and personal details + const beforeAll = () => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, + [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { + reportID: '', + ownerAccountID: 8, + total: 1000, + }, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, + }, + }); + Onyx.registerLogger(() => {}); + return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); + }; + + // TODO: duplicate code? + let OPTIONS: OptionsListUtils.OptionList; + let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; + let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; + let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; + let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; + + const generateOptions = () => { + OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); + OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); + OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); + OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); + OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); + + return { + OPTIONS, + OPTIONS_WITH_CONCIERGE, + OPTIONS_WITH_CHRONOS, + OPTIONS_WITH_RECEIPTS, + OPTIONS_WITH_WORKSPACE_ROOM, + }; + }; + + // TODO: our search is basically just for this use-case: filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const createFilterTests = () => { + it('should return all options when search is empty', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, ''); + + expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); + }); + + it('should return filtered options in correct order', () => { + const searchText = 'man'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(4); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); + expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should filter users by email', () => { + const searchText = 'mistersinister@marauders.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); + }); + + it('should find archived chats', () => { + const searchText = 'Archived'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); + }); + + it('should filter options by email if dot is skipped in the email', () => { + const searchText = 'barryallen'; + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); + }); + + it('should include workspace rooms in the search results', () => { + const searchText = 'avengers'; + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); + }); + + it('should put exact match by login on the top of the list', () => { + const searchText = 'reedrichards@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); + }); + + it('should prioritize options with matching display name over chatrooms', () => { + const searchText = 'spider'; + const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); + + const filterOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filterOptions.recentReports.length).toBe(2); + expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); + }); + + it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { + const searchText = 'fantastic'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should return the user to invite when the search value is a valid, non-existent email', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should not return any results if the search value is on an exluded logins list', () => { + const searchText = 'admin@expensify.com'; + + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); + const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + expect(filterOptions.recentReports.length).toBe(0); + }); + + it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should return limited amount of recent reports if the limit is set', () => { + const searchText = ''; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); + + expect(filteredOptions.recentReports.length).toBe(2); + }); + + it('should not return any user to invite if email exists on the personal details list', () => { + const searchText = 'natasharomanoff@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); + }); + + it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { + // Filter current REPORTS as we do in the component, before getting share destination options + const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { + const report = option.item; + if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); + + expect(filteredOptions.recentReports.length).toBe(1); + }); + + it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); + + expect(filteredOptions.recentReports.length).toBe(0); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + }); + + it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); + }); + + it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); + }); + + it('should return matching option when searching (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + }); + + it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + + return waitForBatchedUpdates() + .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) + .then(() => { + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); + const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); + + expect(filteredResults.recentReports.length).toBe(1); + expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); + }); + }); + }; + + return { + generateOptions, + beforeAll, + createFilterTests, + PERSONAL_DETAILS, + REPORTS, + PERSONAL_DETAILS_WITH_PERIODS, + REPORTS_WITH_CHAT_ROOM, + }; +}; + +export default createOptionsListUtilsHelper; + +export type {PersonalDetailsList}; From 9ed2253d8a2f774698c9ba08905b61671e117efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 5 Nov 2024 10:27:14 +0100 Subject: [PATCH 009/165] Revert "wip: refactoring test to be reusable" This reverts commit a2d8012769451cccaea1e27f2ddf4154abcad201. --- tests/unit/OptionsListUtilsTest.ts | 741 +++++++++++++++++++++++++- tests/utils/OptionListUtilsHelper.ts | 765 --------------------------- 2 files changed, 723 insertions(+), 783 deletions(-) delete mode 100644 tests/utils/OptionListUtilsHelper.ts diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 76ed984d9194..5a0cd6638a07 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -1,37 +1,405 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import Onyx from 'react-native-onyx'; import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import type {SelectedTagOption} from '@components/TagPicker'; +import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; +import type {PersonalDetails, Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {PersonalDetailsList} from '../utils/OptionListUtilsHelper'; -import createOptionsListUtilsHelper from '../utils/OptionListUtilsHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +type PersonalDetailsList = Record; + describe('OptionsListUtils', () => { + // Given a set of reports with both single participants and multiple participants some pinned and some not + const REPORTS: OnyxCollection = { + '1': { + lastReadTime: '2021-01-14 11:25:39.295', + lastVisibleActionCreated: '2022-11-22 03:26:02.015', + isPinned: false, + reportID: '1', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Iron Man, Mister Fantastic, Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '2': { + lastReadTime: '2021-01-14 11:25:39.296', + lastVisibleActionCreated: '2022-11-22 03:26:02.016', + isPinned: false, + reportID: '2', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This is the only report we are pinning in this test + '3': { + lastReadTime: '2021-01-14 11:25:39.297', + lastVisibleActionCreated: '2022-11-22 03:26:02.170', + isPinned: true, + reportID: '3', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Fantastic', + type: CONST.REPORT.TYPE.CHAT, + }, + '4': { + lastReadTime: '2021-01-14 11:25:39.298', + lastVisibleActionCreated: '2022-11-22 03:26:02.180', + isPinned: false, + reportID: '4', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Black Panther', + type: CONST.REPORT.TYPE.CHAT, + }, + '5': { + lastReadTime: '2021-01-14 11:25:39.299', + lastVisibleActionCreated: '2022-11-22 03:26:02.019', + isPinned: false, + reportID: '5', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '6': { + lastReadTime: '2021-01-14 11:25:39.300', + lastVisibleActionCreated: '2022-11-22 03:26:02.020', + isPinned: false, + reportID: '6', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Thor', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has the largest lastVisibleActionCreated + '7': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:03.999', + isPinned: false, + reportID: '7', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Captain America', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has no lastVisibleActionCreated + '8': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '8', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Silver Surfer', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has an IOU + '9': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.998', + isPinned: false, + reportID: '9', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Sinister', + iouReportID: '100', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This report is an archived room – it does not have a name and instead falls back on oldPolicyName + '10': { + lastReadTime: '2021-01-14 11:25:39.200', + lastVisibleActionCreated: '2022-11-22 03:26:02.001', + reportID: '10', + isPinned: false, + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: "SHIELD's workspace", + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + + // This indicates that the report is archived + stateNum: 2, + statusNum: 2, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), + }, + }; + + // And a set of personalDetails some with existing reports and some without + const PERSONAL_DETAILS: PersonalDetailsList = { + // These exist in our reports + '1': { + accountID: 1, + displayName: 'Mister Fantastic', + login: 'reedrichards@expensify.com', + isSelected: true, + reportID: '1', + }, + '2': { + accountID: 2, + displayName: 'Iron Man', + login: 'tonystark@expensify.com', + reportID: '1', + }, + '3': { + accountID: 3, + displayName: 'Spider-Man', + login: 'peterparker@expensify.com', + reportID: '1', + }, + '4': { + accountID: 4, + displayName: 'Black Panther', + login: 'tchalla@expensify.com', + reportID: '1', + }, + '5': { + accountID: 5, + displayName: 'Invisible Woman', + login: 'suestorm@expensify.com', + reportID: '1', + }, + '6': { + accountID: 6, + displayName: 'Thor', + login: 'thor@expensify.com', + reportID: '1', + }, + '7': { + accountID: 7, + displayName: 'Captain America', + login: 'steverogers@expensify.com', + reportID: '1', + }, + '8': { + accountID: 8, + displayName: 'Mr Sinister', + login: 'mistersinister@marauders.com', + reportID: '1', + }, + + // These do not exist in reports at all + '9': { + accountID: 9, + displayName: 'Black Widow', + login: 'natasharomanoff@expensify.com', + reportID: '', + }, + '10': { + accountID: 10, + displayName: 'The Incredible Hulk', + login: 'brucebanner@expensify.com', + reportID: '', + }, + }; + + const REPORTS_WITH_CONCIERGE: OnyxCollection = { + ...REPORTS, + + '11': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '11', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Concierge', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_CHRONOS: OnyxCollection = { + ...REPORTS, + '12': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '12', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Chronos', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_RECEIPTS: OnyxCollection = { + ...REPORTS, + '13': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '13', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Receipts', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { + ...REPORTS, + '14': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '14', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: 'Avengers Room', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { + ...REPORTS, + 15: { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '15', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man, Black Panther', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + }, + }; + + const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { + ...PERSONAL_DETAILS, + '999': { + accountID: 999, + displayName: 'Concierge', + login: 'concierge@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1000': { + accountID: 1000, + displayName: 'Chronos', + login: 'chronos@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1001': { + accountID: 1001, + displayName: 'Receipts', + login: 'receipts@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1002': { + accountID: 1002, + displayName: 'The Flash', + login: 'barry.allen@expensify.com', + reportID: '', + }, + }; + + const policyID = 'ABC123'; + + const POLICY: Policy = { + id: policyID, + name: 'Hero Policy', + role: 'user', + type: CONST.POLICY.TYPE.TEAM, + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }; + + // Set the currently logged in user, report data, and personal details + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, + [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { + reportID: '', + ownerAccountID: 8, + total: 1000, + }, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, + }, + }); + Onyx.registerLogger(() => {}); + return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); + }); + let OPTIONS: OptionsListUtils.OptionList; let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; - const optionListUtilsHelper = createOptionsListUtilsHelper(); - - beforeAll(() => { - return optionListUtilsHelper.beforeAll(); - }); - beforeEach(() => { - const options = optionListUtilsHelper.generateOptions(); - OPTIONS = options.OPTIONS; - OPTIONS_WITH_CONCIERGE = options.OPTIONS_WITH_CONCIERGE; - OPTIONS_WITH_CHRONOS = options.OPTIONS_WITH_CHRONOS; - OPTIONS_WITH_RECEIPTS = options.OPTIONS_WITH_RECEIPTS; - OPTIONS_WITH_WORKSPACE_ROOM = options.OPTIONS_WITH_WORKSPACE_ROOM; + OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); + OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); + OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); + OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); + OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); }); it('getSearchOptions()', () => { @@ -2176,7 +2544,7 @@ describe('OptionsListUtils', () => { }); it('formatMemberForList()', () => { - const formattedMembers = Object.values(optionListUtilsHelper.PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); + const formattedMembers = Object.values(PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); // We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array expect(formattedMembers.at(0)?.text).toBe('Mister Fantastic'); @@ -2193,7 +2561,344 @@ describe('OptionsListUtils', () => { expect(formattedMembers.every((personalDetail) => !personalDetail.isDisabled)).toBe(true); }); - describe('filterOptions', () => optionListUtilsHelper.createFilterTests()); + describe('filterOptions', () => { + it('should return all options when search is empty', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, ''); + + expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); + }); + + it('should return filtered options in correct order', () => { + const searchText = 'man'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(4); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); + expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should filter users by email', () => { + const searchText = 'mistersinister@marauders.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); + }); + + it('should find archived chats', () => { + const searchText = 'Archived'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); + }); + + it('should filter options by email if dot is skipped in the email', () => { + const searchText = 'barryallen'; + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); + }); + + it('should include workspace rooms in the search results', () => { + const searchText = 'avengers'; + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); + }); + + it('should put exact match by login on the top of the list', () => { + const searchText = 'reedrichards@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); + }); + + it('should prioritize options with matching display name over chatrooms', () => { + const searchText = 'spider'; + const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); + + const filterOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filterOptions.recentReports.length).toBe(2); + expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); + }); + + it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { + const searchText = 'fantastic'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should return the user to invite when the search value is a valid, non-existent email', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should not return any results if the search value is on an exluded logins list', () => { + const searchText = 'admin@expensify.com'; + + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); + const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + expect(filterOptions.recentReports.length).toBe(0); + }); + + it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should return limited amount of recent reports if the limit is set', () => { + const searchText = ''; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); + + expect(filteredOptions.recentReports.length).toBe(2); + }); + + it('should not return any user to invite if email exists on the personal details list', () => { + const searchText = 'natasharomanoff@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); + }); + + it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { + // Filter current REPORTS as we do in the component, before getting share destination options + const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { + const report = option.item; + if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); + + expect(filteredOptions.recentReports.length).toBe(1); + }); + + it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); + + expect(filteredOptions.recentReports.length).toBe(0); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + }); + + it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); + }); + + it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); + }); + + it('should return matching option when searching (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + }); + + it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + + return waitForBatchedUpdates() + .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) + .then(() => { + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); + const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); + + expect(filteredResults.recentReports.length).toBe(1); + expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); + }); + }); + }); describe('canCreateOptimisticPersonalDetailOption', () => { const VALID_EMAIL = 'valid@email.com'; diff --git a/tests/utils/OptionListUtilsHelper.ts b/tests/utils/OptionListUtilsHelper.ts deleted file mode 100644 index d5055aebc5e5..000000000000 --- a/tests/utils/OptionListUtilsHelper.ts +++ /dev/null @@ -1,765 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import Onyx from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; -import DateUtils from '@libs/DateUtils'; -import CONST from '@src/CONST'; -import * as OptionsListUtils from '@src/libs/OptionsListUtils'; -import * as ReportUtils from '@src/libs/ReportUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; -import waitForBatchedUpdates from './waitForBatchedUpdates'; - -type PersonalDetailsList = Record; - -const createOptionsListUtilsHelper = () => { - // Given a set of reports with both single participants and multiple participants some pinned and some not - const REPORTS: OnyxCollection = { - '1': { - lastReadTime: '2021-01-14 11:25:39.295', - lastVisibleActionCreated: '2022-11-22 03:26:02.015', - isPinned: false, - reportID: '1', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Iron Man, Mister Fantastic, Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '2': { - lastReadTime: '2021-01-14 11:25:39.296', - lastVisibleActionCreated: '2022-11-22 03:26:02.016', - isPinned: false, - reportID: '2', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This is the only report we are pinning in this test - '3': { - lastReadTime: '2021-01-14 11:25:39.297', - lastVisibleActionCreated: '2022-11-22 03:26:02.170', - isPinned: true, - reportID: '3', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Fantastic', - type: CONST.REPORT.TYPE.CHAT, - }, - '4': { - lastReadTime: '2021-01-14 11:25:39.298', - lastVisibleActionCreated: '2022-11-22 03:26:02.180', - isPinned: false, - reportID: '4', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Black Panther', - type: CONST.REPORT.TYPE.CHAT, - }, - '5': { - lastReadTime: '2021-01-14 11:25:39.299', - lastVisibleActionCreated: '2022-11-22 03:26:02.019', - isPinned: false, - reportID: '5', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '6': { - lastReadTime: '2021-01-14 11:25:39.300', - lastVisibleActionCreated: '2022-11-22 03:26:02.020', - isPinned: false, - reportID: '6', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Thor', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has the largest lastVisibleActionCreated - '7': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:03.999', - isPinned: false, - reportID: '7', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Captain America', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has no lastVisibleActionCreated - '8': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '8', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Silver Surfer', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has an IOU - '9': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.998', - isPinned: false, - reportID: '9', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Sinister', - iouReportID: '100', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This report is an archived room – it does not have a name and instead falls back on oldPolicyName - '10': { - lastReadTime: '2021-01-14 11:25:39.200', - lastVisibleActionCreated: '2022-11-22 03:26:02.001', - reportID: '10', - isPinned: false, - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: "SHIELD's workspace", - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - - // This indicates that the report is archived - stateNum: 2, - statusNum: 2, - // eslint-disable-next-line @typescript-eslint/naming-convention - private_isArchived: DateUtils.getDBTime(), - }, - }; - - // And a set of personalDetails some with existing reports and some without - const PERSONAL_DETAILS: PersonalDetailsList = { - // These exist in our reports - '1': { - accountID: 1, - displayName: 'Mister Fantastic', - login: 'reedrichards@expensify.com', - isSelected: true, - reportID: '1', - }, - '2': { - accountID: 2, - displayName: 'Iron Man', - login: 'tonystark@expensify.com', - reportID: '1', - }, - '3': { - accountID: 3, - displayName: 'Spider-Man', - login: 'peterparker@expensify.com', - reportID: '1', - }, - '4': { - accountID: 4, - displayName: 'Black Panther', - login: 'tchalla@expensify.com', - reportID: '1', - }, - '5': { - accountID: 5, - displayName: 'Invisible Woman', - login: 'suestorm@expensify.com', - reportID: '1', - }, - '6': { - accountID: 6, - displayName: 'Thor', - login: 'thor@expensify.com', - reportID: '1', - }, - '7': { - accountID: 7, - displayName: 'Captain America', - login: 'steverogers@expensify.com', - reportID: '1', - }, - '8': { - accountID: 8, - displayName: 'Mr Sinister', - login: 'mistersinister@marauders.com', - reportID: '1', - }, - - // These do not exist in reports at all - '9': { - accountID: 9, - displayName: 'Black Widow', - login: 'natasharomanoff@expensify.com', - reportID: '', - }, - '10': { - accountID: 10, - displayName: 'The Incredible Hulk', - login: 'brucebanner@expensify.com', - reportID: '', - }, - }; - - const REPORTS_WITH_CONCIERGE: OnyxCollection = { - ...REPORTS, - - '11': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '11', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Concierge', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_CHRONOS: OnyxCollection = { - ...REPORTS, - '12': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '12', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Chronos', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_RECEIPTS: OnyxCollection = { - ...REPORTS, - '13': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '13', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Receipts', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { - ...REPORTS, - '14': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '14', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: 'Avengers Room', - chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { - ...PERSONAL_DETAILS, - '999': { - accountID: 999, - displayName: 'Concierge', - login: 'concierge@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1000': { - accountID: 1000, - displayName: 'Chronos', - login: 'chronos@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1001': { - accountID: 1001, - displayName: 'Receipts', - login: 'receipts@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1002': { - accountID: 1002, - displayName: 'The Flash', - login: 'barry.allen@expensify.com', - reportID: '', - }, - }; - - const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { - ...REPORTS, - 15: { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '15', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man, Black Panther', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, - }, - }; - - const policyID = 'ABC123'; - - const POLICY: Policy = { - id: policyID, - name: 'Hero Policy', - role: 'user', - type: CONST.POLICY.TYPE.TEAM, - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }; - - // Set the currently logged in user, report data, and personal details - const beforeAll = () => { - Onyx.init({ - keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, - [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { - reportID: '', - ownerAccountID: 8, - total: 1000, - }, - [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, - }, - }); - Onyx.registerLogger(() => {}); - return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); - }; - - // TODO: duplicate code? - let OPTIONS: OptionsListUtils.OptionList; - let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; - let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; - let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; - let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; - - const generateOptions = () => { - OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); - OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); - OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); - OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); - OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); - - return { - OPTIONS, - OPTIONS_WITH_CONCIERGE, - OPTIONS_WITH_CHRONOS, - OPTIONS_WITH_RECEIPTS, - OPTIONS_WITH_WORKSPACE_ROOM, - }; - }; - - // TODO: our search is basically just for this use-case: filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); - const createFilterTests = () => { - it('should return all options when search is empty', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, ''); - - expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); - }); - - it('should return filtered options in correct order', () => { - const searchText = 'man'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(4); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); - expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should filter users by email', () => { - const searchText = 'mistersinister@marauders.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); - }); - - it('should find archived chats', () => { - const searchText = 'Archived'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); - }); - - it('should filter options by email if dot is skipped in the email', () => { - const searchText = 'barryallen'; - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); - }); - - it('should include workspace rooms in the search results', () => { - const searchText = 'avengers'; - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); - }); - - it('should put exact match by login on the top of the list', () => { - const searchText = 'reedrichards@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); - }); - - it('should prioritize options with matching display name over chatrooms', () => { - const searchText = 'spider'; - const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); - - const filterOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filterOptions.recentReports.length).toBe(2); - expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); - }); - - it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { - const searchText = 'fantastic'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should return the user to invite when the search value is a valid, non-existent email', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should not return any results if the search value is on an exluded logins list', () => { - const searchText = 'admin@expensify.com'; - - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); - const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - expect(filterOptions.recentReports.length).toBe(0); - }); - - it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should return limited amount of recent reports if the limit is set', () => { - const searchText = ''; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); - - expect(filteredOptions.recentReports.length).toBe(2); - }); - - it('should not return any user to invite if email exists on the personal details list', () => { - const searchText = 'natasharomanoff@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); - }); - - it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { - // Filter current REPORTS as we do in the component, before getting share destination options - const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { - const report = option.item; - if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); - - expect(filteredOptions.recentReports.length).toBe(1); - }); - - it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); - - expect(filteredOptions.recentReports.length).toBe(0); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - }); - - it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); - }); - - it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); - }); - - it('should return matching option when searching (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - }); - - it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - - return waitForBatchedUpdates() - .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) - .then(() => { - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); - const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); - - expect(filteredResults.recentReports.length).toBe(1); - expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); - }); - }); - }; - - return { - generateOptions, - beforeAll, - createFilterTests, - PERSONAL_DETAILS, - REPORTS, - PERSONAL_DETAILS_WITH_PERIODS, - REPORTS_WITH_CHAT_ROOM, - }; -}; - -export default createOptionsListUtilsHelper; - -export type {PersonalDetailsList}; From a01a375a7368b2e69603fa4aed5401de48c7c74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 5 Nov 2024 11:49:28 +0100 Subject: [PATCH 010/165] fix: sort search results correctly --- .../Search/SearchRouter/SearchRouter.tsx | 15 ++++++++++----- src/libs/OptionsListUtils.ts | 14 +++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 6f5481a17983..4b1a7a9ed4ad 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -162,19 +162,24 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const newOptions = findInSearchTree(debouncedInputValue); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const recentReports = newOptions.recentReports.concat(newOptions.personalDetails); + // See OptionListUtils.filterOptions#sortByReportTypeInSearch: + const filteredPersonalDetails = OptionsListUtils.filteredPersonalDetailsOfRecentReports(newOptions.recentReports, newOptions.personalDetails); + const recentReportsWithPersonalDetails = newOptions.recentReports.concat(filteredPersonalDetails); + const sortedReports = OptionsListUtils.orderOptions(recentReportsWithPersonalDetails, debouncedInputValue, { + preferChatroomsOverThreads: true, + }); const userToInvite = OptionsListUtils.pickUserToInvite({ canInviteUser: true, - recentReports: newOptions.recentReports, - personalDetails: newOptions.personalDetails, + recentReports: sortedReports, + personalDetails: filteredPersonalDetails, searchValue: debouncedInputValue, optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], }); return { - recentReports, - personalDetails: [], + recentReports: sortedReports, + personalDetails: filteredPersonalDetails, userToInvite, }; }, [debouncedInputValue, findInSearchTree]); diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index f414d2328ef6..efeca80c1fd1 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2502,6 +2502,14 @@ const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, search return userToInvite; }; +/** + * Remove the personal details for the DMs that are already in the recent reports so that we don't show duplicates + */ +function filteredPersonalDetailsOfRecentReports(recentReports: ReportUtils.OptionData[], personalDetails: ReportUtils.OptionData[]) { + const excludedLogins = new Set(recentReports.map((report) => report.login)); + return personalDetails.filter((personalDetail) => !excludedLogins.has(personalDetail.login)); +} + /** * Filters options based on the search input value */ @@ -2515,11 +2523,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt preferPolicyExpenseChat = false, preferRecentExpenseReports = false, } = config ?? {}; - // Remove the personal details for the DMs that are already in the recent reports so that we don't show duplicates - function filteredPersonalDetailsOfRecentReports(recentReports: ReportUtils.OptionData[], personalDetails: ReportUtils.OptionData[]) { - const excludedLogins = new Set(recentReports.map((report) => report.login)); - return personalDetails.filter((personalDetail) => !excludedLogins.has(personalDetail.login)); - } if (searchInputValue.trim() === '' && maxRecentReportsToShow > 0) { const recentReports = options.recentReports.slice(0, maxRecentReportsToShow); const personalDetails = filteredPersonalDetailsOfRecentReports(recentReports, options.personalDetails); @@ -2676,6 +2679,7 @@ export { getAlternateText, pickUserToInvite, hasReportErrors, + filteredPersonalDetailsOfRecentReports, }; export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree}; From 1e02c8257083ae87dccfa30e4315672fb929664d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 7 Nov 2024 10:47:26 +0100 Subject: [PATCH 011/165] cleanup option list --- src/libs/OptionsListUtils.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 099efcc1bfd9..56ae9e94deb7 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2583,13 +2583,15 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt }; }, options); - let {recentReports, personalDetails} = matchResults; + const {recentReports, personalDetails} = matchResults; + const noneReportPersonalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails); + + let filteredPersonalDetails: ReportUtils.OptionData[] = noneReportPersonalDetails; + let filteredRecentReports: ReportUtils.OptionData[] = recentReports; if (sortByReportTypeInSearch) { - personalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails); - recentReports = recentReports.concat(personalDetails); - personalDetails = []; - recentReports = orderOptions(recentReports, searchValue); + filteredRecentReports = recentReports.concat(noneReportPersonalDetails); + filteredPersonalDetails = []; } const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}); @@ -2597,11 +2599,11 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) { recentReports.splice(maxRecentReportsToShow); } - const filteredPersonalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails); + const sortedRecentReports = orderOptions(filteredRecentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}); return { personalDetails: filteredPersonalDetails, - recentReports: orderOptions(recentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}), + recentReports: sortedRecentReports, userToInvite, currentUserOption: matchResults.currentUserOption, categoryOptions: [], From c73aad5b75f156928209d1236720f0653f96b358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 7 Nov 2024 10:50:30 +0100 Subject: [PATCH 012/165] fix duplicate search results --- src/components/Search/SearchRouter/SearchRouter.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 5d2d00bfeb43..968bb234ff5b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -135,8 +135,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); // See OptionListUtils.filterOptions#sortByReportTypeInSearch: - const filteredPersonalDetails = OptionsListUtils.filteredPersonalDetailsOfRecentReports(newOptions.recentReports, newOptions.personalDetails); - const recentReportsWithPersonalDetails = newOptions.recentReports.concat(filteredPersonalDetails); + const noneReportPersonalDetails = OptionsListUtils.filteredPersonalDetailsOfRecentReports(newOptions.recentReports, newOptions.personalDetails); + const recentReportsWithPersonalDetails = newOptions.recentReports.concat(noneReportPersonalDetails); const sortedReports = OptionsListUtils.orderOptions(recentReportsWithPersonalDetails, debouncedInputValue, { preferChatroomsOverThreads: true, }); @@ -144,14 +144,14 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const userToInvite = OptionsListUtils.pickUserToInvite({ canInviteUser: true, recentReports: sortedReports, - personalDetails: filteredPersonalDetails, + personalDetails: [], searchValue: debouncedInputValue, optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], }); return { recentReports: sortedReports, - personalDetails: filteredPersonalDetails, + personalDetails: [], userToInvite, }; }, [debouncedInputValue, findInSearchTree]); From 6a7b7e84a2f10cf5749b5c39494129bd05a9b8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 7 Nov 2024 17:00:20 +0100 Subject: [PATCH 013/165] eslint --- src/components/Search/SearchRouter/SearchRouter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 968bb234ff5b..0c1fd1a9094b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -16,8 +16,8 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import FastSearch from '@libs/FastSearch'; import * as CardUtils from '@libs/CardUtils'; +import FastSearch from '@libs/FastSearch'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; From 253d17b8baab9bad3d6fd97fa8a9cc6551b75980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 8 Nov 2024 10:16:51 +0100 Subject: [PATCH 014/165] wip --- src/libs/OptionsListUtils.ts | 6 +++++- tests/unit/OptionsListUtilsTest.ts | 24 ++++++++++++++++++++++++ tests/unit/SuffixUkkonenTreeTest.ts | 9 +++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 56ae9e94deb7..fba245b330b1 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1687,7 +1687,11 @@ function getUserToInviteOption({ } /** - * filter options based on specific conditions + * TODO: What is the purpose of this function + * + * - It seems to convert Report & PersonalDetails into a unified format + * - It applies ordering to the items + * - Given a searchValue, it will filter the items */ function getOptions( options: OptionList, diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 5a0cd6638a07..65ef0aebd000 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -225,6 +225,18 @@ describe('OptionsListUtils', () => { login: 'brucebanner@expensify.com', reportID: '', }, + '110': { + accountID: 110, + displayName: 'SubString', + login: 'SubString@mail.com', + reportID: '', + }, + '111': { + accountID: 111, + displayName: 'String', + login: 'String@mail.com', + reportID: '', + }, }; const REPORTS_WITH_CONCIERGE: OnyxCollection = { @@ -396,6 +408,7 @@ describe('OptionsListUtils', () => { beforeEach(() => { OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); + console.log(OPTIONS); OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); @@ -2898,6 +2911,17 @@ describe('OptionsListUtils', () => { expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); }); }); + + it('should return prefix match before suffix match', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + console.log(options); + const filteredOptions = OptionsListUtils.filterOptions(options, 'String'); + + console.log(filteredOptions); + expect(filteredOptions.personalDetails.length).toBe(2); + expect(filteredOptions.personalDetails.at(0)?.text).toBe('String'); + expect(filteredOptions.personalDetails.at(1)?.text).toBe('SubString'); + }); }); describe('canCreateOptimisticPersonalDetailOption', () => { diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts index c0c556c16e14..0cf34fff3308 100644 --- a/tests/unit/SuffixUkkonenTreeTest.ts +++ b/tests/unit/SuffixUkkonenTreeTest.ts @@ -60,4 +60,13 @@ describe('SuffixUkkonenTree', () => { // "2" in ASCII is 50, so base26(50) = [0, 23] expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]); }); + + it('should have prefix matches first, then substring matches', () => { + const strings = ['abcdef', 'cdef', 'def']; + const numericIntArray = helperStringsToNumericForTree(strings); + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('def', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([18, 15, 10])); + }); }); From 50965a73228416b21856b56fdc8dc820f880aadd Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 18 Nov 2024 14:45:18 +0700 Subject: [PATCH 015/165] prevent refocus on closing modal --- .../ComposerWithSuggestions.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 582aabf06c9e..210d3e17bc7d 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -29,6 +29,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {forceClearInput} from '@libs/ComponentUtils'; import * as ComposerUtils from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; @@ -624,7 +625,15 @@ function ComposerWithSuggestions( // 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 && canFocusInputOnScreenFocus())) && + !isNextModalWillOpenRef.current && + !modal?.isVisible && + isFocused && + (!!prevIsModalVisible || !prevIsFocused) + ) + ) { return; } From 857262238f1518f5c0ac0461dccf5d1844bb8330 Mon Sep 17 00:00:00 2001 From: christianwen Date: Wed, 20 Nov 2024 15:53:25 +0700 Subject: [PATCH 016/165] fix cursor flash --- .../Composer/implementation/index.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index bf155bfdc04b..cdf43ecd8d90 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -4,7 +4,7 @@ import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; -import {DeviceEventEmitter, StyleSheet} from 'react-native'; +import {DeviceEventEmitter, InteractionManager, StyleSheet} from 'react-native'; import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -72,6 +72,11 @@ function Composer( end: selectionProp.end, }); const [isRendered, setIsRendered] = useState(false); + + // On mobile safari, the cursor will move from right to left with inputMode set to none during report transition + // To avoid that we should hide the cursor util the transition is finished + const [shouldTransparentCursor, setShouldTransparentCursor] = useState(!showSoftInputOnFocus && Browser.isMobileSafari()); + const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); const [prevScroll, setPrevScroll] = useState(); const isReportFlatListScrolling = useRef(false); @@ -256,6 +261,15 @@ function Composer( setIsRendered(true); }, []); + useEffect(() => { + if (!shouldTransparentCursor) { + return; + } + InteractionManager.runAfterInteractions(() => { + setShouldTransparentCursor(false); + }); + }, [shouldTransparentCursor]); + const clear = useCallback(() => { if (!textInput.current) { return; @@ -343,7 +357,7 @@ function Composer( placeholderTextColor={theme.placeholderText} ref={(el) => (textInput.current = el)} selection={selection} - style={[inputStyleMemo]} + style={[inputStyleMemo, shouldTransparentCursor ? {color: 'transparent'} : undefined]} markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} From 6019f6465f5ed94c18fbe2205784ff2b568698f8 Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 25 Nov 2024 15:04:56 +0700 Subject: [PATCH 017/165] transparent caret safari --- src/components/Composer/implementation/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index aa7ab0f87898..3a78d9fda0a5 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -359,7 +359,7 @@ function Composer( placeholderTextColor={theme.placeholderText} ref={(el) => (textInput.current = el)} selection={selection} - style={[inputStyleMemo, shouldTransparentCursor ? {color: 'transparent'} : undefined]} + style={[inputStyleMemo, shouldTransparentCursor ? {caretColor: 'transparent'} : undefined]} markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} From 8e2d09b1dabbd8e4614fc140a742b4a8b84931e6 Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 2 Dec 2024 12:29:30 +0700 Subject: [PATCH 018/165] fix: composer hide chat --- src/pages/home/ReportScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index d3e03360ac4e..bd2207f9e910 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -749,7 +749,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro Date: Wed, 4 Dec 2024 17:41:42 +0700 Subject: [PATCH 019/165] fix: some member appear as Hidden --- src/libs/PersonalDetailsUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 3d9aed117ca3..31d344facb32 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -75,7 +75,7 @@ function getDisplayNameOrDefault(passedPersonalDetails?: Partial Date: Thu, 5 Dec 2024 21:13:33 +0100 Subject: [PATCH 020/165] replace FlatList with .map solution --- .../Transaction/DebugTransactionViolations.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/pages/Debug/Transaction/DebugTransactionViolations.tsx b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx index d3e37f726a96..e13fd01fdcd7 100644 --- a/src/pages/Debug/Transaction/DebugTransactionViolations.tsx +++ b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import type {ListRenderItemInfo} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; -import FlatList from '@components/FlatList'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -23,12 +21,13 @@ function DebugTransactionViolations({transactionID}: DebugTransactionViolationsP const styles = useThemeStyles(); const {translate} = useLocalize(); - const renderItem = ({item, index}: ListRenderItemInfo) => ( + const renderItem = (item: TransactionViolation, index: number) => ( Navigation.navigate(ROUTES.DEBUG_TRANSACTION_VIOLATION.getRoute(transactionID, String(index)))} style={({pressed}) => [styles.flexRow, styles.justifyContentBetween, pressed && styles.hoveredComponentBG, styles.p4]} hoverStyle={styles.hoveredComponentBG} + key={index} > {item.type} {item.name} @@ -44,11 +43,9 @@ function DebugTransactionViolations({transactionID}: DebugTransactionViolationsP onPress={() => Navigation.navigate(ROUTES.DEBUG_TRANSACTION_VIOLATION_CREATE.getRoute(transactionID))} style={[styles.pb5, styles.ph3]} /> - + {/* This list was previously rendered as a FlatList, but it turned out that it caused the component to flash in some cases, + so it was replaced by this solution. */} + {transactionViolations?.map((item, index) => renderItem(item, index))} ); } From 5950c1180c199ba9364b2b19a3dcf1691ba82b14 Mon Sep 17 00:00:00 2001 From: daledah Date: Fri, 6 Dec 2024 14:48:37 +0700 Subject: [PATCH 021/165] refactor: change logics to more precise --- src/libs/PersonalDetailsUtils.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 31d344facb32..395ab930b116 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -52,6 +52,8 @@ const regexMergedAccount = new RegExp(CONST.REGEX.MERGED_ACCOUNT_PREFIX); function getDisplayNameOrDefault(passedPersonalDetails?: Partial | null, defaultValue = '', shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false): string { let displayName = passedPersonalDetails?.displayName ?? ''; + let login = passedPersonalDetails?.login ?? ''; + // If the displayName starts with the merged account prefix, remove it. if (regexMergedAccount.test(displayName)) { // Remove the merged account prefix from the displayName. @@ -60,8 +62,11 @@ function getDisplayNameOrDefault(passedPersonalDetails?: Partial Date: Fri, 6 Dec 2024 11:31:09 +0100 Subject: [PATCH 022/165] Replace FlatList with .map solution in DebugReportActions.tsx --- src/pages/Debug/Report/DebugReportActions.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx index 9368ca5116bd..fdc2aa8b1ca8 100644 --- a/src/pages/Debug/Report/DebugReportActions.tsx +++ b/src/pages/Debug/Report/DebugReportActions.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import type {ListRenderItemInfo} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; -import FlatList from '@components/FlatList'; import {PressableWithFeedback} from '@components/Pressable'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -28,17 +26,20 @@ function DebugReportActions({reportID}: DebugReportActionsProps) { canEvict: false, selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, canUserPerformWriteAction, true), }); - const renderItem = ({item}: ListRenderItemInfo) => ( + + const renderItem = (item: ReportAction, index: number) => ( Navigation.navigate(ROUTES.DEBUG_REPORT_ACTION.getRoute(reportID, item.reportActionID))} style={({pressed}) => [styles.flexRow, styles.justifyContentBetween, pressed && styles.hoveredComponentBG, styles.p4]} hoverStyle={styles.hoveredComponentBG} + key={index} > {item.reportActionID} {datetimeToCalendarTime(item.created, false, false)} ); + return (