From 1b5c5902d1344fcb5ba6071c39c22f49ae9f7c40 Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 21 Oct 2024 17:50:19 +0700 Subject: [PATCH 001/323] 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/323] 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/323] 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/323] 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/323] 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/323] 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/323] 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/323] 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/323] 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/323] 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 a162fe71045c28c4d5f1075e27b84e90f3143185 Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Tue, 5 Nov 2024 15:07:15 -0800 Subject: [PATCH 011/323] fix wallet phone validation page --- src/libs/GetPhysicalCardUtils.ts | 6 +++++- .../settings/Wallet/Card/GetPhysicalCardPhone.tsx | 15 +++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts index 8dc46204db3c..ff0c02aff54c 100644 --- a/src/libs/GetPhysicalCardUtils.ts +++ b/src/libs/GetPhysicalCardUtils.ts @@ -1,4 +1,6 @@ +import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; +import * as PhoneNumberUtils from '@libs/PhoneNumber'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; @@ -11,11 +13,13 @@ import * as UserUtils from './UserUtils'; function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route { const {legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {}; const address = PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails); + const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(phoneNumber ?? ''); + const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumberWithCountryCode); if (!legalFirstName && !legalLastName) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain); } - if (!phoneNumber || !LoginUtils.validateNumber(phoneNumber)) { + if (!phoneNumber || !parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumberWithCountryCode.slice(0))) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain); } if (!(address?.street && address?.city && address?.state && address?.country && address?.zip)) { diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx index 56d5a29a3203..ad04d9f3fb9c 100644 --- a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx @@ -1,4 +1,5 @@ import type {StackScreenProps} from '@react-navigation/stack'; +import {Str} from 'expensify-common'; import React from 'react'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -6,6 +7,8 @@ import InputWrapper from '@components/Form/InputWrapper'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import * as LoginUtils from '@libs/LoginUtils'; +import * as PhoneNumberUtils from '@libs/PhoneNumber'; +import * as ValidationUtils from '@libs/ValidationUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -40,13 +43,17 @@ function GetPhysicalCardPhone({ const {phoneNumber: phoneNumberToValidate = ''} = values ?? {}; const errors: OnValidateResult = {}; - - if (!LoginUtils.validateNumber(phoneNumberToValidate)) { - errors.phoneNumber = translate('common.error.phoneNumber'); - } else if (!phoneNumberToValidate) { + if (!ValidationUtils.isRequiredFulfilled(phoneNumberToValidate)) { errors.phoneNumber = translate('common.error.fieldRequired'); } + const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(phoneNumberToValidate); + const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumberWithCountryCode); + + if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumberWithCountryCode.slice(0))) { + errors.phoneNumber = translate('bankAccount.error.phoneNumber'); + } + return errors; }; From 8e3babe9a7e599b8f1d18a52d3bfdc77c4d0c464 Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Wed, 6 Nov 2024 22:39:18 -0800 Subject: [PATCH 012/323] fix eslint warning and unnecessary changes --- src/libs/GetPhysicalCardUtils.ts | 2 +- src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts index ff0c02aff54c..9ed192b09233 100644 --- a/src/libs/GetPhysicalCardUtils.ts +++ b/src/libs/GetPhysicalCardUtils.ts @@ -1,6 +1,5 @@ import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; -import * as PhoneNumberUtils from '@libs/PhoneNumber'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; @@ -8,6 +7,7 @@ import type {LoginList, PrivatePersonalDetails} from '@src/types/onyx'; import * as LoginUtils from './LoginUtils'; import Navigation from './Navigation/Navigation'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as PhoneNumberUtils from './PhoneNumber'; import * as UserUtils from './UserUtils'; function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route { diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx index ad04d9f3fb9c..ce50a224c20d 100644 --- a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx @@ -43,6 +43,7 @@ function GetPhysicalCardPhone({ const {phoneNumber: phoneNumberToValidate = ''} = values ?? {}; const errors: OnValidateResult = {}; + if (!ValidationUtils.isRequiredFulfilled(phoneNumberToValidate)) { errors.phoneNumber = translate('common.error.fieldRequired'); } 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 013/323] 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 014/323] 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 015/323] 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 016/323] 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 81cf18979ba9c934d4eed36deb729ef2fd307e4d Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 14 Nov 2024 16:55:46 +0700 Subject: [PATCH 017/323] Update correct next approver with category/tag rules --- src/libs/ReportUtils.ts | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b220c2db20b6..5211ea986bd9 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -61,6 +61,7 @@ import * as IOU from './actions/IOU'; import * as PolicyActions from './actions/Policy/Policy'; import * as store from './actions/ReimbursementAccount/store'; import * as SessionUtils from './actions/Session'; +import {getCategoryApproverRule} from './CategoryUtils'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import {hasValidDraftComment} from './DraftCommentUtils'; @@ -6351,7 +6352,7 @@ function shouldDisplayViolationsRBRInLHN(report: OnyxEntry, transactionV // - Belong to the same workspace // And if any have a violation, then it should have a RBR const allReports = Object.values(ReportConnection.getAllReports() ?? {}) as Report[]; - const potentialReports = allReports.filter((r) => r.ownerAccountID === currentUserAccountID && (r.stateNum ?? 0) <= 1 && r.policyID === report.policyID); + const potentialReports = allReports.filter((r) => r?.ownerAccountID === currentUserAccountID && (r.stateNum ?? 0) <= 1 && r.policyID === report.policyID); return potentialReports.some( (potentialReport) => hasViolations(potentialReport.reportID, transactionViolations) || hasWarningTypeViolations(potentialReport.reportID, transactionViolations), ); @@ -8368,9 +8369,47 @@ function isExported(reportActions: OnyxEntry) { return Object.values(reportActions).some((action) => ReportActionsUtils.isExportIntegrationAction(action)); } +function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { + const categoryAppovers: string[] = []; + const tagApprovers: string[] = []; + const allReportTransactions = TransactionUtils.getAllReportTransactions(expenseReport?.reportID).sort((transA, transB) => (transA.created < transB.created ? -1 : 1)); + + // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. + // Category approvers are prioritized, then tag approvers. + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); + const tag = TransactionUtils.getTag(transaction); + const category = TransactionUtils.getCategory(transaction); + const categoryAppover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + const tagApprover = PolicyUtils.getTagApproverRule(policy?.id ?? '-1', tag)?.approver; + if (categoryAppover) { + categoryAppovers.push(categoryAppover); + } + + if (tagApprover) { + tagApprovers.push(tagApprover); + } + } + + return [...categoryAppovers, ...tagApprovers]; +} + function getApprovalChain(policy: OnyxEntry, expenseReport: OnyxEntry): string[] { const approvalChain: string[] = []; const reportTotal = expenseReport?.total ?? 0; + const submitterEmail = PersonalDetailsUtils.getLoginsByAccountIDs([expenseReport?.ownerAccountID ?? -1]).at(0) ?? ''; + + // Get category/tag approver list + const ruleApprovers = getRuleApprovers(policy, expenseReport); + + // Push rule approvers to approvalChain list before submitsTo/forwardsTo approvers + ruleApprovers.forEach((ruleApprover) => { + // Don't push submiiter to approve as a rule approver + if (approvalChain.includes(ruleApprover) || ruleApprover === submitterEmail) { + return; + } + approvalChain.push(ruleApprover); + }); // If the policy is not on advanced approval mode, we should not use the approval chain even if it exists. if (!PolicyUtils.isControlOnAdvancedApprovalMode(policy)) { From 50965a73228416b21856b56fdc8dc820f880aadd Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 18 Nov 2024 14:45:18 +0700 Subject: [PATCH 018/323] 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 019/323] 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 020/323] 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 9fa35aba365bb10caa0048a7087f34c3532d50c7 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Mon, 25 Nov 2024 22:36:08 +0700 Subject: [PATCH 021/323] sort all transactons correctly --- src/libs/PolicyUtils.ts | 4 +-- src/libs/ReportUtils.ts | 2 +- src/libs/TransactionUtils/index.ts | 40 +++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index dc1c07388293..e47e19f3d330 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -37,7 +37,7 @@ import * as Localize from './Localize'; import Navigation from './Navigation/Navigation'; import * as NetworkStore from './Network/NetworkStore'; import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils'; -import {getAllReportTransactions, getCategory, getTag} from './TransactionUtils'; +import {getAllSortedTransactions, getCategory, getTag} from './TransactionUtils'; type MemberEmailsToAccountIDs = Record; @@ -538,7 +538,7 @@ function getSubmitToAccountID(policy: OnyxEntry, expenseReport: OnyxEntr let categoryAppover; let tagApprover; - const allTransactions = getAllReportTransactions(expenseReport?.reportID).sort((transA, transB) => (transA.created < transB.created ? -1 : 1)); + const allTransactions = getAllSortedTransactions(expenseReport?.reportID ?? '-1'); // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. // Category approvers are prioritized, then tag approvers. diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ff9e85c24e82..e598289a373c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -8387,7 +8387,7 @@ function isExported(reportActions: OnyxEntry) { function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { const categoryAppovers: string[] = []; const tagApprovers: string[] = []; - const allReportTransactions = TransactionUtils.getAllReportTransactions(expenseReport?.reportID).sort((transA, transB) => (transA.created < transB.created ? -1 : 1)); + const allReportTransactions = TransactionUtils.getAllSortedTransactions(expenseReport?.reportID ?? '-1'); // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. // Category approvers are prioritized, then tag approvers. diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 1fc7cad2f456..44058cd416d0 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -25,7 +25,20 @@ import type {IOURequestType} from '@userActions/IOU'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type { + Beta, + OnyxInputOrEntry, + Policy, + RecentWaypoint, + Report, + ReportAction, + ReviewDuplicates, + TaxRate, + TaxRates, + Transaction, + TransactionViolation, + TransactionViolations, +} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {SearchPolicy, SearchReport} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; @@ -1218,6 +1231,30 @@ function buildTransactionsMergeParams(reviewDuplicates: OnyxEntry> { + // We need sort all transactions by sorting the parent report actions because `created` of the transaction only has format `YYYY-MM-DD` which can cause the wrong sorting + const allCreatedIOUActions = Object.values(ReportActionsUtils.getAllReportActions(iouReportID)) + ?.filter((reportAction): reportAction is ReportAction => { + if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) { + return false; + } + const message = ReportActionsUtils.getOriginalMessage(reportAction); + if (!message?.IOUTransactionID) { + return false; + } + return true; + }) + .sort((actionA, actionB) => (actionA.created < actionB.created ? -1 : 1)); + + return allCreatedIOUActions.map((iouAction) => { + const transactionID = ReportActionsUtils.getOriginalMessage(iouAction)?.IOUTransactionID ?? '-1'; + return getTransaction(transactionID); + }); +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -1301,6 +1338,7 @@ export { getCardName, hasReceiptSource, shouldShowAttendees, + getAllSortedTransactions, }; export type {TransactionChanges}; From b4c8b46bc8e4a51eae99310499d946937d85cfc4 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 30 Nov 2024 17:16:29 +0800 Subject: [PATCH 022/323] apply tax rule when selecting category --- src/libs/actions/IOU.ts | 8 ++++ .../request/step/IOURequestStepCategory.tsx | 39 +++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 90719ffeed55..97aa5bb4c717 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3203,6 +3203,8 @@ function updateMoneyRequestCategory( transactionID: string, transactionThreadReportID: string, category: string, + categoryTaxCode: string | undefined, + categoryTaxAmount: number | undefined, policy: OnyxEntry, policyTagList: OnyxEntry, policyCategories: OnyxEntry, @@ -3210,6 +3212,12 @@ function updateMoneyRequestCategory( const transactionChanges: TransactionChanges = { category, }; + + if (categoryTaxCode && categoryTaxAmount !== undefined) { + transactionChanges['taxCode'] = categoryTaxCode; + transactionChanges['taxAmount'] = categoryTaxAmount; + } + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); } diff --git a/src/pages/iou/request/step/IOURequestStepCategory.tsx b/src/pages/iou/request/step/IOURequestStepCategory.tsx index 6f81d6ea3443..7b478cff75ec 100644 --- a/src/pages/iou/request/step/IOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategory.tsx @@ -14,6 +14,8 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as CategoryUtils from '@libs/CategoryUtils'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -68,7 +70,8 @@ function IOURequestStepCategory({ const {translate} = useLocalize(); const isEditing = action === CONST.IOU.ACTION.EDIT; const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT; - const transactionCategory = ReportUtils.getTransactionDetails(isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction)?.category; + const currentTransaction = isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction; + const transactionCategory = ReportUtils.getTransactionDetails(currentTransaction)?.category; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const reportAction = reportActions?.[report?.parentReportActionID || reportActionID] ?? null; @@ -109,17 +112,42 @@ function IOURequestStepCategory({ const categorySearchText = category.searchText ?? ''; const isSelectedCategory = categorySearchText === transactionCategory; const updatedCategory = isSelectedCategory ? '' : categorySearchText; + const categoryTaxCode = CategoryUtils.getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], updatedCategory, policy?.taxRates?.defaultExternalID); + let categoryTaxPercentage; + let categoryTaxAmount; + + if (categoryTaxCode) { + categoryTaxPercentage = TransactionUtils.getTaxValue(policy, currentTransaction, categoryTaxCode); + + if (categoryTaxPercentage) { + const isFromExpenseReport = ReportUtils.isExpenseReport(report) || ReportUtils.isPolicyExpenseChat(report); + categoryTaxAmount = CurrencyUtils.convertToBackendAmount( + TransactionUtils.calculateTaxAmount( + categoryTaxPercentage, + TransactionUtils.getAmount(currentTransaction, isFromExpenseReport), + TransactionUtils.getCurrency(transaction), + ), + ); + } + } + + console.log(categoryTaxPercentage, categoryTaxAmount); if (transaction) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value if (isEditingSplitBill) { - IOU.setDraftSplitTransaction(transaction.transactionID, {category: updatedCategory}); + const transactionChanges: TransactionUtils.TransactionChanges = {category: updatedCategory}; + if (categoryTaxCode && categoryTaxAmount !== undefined) { + transactionChanges.taxCode = categoryTaxCode; + transactionChanges.taxAmount = categoryTaxAmount; + } + IOU.setDraftSplitTransaction(transaction.transactionID, transactionChanges); navigateBack(); return; } if (isEditing && report) { - IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, policy, policyTags, policyCategories); + IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, categoryTaxCode, categoryTaxAmount, policy, policyTags, policyCategories); navigateBack(); return; } @@ -127,6 +155,11 @@ function IOURequestStepCategory({ IOU.setMoneyRequestCategory(transactionID, updatedCategory); + if (categoryTaxCode && categoryTaxAmount !== undefined) { + IOU.setMoneyRequestTaxRate(transactionID, categoryTaxCode); + IOU.setMoneyRequestTaxAmount(transactionID, categoryTaxAmount); + } + if (action === CONST.IOU.ACTION.CATEGORIZE) { Navigation.closeAndNavigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, report?.reportID ?? '-1')); return; From 622fe8bb19e9cb99f1a316d39c6981a4e15ed39b Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 30 Nov 2024 17:37:41 +0800 Subject: [PATCH 023/323] remove log --- src/pages/iou/request/step/IOURequestStepCategory.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepCategory.tsx b/src/pages/iou/request/step/IOURequestStepCategory.tsx index 7b478cff75ec..e148b527f19a 100644 --- a/src/pages/iou/request/step/IOURequestStepCategory.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategory.tsx @@ -131,8 +131,6 @@ function IOURequestStepCategory({ } } - console.log(categoryTaxPercentage, categoryTaxAmount); - if (transaction) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value if (isEditingSplitBill) { From 438ad047da5893d05fe4f193ae27631e453f632c Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 30 Nov 2024 17:43:34 +0800 Subject: [PATCH 024/323] lint --- src/libs/actions/IOU.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 97aa5bb4c717..3c50ee3f32d4 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3214,8 +3214,8 @@ function updateMoneyRequestCategory( }; if (categoryTaxCode && categoryTaxAmount !== undefined) { - transactionChanges['taxCode'] = categoryTaxCode; - transactionChanges['taxAmount'] = categoryTaxAmount; + transactionChanges.taxCode = categoryTaxCode; + transactionChanges.taxAmount = categoryTaxAmount; } const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories); From 8e2d09b1dabbd8e4614fc140a742b4a8b84931e6 Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 2 Dec 2024 12:29:30 +0700 Subject: [PATCH 025/323] 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: Tue, 3 Dec 2024 14:13:48 +0700 Subject: [PATCH 026/323] prevent submit button from jumping when proceeding to the confirmation page --- .../step/IOURequestStepParticipants.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index fb2484ea414f..4552e94b3b5d 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -18,6 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Participant} from '@src/types/onyx/IOU'; +import KeyboardUtils from '@src/utils/keyboard'; import StepScreenWrapper from './StepScreenWrapper'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -131,11 +132,14 @@ function IOURequestStepParticipants({ transactionID, selectedReportID.current || reportID, ); - if (isCategorizing) { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, selectedReportID.current || reportID, iouConfirmationPageRoute)); - } else { - Navigation.navigate(iouConfirmationPageRoute); - } + + const route = isCategorizing + ? ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, selectedReportID.current || reportID, iouConfirmationPageRoute) + : iouConfirmationPageRoute; + + KeyboardUtils.dismiss().then(() => { + Navigation.navigate(route); + }); }, [iouType, transactionID, transaction, reportID, action, participants]); const navigateBack = useCallback(() => { @@ -153,7 +157,9 @@ function IOURequestStepParticipants({ IOU.setCustomUnitRateID(transactionID, rateID); IOU.setMoneyRequestParticipantsFromReport(transactionID, ReportUtils.getReport(selfDMReportID)); const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID); - Navigation.navigate(iouConfirmationPageRoute); + KeyboardUtils.dismiss().then(() => { + Navigation.navigate(iouConfirmationPageRoute); + }); }; useEffect(() => { From 8733cd4b4dc48e25cb33bffccb97409083a72143 Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 4 Dec 2024 11:32:24 +0700 Subject: [PATCH 027/323] fix: when selecting categories, the selected categories get reset --- src/hooks/useCleanupSelectedOptions/index.ts | 24 +++++++++++++++++++ .../categories/WorkspaceCategoriesPage.tsx | 13 +++++----- .../workspace/tags/WorkspaceTagsPage.tsx | 13 +++++----- .../workspace/taxes/WorkspaceTaxesPage.tsx | 15 ++++++------ 4 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 src/hooks/useCleanupSelectedOptions/index.ts diff --git a/src/hooks/useCleanupSelectedOptions/index.ts b/src/hooks/useCleanupSelectedOptions/index.ts new file mode 100644 index 000000000000..a920e2ea07cf --- /dev/null +++ b/src/hooks/useCleanupSelectedOptions/index.ts @@ -0,0 +1,24 @@ +import {NavigationContainerRefContext, useIsFocused} from '@react-navigation/native'; +import {useContext, useEffect} from 'react'; +import NAVIGATORS from '@src/NAVIGATORS'; + +let shouldCleanupSelectedOptions = false; + +const useCleanupSelectedOptions = (cleanupFunction?: () => void) => { + const navigationContainerRef = useContext(NavigationContainerRefContext); + const state = navigationContainerRef?.getState(); + const lastRoute = state?.routes.at(-1); + const isRightModalOpening = lastRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; + + const isFocused = useIsFocused(); + + useEffect(() => { + if (isFocused || isRightModalOpening) { + return; + } + shouldCleanupSelectedOptions = false; + cleanupFunction?.(); + }, [isFocused, cleanupFunction, isRightModalOpening]); +}; + +export {useCleanupSelectedOptions}; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 56ba8a5440b8..c73e9753ed66 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -23,6 +23,7 @@ import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useAutoTurnSelectionModeOffWhenHasNoActiveOption from '@hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption'; +import {useCleanupSelectedOptions} from '@hooks/useCleanupSelectedOptions'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -98,12 +99,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, [fetchCategories]), ); - useEffect(() => { - if (isFocused) { - return; - } - setSelectedCategories({}); - }, [isFocused]); + const cleanupSelectedOption = useCallback(() => setSelectedCategories({}), []); + useCleanupSelectedOptions(cleanupSelectedOption); const categoryList = useMemo( () => @@ -151,6 +148,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }; const navigateToCategorySettings = (category: PolicyOption) => { + if (isSmallScreenWidth && selectionMode?.isEnabled) { + toggleCategory(category); + return; + } Navigation.navigate( isQuickSettingsFlow ? ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(policyId, category.keyForList, backTo) diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index d5c72048f8a4..9962113f44c4 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -22,6 +22,7 @@ import CustomListHeader from '@components/SelectionListWithModal/CustomListHeade import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import {useCleanupSelectedOptions} from '@hooks/useCleanupSelectedOptions'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -87,12 +88,8 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { useFocusEffect(fetchTags); - useEffect(() => { - if (isFocused) { - return; - } - setSelectedTags({}); - }, [isFocused]); + const cleanupSelectedOption = useCallback(() => setSelectedTags({}), []); + useCleanupSelectedOptions(cleanupSelectedOption); const getPendingAction = (policyTagList: PolicyTagList): PendingAction | undefined => { if (!policyTagList) { @@ -176,6 +173,10 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { }; const navigateToTagSettings = (tag: TagListItem) => { + if (isSmallScreenWidth && selectionMode?.isEnabled) { + toggleTag(tag); + return; + } if (tag.orderWeight !== undefined) { Navigation.navigate( isQuickSettingsFlow ? ROUTES.SETTINGS_TAG_LIST_VIEW.getRoute(policyID, tag.orderWeight, backTo) : ROUTES.WORKSPACE_TAG_LIST_VIEW.getRoute(policyID, tag.orderWeight), diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 9dbe739ae1db..9694d43df144 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -17,6 +17,7 @@ import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import {useCleanupSelectedOptions} from '@hooks/useCleanupSelectedOptions'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -51,7 +52,7 @@ function WorkspaceTaxesPage({ params: {policyID}, }, }: WorkspaceTaxesPageProps) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -86,12 +87,8 @@ function WorkspaceTaxesPage({ }, [fetchTaxes]), ); - useEffect(() => { - if (isFocused) { - return; - } - setSelectedTaxesIDs([]); - }, [isFocused]); + const cleanupSelectedOption = useCallback(() => setSelectedTaxesIDs([]), []); + useCleanupSelectedOptions(cleanupSelectedOption); const textForDefault = useCallback( (taxID: string, taxRate: TaxRate): string => { @@ -192,6 +189,10 @@ function WorkspaceTaxesPage({ if (!taxRate.keyForList) { return; } + if (isSmallScreenWidth && selectionMode?.isEnabled) { + toggleTax(taxRate); + return; + } Navigation.navigate(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxRate.keyForList)); }; From cbd2efe9acd650e9ffa7c9ba52003ddd4e434d9c Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 4 Dec 2024 14:00:24 +0700 Subject: [PATCH 028/323] fix: lint --- src/hooks/useCleanupSelectedOptions/index.ts | 2 +- .../workspace/categories/WorkspaceCategoriesPage.tsx | 5 ++--- src/pages/workspace/tags/WorkspaceTagsPage.tsx | 7 +++---- src/pages/workspace/taxes/WorkspaceTaxesPage.tsx | 8 ++++---- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/hooks/useCleanupSelectedOptions/index.ts b/src/hooks/useCleanupSelectedOptions/index.ts index a920e2ea07cf..afcc0346afb9 100644 --- a/src/hooks/useCleanupSelectedOptions/index.ts +++ b/src/hooks/useCleanupSelectedOptions/index.ts @@ -21,4 +21,4 @@ const useCleanupSelectedOptions = (cleanupFunction?: () => void) => { }, [isFocused, cleanupFunction, isRightModalOpening]); }; -export {useCleanupSelectedOptions}; +export default useCleanupSelectedOptions; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index c73e9753ed66..3a588176ef4d 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -1,4 +1,4 @@ -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import {useFocusEffect} from '@react-navigation/native'; import lodashSortBy from 'lodash/sortBy'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; @@ -23,7 +23,7 @@ import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useAutoTurnSelectionModeOffWhenHasNoActiveOption from '@hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption'; -import {useCleanupSelectedOptions} from '@hooks/useCleanupSelectedOptions'; +import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -71,7 +71,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const [selectedCategories, setSelectedCategories] = useState>({}); const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); const [deleteCategoriesConfirmModalVisible, setDeleteCategoriesConfirmModalVisible] = useState(false); - const isFocused = useIsFocused(); const {environmentURL} = useEnvironment(); const policyId = route.params.policyID ?? '-1'; const backTo = route.params?.backTo; diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 9962113f44c4..756f402b9182 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -1,6 +1,6 @@ -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import {useFocusEffect} from '@react-navigation/native'; import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -22,7 +22,7 @@ import CustomListHeader from '@components/SelectionListWithModal/CustomListHeade import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import {useCleanupSelectedOptions} from '@hooks/useCleanupSelectedOptions'; +import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -65,7 +65,6 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); const [isDeleteTagsConfirmModalVisible, setIsDeleteTagsConfirmModalVisible] = useState(false); const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); - const isFocused = useIsFocused(); const policyID = route.params.policyID ?? '-1'; const backTo = route.params.backTo; const policy = usePolicy(policyID); diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 9694d43df144..94831f8dc8f5 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -1,5 +1,5 @@ -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -17,7 +17,7 @@ import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import {useCleanupSelectedOptions} from '@hooks/useCleanupSelectedOptions'; +import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -52,6 +52,7 @@ function WorkspaceTaxesPage({ params: {policyID}, }, }: WorkspaceTaxesPageProps) { + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); const theme = useTheme(); @@ -62,7 +63,6 @@ function WorkspaceTaxesPage({ const {selectionMode} = useMobileSelectionMode(); const defaultExternalID = policy?.taxRates?.defaultExternalID; const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; - const isFocused = useIsFocused(); const hasAccountingConnections = PolicyUtils.hasAccountingConnections(policy); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); From 195aaeea0c3cd8f68c86d31d949ae3cb74a3312e Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 4 Dec 2024 14:09:53 +0700 Subject: [PATCH 029/323] fix: lint --- src/hooks/useCleanupSelectedOptions/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/hooks/useCleanupSelectedOptions/index.ts b/src/hooks/useCleanupSelectedOptions/index.ts index afcc0346afb9..7451e85aef23 100644 --- a/src/hooks/useCleanupSelectedOptions/index.ts +++ b/src/hooks/useCleanupSelectedOptions/index.ts @@ -2,8 +2,6 @@ import {NavigationContainerRefContext, useIsFocused} from '@react-navigation/nat import {useContext, useEffect} from 'react'; import NAVIGATORS from '@src/NAVIGATORS'; -let shouldCleanupSelectedOptions = false; - const useCleanupSelectedOptions = (cleanupFunction?: () => void) => { const navigationContainerRef = useContext(NavigationContainerRefContext); const state = navigationContainerRef?.getState(); @@ -16,7 +14,6 @@ const useCleanupSelectedOptions = (cleanupFunction?: () => void) => { if (isFocused || isRightModalOpening) { return; } - shouldCleanupSelectedOptions = false; cleanupFunction?.(); }, [isFocused, cleanupFunction, isRightModalOpening]); }; From c0d30f792930f1757d933f7370a618876b0f1335 Mon Sep 17 00:00:00 2001 From: Huu Le <20178761+huult@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:04:24 +0700 Subject: [PATCH 030/323] Update dismiss keyboard for web --- src/utils/keyboard.ts | 83 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/src/utils/keyboard.ts b/src/utils/keyboard.ts index a2b1d329aa0a..6cea7dc727cf 100644 --- a/src/utils/keyboard.ts +++ b/src/utils/keyboard.ts @@ -1,25 +1,82 @@ -import {Keyboard} from 'react-native'; +import {InteractionManager, Keyboard} from 'react-native'; +import getPlatform from '@libs/getPlatform'; +import CONST from '@src/CONST'; -let isVisible = false; +let isNativeKeyboardVisible = false; // Native keyboard visibility +let isWebKeyboardOpen = false; // Web keyboard visibility +const isWeb = getPlatform() === CONST.PLATFORM.WEB; +/** + * Initializes native keyboard visibility listeners + */ +const initializeNativeKeyboardListeners = () => { + Keyboard.addListener('keyboardDidHide', () => { + isNativeKeyboardVisible = false; + }); + + Keyboard.addListener('keyboardDidShow', () => { + isNativeKeyboardVisible = true; + }); +}; + +/** + * Checks if the given HTML element is a keyboard-related input + */ +const isKeyboardInput = (elem: HTMLElement): boolean => + (elem.tagName === 'INPUT' && !['button', 'submit', 'checkbox', 'file', 'image'].includes((elem as HTMLInputElement).type)) || elem.hasAttribute('contenteditable'); -Keyboard.addListener('keyboardDidHide', () => { - isVisible = false; -}); +/** + * Initializes web-specific keyboard visibility listeners + */ +const initializeWebKeyboardListeners = () => { + if (typeof document === 'undefined' || !isWeb) { + return; + } + + const handleFocusIn = (e: FocusEvent) => { + const target = e.target as HTMLElement; + if (target && isKeyboardInput(target)) { + isWebKeyboardOpen = true; + } + }; + + const handleFocusOut = (e: FocusEvent) => { + const target = e.target as HTMLElement; + if (target && isKeyboardInput(target)) { + isWebKeyboardOpen = false; + } + }; -Keyboard.addListener('keyboardDidShow', () => { - isVisible = true; -}); + document.addEventListener('focusin', handleFocusIn); + document.addEventListener('focusout', handleFocusOut); +}; +/** + * Dismisses the keyboard and resolves the promise when the dismissal is complete + */ const dismiss = (): Promise => { return new Promise((resolve) => { - if (!isVisible) { - resolve(); + if (isWeb) { + if (!isWebKeyboardOpen) { + resolve(); + return; + } + + Keyboard.dismiss(); + InteractionManager.runAfterInteractions(() => { + isWebKeyboardOpen = false; + resolve(); + }); return; } + if (!isNativeKeyboardVisible) { + resolve(); + return; + } + const subscription = Keyboard.addListener('keyboardDidHide', () => { - resolve(undefined); + resolve(); subscription.remove(); }); @@ -27,6 +84,10 @@ const dismiss = (): Promise => { }); }; +// Initialize listeners for native and web +initializeNativeKeyboardListeners(); +initializeWebKeyboardListeners(); + const utils = {dismiss}; export default utils; From 3a8d4302acef3fb9b2f7f187bdde7c2d36d7ba63 Mon Sep 17 00:00:00 2001 From: daledah Date: Wed, 4 Dec 2024 17:41:42 +0700 Subject: [PATCH 031/323] 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 09:08:02 +0700 Subject: [PATCH 032/323] Add keyboard dismiss for web --- src/utils/keyboard.ts | 93 ----------------------------- src/utils/keyboard/index.ts | 32 ++++++++++ src/utils/keyboard/index.website.ts | 46 ++++++++++++++ 3 files changed, 78 insertions(+), 93 deletions(-) delete mode 100644 src/utils/keyboard.ts create mode 100644 src/utils/keyboard/index.ts create mode 100644 src/utils/keyboard/index.website.ts diff --git a/src/utils/keyboard.ts b/src/utils/keyboard.ts deleted file mode 100644 index 6cea7dc727cf..000000000000 --- a/src/utils/keyboard.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {InteractionManager, Keyboard} from 'react-native'; -import getPlatform from '@libs/getPlatform'; -import CONST from '@src/CONST'; - -let isNativeKeyboardVisible = false; // Native keyboard visibility -let isWebKeyboardOpen = false; // Web keyboard visibility -const isWeb = getPlatform() === CONST.PLATFORM.WEB; -/** - * Initializes native keyboard visibility listeners - */ -const initializeNativeKeyboardListeners = () => { - Keyboard.addListener('keyboardDidHide', () => { - isNativeKeyboardVisible = false; - }); - - Keyboard.addListener('keyboardDidShow', () => { - isNativeKeyboardVisible = true; - }); -}; - -/** - * Checks if the given HTML element is a keyboard-related input - */ -const isKeyboardInput = (elem: HTMLElement): boolean => - (elem.tagName === 'INPUT' && !['button', 'submit', 'checkbox', 'file', 'image'].includes((elem as HTMLInputElement).type)) || elem.hasAttribute('contenteditable'); - -/** - * Initializes web-specific keyboard visibility listeners - */ -const initializeWebKeyboardListeners = () => { - if (typeof document === 'undefined' || !isWeb) { - return; - } - - const handleFocusIn = (e: FocusEvent) => { - const target = e.target as HTMLElement; - if (target && isKeyboardInput(target)) { - isWebKeyboardOpen = true; - } - }; - - const handleFocusOut = (e: FocusEvent) => { - const target = e.target as HTMLElement; - if (target && isKeyboardInput(target)) { - isWebKeyboardOpen = false; - } - }; - - document.addEventListener('focusin', handleFocusIn); - document.addEventListener('focusout', handleFocusOut); -}; - -/** - * Dismisses the keyboard and resolves the promise when the dismissal is complete - */ -const dismiss = (): Promise => { - return new Promise((resolve) => { - if (isWeb) { - if (!isWebKeyboardOpen) { - resolve(); - return; - } - - Keyboard.dismiss(); - InteractionManager.runAfterInteractions(() => { - isWebKeyboardOpen = false; - resolve(); - }); - - return; - } - - if (!isNativeKeyboardVisible) { - resolve(); - return; - } - - const subscription = Keyboard.addListener('keyboardDidHide', () => { - resolve(); - subscription.remove(); - }); - - Keyboard.dismiss(); - }); -}; - -// Initialize listeners for native and web -initializeNativeKeyboardListeners(); -initializeWebKeyboardListeners(); - -const utils = {dismiss}; - -export default utils; diff --git a/src/utils/keyboard/index.ts b/src/utils/keyboard/index.ts new file mode 100644 index 000000000000..a2b1d329aa0a --- /dev/null +++ b/src/utils/keyboard/index.ts @@ -0,0 +1,32 @@ +import {Keyboard} from 'react-native'; + +let isVisible = false; + +Keyboard.addListener('keyboardDidHide', () => { + isVisible = false; +}); + +Keyboard.addListener('keyboardDidShow', () => { + isVisible = true; +}); + +const dismiss = (): Promise => { + return new Promise((resolve) => { + if (!isVisible) { + resolve(); + + return; + } + + const subscription = Keyboard.addListener('keyboardDidHide', () => { + resolve(undefined); + subscription.remove(); + }); + + Keyboard.dismiss(); + }); +}; + +const utils = {dismiss}; + +export default utils; diff --git a/src/utils/keyboard/index.website.ts b/src/utils/keyboard/index.website.ts new file mode 100644 index 000000000000..de7e89b9d660 --- /dev/null +++ b/src/utils/keyboard/index.website.ts @@ -0,0 +1,46 @@ +import {InteractionManager, Keyboard} from 'react-native'; + +let isVisible = false; + +const isKeyboardInput = (elem: HTMLElement): boolean => { + const inputTypesToIgnore = ['button', 'submit', 'checkbox', 'file', 'image']; + return (elem.tagName === 'INPUT' && !inputTypesToIgnore.includes((elem as HTMLInputElement).type)) || elem.tagName === 'TEXTAREA' || elem.hasAttribute('contenteditable'); +}; + +const handleFocusIn = (event: FocusEvent): void => { + const target = event.target as HTMLElement; + if (target && isKeyboardInput(target)) { + isVisible = true; + } +}; + +const handleFocusOut = (event: FocusEvent): void => { + const target = event.target as HTMLElement; + if (target && isKeyboardInput(target)) { + isVisible = false; + } +}; + +document.addEventListener('focusin', handleFocusIn); +document.addEventListener('focusout', handleFocusOut); + +const dismiss = (): Promise => { + return new Promise((resolve) => { + if (!isVisible) { + resolve(); + return; + } + + Keyboard.dismiss(); + InteractionManager.runAfterInteractions(() => { + isVisible = false; + resolve(); + }); + }); +}; + +const utils = { + dismiss, +}; + +export default utils; From e671307a43f87b42332562fd0092288422f50f4b Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Thu, 5 Dec 2024 14:26:23 +0530 Subject: [PATCH 033/323] Implemented Edit/Delete Per diem rates --- src/ONYXKEYS.ts | 3 + src/ROUTES.ts | 20 ++ src/SCREENS.ts | 5 + src/components/AmountWithoutCurrencyForm.tsx | 13 +- src/languages/en.ts | 4 + src/languages/es.ts | 4 + src/languages/params.ts | 5 + .../UpdateWorkspaceCustomUnitParams.ts | 6 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/MoneyRequestUtils.ts | 12 +- .../ModalStackNavigators/index.tsx | 5 + .../FULL_SCREEN_TO_RHP_MAPPING.ts | 11 +- src/libs/Navigation/linkingConfig/config.ts | 15 ++ src/libs/Navigation/types.ts | 25 ++ src/libs/actions/Policy/PerDiem.ts | 215 +++++++++++++++++- .../perDiem/EditPerDiemAmountPage.tsx | 116 ++++++++++ .../perDiem/EditPerDiemCurrencyPage.tsx | 80 +++++++ .../perDiem/EditPerDiemDestinationPage.tsx | 115 ++++++++++ .../perDiem/EditPerDiemSubratePage.tsx | 109 +++++++++ .../perDiem/WorkspacePerDiemDetailsPage.tsx | 128 +++++++++++ .../perDiem/WorkspacePerDiemPage.tsx | 11 +- src/types/form/WorkspacePerDiemForm.ts | 22 ++ src/types/form/index.ts | 1 + 24 files changed, 906 insertions(+), 22 deletions(-) create mode 100644 src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts create mode 100644 src/pages/workspace/perDiem/EditPerDiemAmountPage.tsx create mode 100644 src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx create mode 100644 src/pages/workspace/perDiem/EditPerDiemDestinationPage.tsx create mode 100644 src/pages/workspace/perDiem/EditPerDiemSubratePage.tsx create mode 100644 src/pages/workspace/perDiem/WorkspacePerDiemDetailsPage.tsx create mode 100644 src/types/form/WorkspacePerDiemForm.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 3c3812774380..9251f6c368b4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -721,6 +721,8 @@ const ONYXKEYS = { RULES_MAX_EXPENSE_AGE_FORM_DRAFT: 'rulesMaxExpenseAgeFormDraft', DEBUG_DETAILS_FORM: 'debugDetailsForm', DEBUG_DETAILS_FORM_DRAFT: 'debugDetailsFormDraft', + WORKSPACE_PER_DIEM_FORM: 'workspacePerDiemForm', + WORKSPACE_PER_DIEM_FORM_DRAFT: 'workspacePerDiemFormDraft', }, } as const; @@ -814,6 +816,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; [ONYXKEYS.FORMS.SEARCH_SAVED_SEARCH_RENAME_FORM]: FormTypes.SearchSavedSearchRenameForm; [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm | FormTypes.DebugTransactionForm | FormTypes.DebugTransactionViolationForm; + [ONYXKEYS.FORMS.WORKSPACE_PER_DIEM_FORM]: FormTypes.WorkspacePerDiemForm; }; type OnyxFormDraftValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a6eb3c1166df..bddfc9756c08 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1316,6 +1316,26 @@ const ROUTES = { route: 'settings/workspaces/:policyID/per-diem/settings', getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem/settings` as const, }, + WORKSPACE_PER_DIEM_DETAILS: { + route: 'settings/workspaces/:policyID/per-diem/details/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/details/${rateID}/${subRateID}` as const, + }, + WORKSPACE_PER_DIEM_EDIT_DESTINATION: { + route: 'settings/workspaces/:policyID/per-diem/edit/destination/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/edit/destination/${rateID}/${subRateID}` as const, + }, + WORKSPACE_PER_DIEM_EDIT_SUBRATE: { + route: 'settings/workspaces/:policyID/per-diem/edit/subrate/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/edit/subrate/${rateID}/${subRateID}` as const, + }, + WORKSPACE_PER_DIEM_EDIT_AMOUNT: { + route: 'settings/workspaces/:policyID/per-diem/edit/amount/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/edit/amount/${rateID}/${subRateID}` as const, + }, + WORKSPACE_PER_DIEM_EDIT_CURRENCY: { + route: 'settings/workspaces/:policyID/per-diem/edit/currency/:rateID/:subRateID', + getRoute: (policyID: string, rateID: string, subRateID: string) => `settings/workspaces/${policyID}/per-diem/edit/currency/${rateID}/${subRateID}` as const, + }, RULES_CUSTOM_NAME: { route: 'settings/workspaces/:policyID/rules/name', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index e4fa03bf4815..1e0e4d1e5fde 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -550,6 +550,11 @@ const SCREENS = { PER_DIEM_IMPORT: 'Per_Diem_Import', PER_DIEM_IMPORTED: 'Per_Diem_Imported', PER_DIEM_SETTINGS: 'Per_Diem_Settings', + PER_DIEM_DETAILS: 'Per_Diem_Details', + PER_DIEM_EDIT_DESTINATION: 'Per_Diem_Edit_Destination', + PER_DIEM_EDIT_SUBRATE: 'Per_Diem_Edit_Subrate', + PER_DIEM_EDIT_AMOUNT: 'Per_Diem_Edit_Amount', + PER_DIEM_EDIT_CURRENCY: 'Per_Diem_Edit_Currency', }, EDIT_REQUEST: { diff --git a/src/components/AmountWithoutCurrencyForm.tsx b/src/components/AmountWithoutCurrencyForm.tsx index 78b7c84ecb54..f94a2eb84f20 100644 --- a/src/components/AmountWithoutCurrencyForm.tsx +++ b/src/components/AmountWithoutCurrencyForm.tsx @@ -12,10 +12,13 @@ type AmountFormProps = { /** Callback to update the amount in the FormProvider */ onInputChange?: (value: string) => void; + + /** Should we allow negative number as valid input */ + shouldAllowNegative?: boolean; } & Partial; function AmountWithoutCurrencyForm( - {value: amount, onInputChange, inputID, name, defaultValue, accessibilityLabel, role, label, ...rest}: AmountFormProps, + {value: amount, onInputChange, shouldAllowNegative = false, inputID, name, defaultValue, accessibilityLabel, role, label, ...rest}: AmountFormProps, ref: ForwardedRef, ) { const {toLocaleDigit} = useLocalize(); @@ -32,13 +35,13 @@ function AmountWithoutCurrencyForm( // More info: https://github.com/Expensify/App/issues/16974 const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); const replacedCommasAmount = replaceCommasWithPeriod(newAmountWithoutSpaces); - const withLeadingZero = addLeadingZero(replacedCommasAmount); - if (!validateAmount(withLeadingZero, 2)) { + const withLeadingZero = addLeadingZero(replacedCommasAmount, shouldAllowNegative); + if (!validateAmount(withLeadingZero, 2, CONST.IOU.AMOUNT_MAX_LENGTH, shouldAllowNegative)) { return; } onInputChange?.(withLeadingZero); }, - [onInputChange], + [onInputChange, shouldAllowNegative], ); const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit); @@ -54,7 +57,7 @@ function AmountWithoutCurrencyForm( accessibilityLabel={accessibilityLabel} role={role} ref={ref} - keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} + keyboardType={!shouldAllowNegative ? CONST.KEYBOARD_TYPE.DECIMAL_PAD : undefined} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/languages/en.ts b/src/languages/en.ts index d79695ed8b48..eca91dbd5f95 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -61,6 +61,7 @@ import type { DeleteConfirmationParams, DidSplitAmountMessageParams, EditActionParams, + EditDestinationSubtitleParams, ElectronicFundsParams, EnterMagicCodeParams, ExportAgainModalDescriptionParams, @@ -2569,6 +2570,9 @@ const translations = { existingRateError: ({rate}: CustomUnitRateParams) => `A rate with value ${rate} already exists.`, }, importPerDiemRates: 'Import per diem rates', + editPerDiemRate: 'Edit per diem rate', + editDestinationSubtitle: ({destination}: EditDestinationSubtitleParams) => `Updating this destination will change it for all ${destination} per diem subrates.`, + editCurrencySubtitle: ({destination}: EditDestinationSubtitleParams) => `Updating this currency will change it for all ${destination} per diem subrates.`, }, qbd: { exportOutOfPocketExpensesDescription: 'Set how out-of-pocket expenses export to QuickBooks Desktop.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5ce47db18d35..574aed309385 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -60,6 +60,7 @@ import type { DeleteConfirmationParams, DidSplitAmountMessageParams, EditActionParams, + EditDestinationSubtitleParams, ElectronicFundsParams, EnterMagicCodeParams, ExportAgainModalDescriptionParams, @@ -2593,6 +2594,9 @@ const translations = { existingRateError: ({rate}: CustomUnitRateParams) => `Ya existe una tasa con el valor ${rate}.`, }, importPerDiemRates: 'Importar tasas de per diem', + editPerDiemRate: 'Edit per diem rate', + editDestinationSubtitle: ({destination}: EditDestinationSubtitleParams) => `Updating this destination will change it for all ${destination} per diem subrates.`, + editCurrencySubtitle: ({destination}: EditDestinationSubtitleParams) => `Updating this currency will change it for all ${destination} per diem subrates.`, }, qbd: { exportOutOfPocketExpensesDescription: 'Establezca cómo se exportan los gastos de bolsillo a QuickBooks Desktop.', diff --git a/src/languages/params.ts b/src/languages/params.ts index 3088b99e753b..4ca58a17346b 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -571,6 +571,10 @@ type ChatWithAccountManagerParams = { accountManagerDisplayName: string; }; +type EditDestinationSubtitleParams = { + destination: string; +}; + export type { AuthenticationErrorParams, ImportMembersSuccessfullDescriptionParams, @@ -776,4 +780,5 @@ export type { CompanyNameParams, CustomUnitRateParams, ChatWithAccountManagerParams, + EditDestinationSubtitleParams, }; diff --git a/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts b/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts new file mode 100644 index 000000000000..8ff704c4a0dd --- /dev/null +++ b/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts @@ -0,0 +1,6 @@ +type UpdateWorkspaceCustomUnitParams = { + policyID: string; + customUnitData: string; +}; + +export default UpdateWorkspaceCustomUnitParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 6a510d074f98..5ae3af578542 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -352,3 +352,4 @@ export type {default as OpenPolicyPerDiemRatesPageParams} from './OpenPolicyPerD export type {default as TogglePlatformMuteParams} from './TogglePlatformMuteParams'; export type {default as ImportPerDiemRatesParams} from './ImportPerDiemRatesParams'; export type {default as ExportPerDiemCSVParams} from './ExportPerDiemCSVParams'; +export type {default as UpdateWorkspaceCustomUnitParams} from './UpdateWorkspaceCustomUnitParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 892bad17928e..3cb6dccbd87e 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -438,6 +438,7 @@ const WRITE_COMMANDS = { SELF_TOUR_VIEWED: 'SelfTourViewed', UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName', UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite', + UPDATE_WORKSPACE_CUSTOM_UNIT: 'UpdateWorkspaceCustomUnit', } as const; type WriteCommand = ValueOf; @@ -762,6 +763,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY]: Parameters.UpdateSubscriptionAddNewUsersAutomaticallyParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_SIZE]: Parameters.UpdateSubscriptionSizeParams; [WRITE_COMMANDS.REQUEST_TAX_EXEMPTION]: null; + [WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT]: Parameters.UpdateWorkspaceCustomUnitParams; [WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; [WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.HoldMoneyRequestOnSearchParams; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 206bb8509af6..d76c9325cc0e 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -32,19 +32,23 @@ function stripDecimalsFromAmount(amount: string): string { * Adds a leading zero to the amount if user entered just the decimal separator * * @param amount - Changed amount from user input + * @param shouldAllowNegative - Should allow negative numbers */ -function addLeadingZero(amount: string): string { +function addLeadingZero(amount: string, shouldAllowNegative = false): string { + if (shouldAllowNegative && amount.startsWith('-.')) { + return `-0${amount}`; + } return amount.startsWith('.') ? `0${amount}` : amount; } /** * Check if amount is a decimal up to 3 digits */ -function validateAmount(amount: string, decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH): boolean { +function validateAmount(amount: string, decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH, shouldAllowNegative = false): boolean { const regexString = decimals === 0 - ? `^\\d{1,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 - : `^\\d{1,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point + ? `^${shouldAllowNegative ? '-?' : ''}\\d{1,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 + : `^${shouldAllowNegative ? '-?' : ''}\\d{1,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point const decimalNumberRegex = new RegExp(regexString, 'i'); return amount === '' || decimalNumberRegex.test(amount); } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 9822c60faaa8..d85433fda14a 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -573,6 +573,11 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/perDiem/ImportPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORTED]: () => require('../../../../pages/workspace/perDiem/ImportedPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemSettingsPage').default, + [SCREENS.WORKSPACE.PER_DIEM_DETAILS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemDetailsPage').default, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION]: () => require('../../../../pages/workspace/perDiem/EditPerDiemDestinationPage').default, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE]: () => require('../../../../pages/workspace/perDiem/EditPerDiemSubratePage').default, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT]: () => require('../../../../pages/workspace/perDiem/EditPerDiemAmountPage').default, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY]: () => require('../../../../pages/workspace/perDiem/EditPerDiemCurrencyPage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index f36f154819f5..bb3cfc3353de 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -245,7 +245,16 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE, SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT, ], - [SCREENS.WORKSPACE.PER_DIEM]: [SCREENS.WORKSPACE.PER_DIEM_IMPORT, SCREENS.WORKSPACE.PER_DIEM_IMPORTED, SCREENS.WORKSPACE.PER_DIEM_SETTINGS], + [SCREENS.WORKSPACE.PER_DIEM]: [ + SCREENS.WORKSPACE.PER_DIEM_IMPORT, + SCREENS.WORKSPACE.PER_DIEM_IMPORTED, + SCREENS.WORKSPACE.PER_DIEM_SETTINGS, + SCREENS.WORKSPACE.PER_DIEM_DETAILS, + SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION, + SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE, + SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT, + SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY, + ], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 02f7f6950a0d..0b894c5810ce 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -953,6 +953,21 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { path: ROUTES.WORKSPACE_PER_DIEM_SETTINGS.route, }, + [SCREENS.WORKSPACE.PER_DIEM_DETAILS]: { + path: ROUTES.WORKSPACE_PER_DIEM_DETAILS.route, + }, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION]: { + path: ROUTES.WORKSPACE_PER_DIEM_EDIT_DESTINATION.route, + }, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE]: { + path: ROUTES.WORKSPACE_PER_DIEM_EDIT_SUBRATE.route, + }, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT]: { + path: ROUTES.WORKSPACE_PER_DIEM_EDIT_AMOUNT.route, + }, + [SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY]: { + path: ROUTES.WORKSPACE_PER_DIEM_EDIT_CURRENCY.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3674a24a907b..012526f801bd 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -908,6 +908,31 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { policyID: string; }; + [SCREENS.WORKSPACE.PER_DIEM_DETAILS]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_DESTINATION]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_SUBRATE]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT]: { + policyID: string; + rateID: string; + subRateID: string; + }; + [SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY]: { + policyID: string; + rateID: string; + subRateID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts index 81898dfb34e0..5f11eeff0b97 100644 --- a/src/libs/actions/Policy/PerDiem.ts +++ b/src/libs/actions/Policy/PerDiem.ts @@ -1,3 +1,4 @@ +import lodashDeepClone from 'lodash/cloneDeep'; import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; @@ -13,9 +14,10 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report} from '@src/types/onyx'; -import type {ErrorFields} from '@src/types/onyx/OnyxCommon'; -import type {Rate} from '@src/types/onyx/Policy'; +import type {ErrorFields, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; const allPolicies: OnyxCollection = {}; Onyx.connect({ @@ -50,6 +52,16 @@ Onyx.connect({ }, }); +type SubRateData = { + pendingAction?: PendingAction; + destination: string; + subRateName: string; + rate: number; + currency: string; + rateID: string; + subRateID: string; +}; + /** * Returns a client generated 13 character hexadecimal value for a custom unit ID */ @@ -193,4 +205,201 @@ function clearPolicyPerDiemRatesErrorFields(policyID: string, customUnitID: stri }); } -export {generateCustomUnitID, enablePerDiem, openPolicyPerDiemPage, importPerDiemRates, downloadPerDiemCSV, clearPolicyPerDiemRatesErrorFields}; +function prepareNewCustomUnit(customUnit: CustomUnit, subRatesToBeDeleted: SubRateData[]) { + const mappedDeletedSubRatesToRate = subRatesToBeDeleted.reduce((acc, subRate) => { + if (subRate.rateID in acc) { + acc[subRate.rateID].push(subRate); + } else { + acc[subRate.rateID] = [subRate]; + } + return acc; + }, {} as Record); + + // Copy the custom unit and remove the sub rates that are to be deleted + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + for (const rateID in mappedDeletedSubRatesToRate) { + if (!(rateID in newCustomUnit.rates)) { + // eslint-disable-next-line no-continue + continue; + } + const subRates = mappedDeletedSubRatesToRate[rateID]; + if (subRates.length === newCustomUnit.rates[rateID].subRates?.length) { + delete newCustomUnit.rates[rateID]; + } else { + const newSubRates = newCustomUnit.rates[rateID].subRates?.filter((subRate) => !subRates.some((subRateToBeDeleted) => subRateToBeDeleted.subRateID === subRate.id)); + newCustomUnit.rates[rateID].subRates = newSubRates; + } + } + return newCustomUnit; +} + +function deleteWorkspacePerDiemRates(policyID: string, customUnit: CustomUnit | undefined, subRatesToBeDeleted: SubRateData[]) { + if (!policyID || isEmptyObject(customUnit) || !subRatesToBeDeleted.length) { + return; + } + const newCustomUnit = prepareNewCustomUnit(customUnit, subRatesToBeDeleted); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: newCustomUnit, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnitData: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +function editPerDiemRateDestination(policyID: string, rateID: string, customUnit: CustomUnit | undefined, newDestination: string) { + if (!policyID || !rateID || isEmptyObject(customUnit) || !newDestination) { + return; + } + + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + newCustomUnit.rates[rateID].name = newDestination; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: newCustomUnit, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnitData: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +function editPerDiemRateSubrate(policyID: string, rateID: string, subRateID: string, customUnit: CustomUnit | undefined, newSubrate: string) { + if (!policyID || !rateID || isEmptyObject(customUnit) || !newSubrate) { + return; + } + + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + newCustomUnit.rates[rateID].subRates = newCustomUnit.rates[rateID].subRates?.map((subRate) => { + if (subRate.id === subRateID) { + return {...subRate, name: newSubrate}; + } + return subRate; + }); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: newCustomUnit, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnitData: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +function editPerDiemRateAmount(policyID: string, rateID: string, subRateID: string, customUnit: CustomUnit | undefined, newAmount: number) { + if (!policyID || !rateID || isEmptyObject(customUnit) || !newAmount) { + return; + } + + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + newCustomUnit.rates[rateID].subRates = newCustomUnit.rates[rateID].subRates?.map((subRate) => { + if (subRate.id === subRateID) { + return {...subRate, rate: newAmount}; + } + return subRate; + }); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: newCustomUnit, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnitData: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +function editPerDiemRateCurrency(policyID: string, rateID: string, customUnit: CustomUnit | undefined, newCurrency: string) { + if (!policyID || !rateID || isEmptyObject(customUnit) || !newCurrency) { + return; + } + + const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + newCustomUnit.rates[rateID].currency = newCurrency; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: newCustomUnit, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + customUnitData: JSON.stringify(newCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); +} + +export { + generateCustomUnitID, + enablePerDiem, + openPolicyPerDiemPage, + importPerDiemRates, + downloadPerDiemCSV, + clearPolicyPerDiemRatesErrorFields, + deleteWorkspacePerDiemRates, + editPerDiemRateDestination, + editPerDiemRateSubrate, + editPerDiemRateAmount, + editPerDiemRateCurrency, +}; diff --git a/src/pages/workspace/perDiem/EditPerDiemAmountPage.tsx b/src/pages/workspace/perDiem/EditPerDiemAmountPage.tsx new file mode 100644 index 000000000000..686620d58a20 --- /dev/null +++ b/src/pages/workspace/perDiem/EditPerDiemAmountPage.tsx @@ -0,0 +1,116 @@ +import React, {useCallback} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import AmountWithoutCurrencyForm from '@components/AmountWithoutCurrencyForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {convertToBackendAmount, convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PerDiem from '@userActions/Policy/PerDiem'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspacePerDiemForm'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type EditPerDiemAmountPageProps = PlatformStackScreenProps; + +function EditPerDiemAmountPage({route}: EditPerDiemAmountPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policyID = route.params.policyID ?? '-1'; + const rateID = route.params.rateID; + const subRateID = route.params.subRateID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + + const customUnit = getPerDiemCustomUnit(policy); + + const selectedRate = customUnit?.rates?.[rateID]; + const selectedSubrate = selectedRate?.subRates?.find((subRate) => subRate.id === subRateID); + + const defaultAmount = selectedSubrate?.rate ? convertToFrontendAmountAsString(Number(selectedSubrate.rate)) : undefined; + + const {inputCallbackRef} = useAutoFocusInput(); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + + const newAmount = values.amount.trim(); + const backendAmount = newAmount ? convertToBackendAmount(Number(newAmount)) : 0; + + if (backendAmount === 0) { + errors.amount = translate('common.error.fieldRequired'); + } + + return errors; + }, + [translate], + ); + + const editAmount = useCallback( + (values: FormOnyxValues) => { + const newAmount = values.amount.trim(); + const backendAmount = newAmount ? convertToBackendAmount(Number(newAmount)) : 0; + if (backendAmount !== Number(selectedSubrate?.rate)) { + PerDiem.editPerDiemRateAmount(policyID, rateID, subRateID, customUnit, backendAmount); + } + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); + }, + [selectedSubrate?.rate, policyID, rateID, subRateID, customUnit], + ); + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID))} + /> + + + + + + ); +} + +EditPerDiemAmountPage.displayName = 'EditPerDiemAmountPage'; + +export default EditPerDiemAmountPage; diff --git a/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx b/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx new file mode 100644 index 000000000000..b142372c2411 --- /dev/null +++ b/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx @@ -0,0 +1,80 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import CurrencySelectionList from '@components/CurrencySelectionList'; +import type {CurrencyListItem} from '@components/CurrencySelectionList/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PerDiem from '@userActions/Policy/PerDiem'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type EditPerDiemCurrencyPageProps = PlatformStackScreenProps; + +function EditPerDiemCurrencyPage({route}: EditPerDiemCurrencyPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policyID = route.params.policyID ?? '-1'; + const rateID = route.params.rateID; + const subRateID = route.params.subRateID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + + const customUnit = getPerDiemCustomUnit(policy); + + const selectedRate = customUnit?.rates?.[rateID]; + + const editCurrency = useCallback( + (item: CurrencyListItem) => { + const newCurrency = item.currencyCode; + if (newCurrency !== selectedRate?.currency) { + PerDiem.editPerDiemRateCurrency(policyID, rateID, customUnit, newCurrency); + } + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); + }, + [selectedRate?.currency, policyID, rateID, subRateID, customUnit], + ); + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID))} + /> + + {translate('workspace.perDiem.editCurrencySubtitle', {destination: selectedRate?.name ?? ''})} + + + + + ); +} + +EditPerDiemCurrencyPage.displayName = 'EditPerDiemCurrencyPage'; + +export default EditPerDiemCurrencyPage; diff --git a/src/pages/workspace/perDiem/EditPerDiemDestinationPage.tsx b/src/pages/workspace/perDiem/EditPerDiemDestinationPage.tsx new file mode 100644 index 000000000000..cca12b1f21d2 --- /dev/null +++ b/src/pages/workspace/perDiem/EditPerDiemDestinationPage.tsx @@ -0,0 +1,115 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PerDiem from '@userActions/Policy/PerDiem'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspacePerDiemForm'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type EditPerDiemDestinationPageProps = PlatformStackScreenProps; + +function EditPerDiemDestinationPage({route}: EditPerDiemDestinationPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policyID = route.params.policyID ?? '-1'; + const rateID = route.params.rateID; + const subRateID = route.params.subRateID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + + const customUnit = getPerDiemCustomUnit(policy); + + const selectedRate = customUnit?.rates?.[rateID]; + + const {inputCallbackRef} = useAutoFocusInput(); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + + if (!values.destination.trim()) { + errors.destination = translate('common.error.fieldRequired'); + } + + return errors; + }, + [translate], + ); + + const editDestination = useCallback( + (values: FormOnyxValues) => { + const newDestination = values.destination.trim(); + if (newDestination !== selectedRate?.name) { + PerDiem.editPerDiemRateDestination(policyID, rateID, customUnit, newDestination); + } + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); + }, + [selectedRate?.name, policyID, rateID, subRateID, customUnit], + ); + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID))} + /> + + + + {translate('workspace.perDiem.editDestinationSubtitle', {destination: selectedRate?.name ?? ''})} + + + + + + + ); +} + +EditPerDiemDestinationPage.displayName = 'EditPerDiemDestinationPage'; + +export default EditPerDiemDestinationPage; diff --git a/src/pages/workspace/perDiem/EditPerDiemSubratePage.tsx b/src/pages/workspace/perDiem/EditPerDiemSubratePage.tsx new file mode 100644 index 000000000000..84a437eab4ed --- /dev/null +++ b/src/pages/workspace/perDiem/EditPerDiemSubratePage.tsx @@ -0,0 +1,109 @@ +import React, {useCallback} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PerDiem from '@userActions/Policy/PerDiem'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspacePerDiemForm'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type EditPerDiemSubratePageProps = PlatformStackScreenProps; + +function EditPerDiemSubratePage({route}: EditPerDiemSubratePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policyID = route.params.policyID ?? '-1'; + const rateID = route.params.rateID; + const subRateID = route.params.subRateID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + + const customUnit = getPerDiemCustomUnit(policy); + + const selectedRate = customUnit?.rates?.[rateID]; + const selectedSubrate = selectedRate?.subRates?.find((subRate) => subRate.id === subRateID); + + const {inputCallbackRef} = useAutoFocusInput(); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + + if (!values.subrate.trim()) { + errors.subrate = translate('common.error.fieldRequired'); + } + + return errors; + }, + [translate], + ); + + const editSubrate = useCallback( + (values: FormOnyxValues) => { + const newSubrate = values.subrate.trim(); + if (newSubrate !== selectedSubrate?.name) { + PerDiem.editPerDiemRateSubrate(policyID, rateID, subRateID, customUnit, newSubrate); + } + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); + }, + [selectedSubrate?.name, policyID, rateID, subRateID, customUnit], + ); + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID))} + /> + + + + + + ); +} + +EditPerDiemSubratePage.displayName = 'EditPerDiemSubratePage'; + +export default EditPerDiemSubratePage; diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemDetailsPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemDetailsPage.tsx new file mode 100644 index 000000000000..d1dea9c99f77 --- /dev/null +++ b/src/pages/workspace/perDiem/WorkspacePerDiemDetailsPage.tsx @@ -0,0 +1,128 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {convertToFrontendAmountAsString, getCurrencySymbol} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as PerDiem from '@userActions/Policy/PerDiem'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type WorkspacePerDiemDetailsPageProps = PlatformStackScreenProps; + +function WorkspacePerDiemDetailsPage({route}: WorkspacePerDiemDetailsPageProps) { + const policyID = route.params.policyID; + const rateID = route.params.rateID; + const subRateID = route.params.subRateID; + const [deletePerDiemConfirmModalVisible, setDeletePerDiemConfirmModalVisible] = useState(false); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const customUnit = getPerDiemCustomUnit(policy); + + const selectedRate = customUnit?.rates?.[rateID]; + const selectedSubRate = selectedRate?.subRates?.find((subRate) => subRate.id === subRateID); + + const amountValue = selectedSubRate?.rate ? convertToFrontendAmountAsString(Number(selectedSubRate.rate)) : undefined; + const currencyValue = selectedRate?.currency ? `${selectedRate.currency} - ${getCurrencySymbol(selectedRate.currency)}` : undefined; + + const FullPageBlockingView = isEmptyObject(selectedSubRate) ? FullPageOfflineBlockingView : View; + + const handleDeletePerDiemRate = () => { + PerDiem.deleteWorkspacePerDiemRates(policyID, customUnit, [ + { + destination: selectedRate?.name ?? '', + subRateName: selectedSubRate?.name ?? '', + rate: selectedSubRate?.rate ?? 0, + currency: selectedRate?.currency ?? '', + rateID, + subRateID, + }, + ]); + setDeletePerDiemConfirmModalVisible(false); + Navigation.goBack(); + }; + + return ( + + + + setDeletePerDiemConfirmModalVisible(false)} + title={translate('workspace.perDiem.deletePerDiemRate')} + prompt={translate('workspace.perDiem.areYouSureDelete', {count: 1})} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + + + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_DESTINATION.getRoute(policyID, rateID, subRateID))} + shouldShowRightIcon + /> + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_SUBRATE.getRoute(policyID, rateID, subRateID))} + shouldShowRightIcon + /> + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_AMOUNT.getRoute(policyID, rateID, subRateID))} + shouldShowRightIcon + /> + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_EDIT_CURRENCY.getRoute(policyID, rateID, subRateID))} + shouldShowRightIcon + /> + setDeletePerDiemConfirmModalVisible(true)} + /> + + + + + ); +} + +WorkspacePerDiemDetailsPage.displayName = 'WorkspacePerDiemDetailsPage'; + +export default WorkspacePerDiemDetailsPage; diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx index 33ef0109a7a7..61e763c9eda7 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -223,18 +223,12 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_SETTINGS.getRoute(policyID)); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const openSubRateDetails = (rate: PolicyOption) => { - // TODO: Uncomment this when the import feature is ready - // Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_RATE_DETAILS.getRoute(policyID, rate.rateID, rate.subRateID)); - }; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const dismissError = (item: PolicyOption) => { - // TODO: Implement this when the editing feature is ready + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rate.rateID, rate.subRateID)); }; const handleDeletePerDiemRates = () => { + PerDiem.deleteWorkspacePerDiemRates(policyID, customUnit, selectedPerDiem); setSelectedPerDiem([]); setDeletePerDiemConfirmModalVisible(false); }; @@ -423,7 +417,6 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} onSelectAll={toggleAllSubRates} ListItem={TableListItem} - onDismissError={dismissError} customListHeader={getCustomListHeader()} listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} listHeaderContent={shouldUseNarrowLayout ? getHeaderText() : null} diff --git a/src/types/form/WorkspacePerDiemForm.ts b/src/types/form/WorkspacePerDiemForm.ts new file mode 100644 index 000000000000..86dc58cb1d5c --- /dev/null +++ b/src/types/form/WorkspacePerDiemForm.ts @@ -0,0 +1,22 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + DESTINATION: 'destination', + SUBRATE: 'subrate', + AMOUNT: 'amount', +} as const; + +type InputID = ValueOf; + +type WorkspacePerDiemForm = Form< + InputID, + { + [INPUT_IDS.DESTINATION]: string; + [INPUT_IDS.SUBRATE]: string; + [INPUT_IDS.AMOUNT]: string; + } +>; + +export type {WorkspacePerDiemForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index e8e37bebef9a..3c9d90ba3c2d 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -87,3 +87,4 @@ export type {WorkspaceCompanyCardFeedName} from './WorkspaceCompanyCardFeedName' export type {SearchSavedSearchRenameForm} from './SearchSavedSearchRenameForm'; export type {WorkspaceCompanyCardEditName} from './WorkspaceCompanyCardEditName'; export type {PersonalDetailsForm} from './PersonalDetailsForm'; +export type {WorkspacePerDiemForm} from './WorkspacePerDiemForm'; From d3d1e8d05c55c2ff75db5e54a35ee170b59c7120 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Thu, 5 Dec 2024 14:44:32 +0530 Subject: [PATCH 034/323] Fix style --- src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx b/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx index b142372c2411..1c75d2704c3b 100644 --- a/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx +++ b/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx @@ -62,7 +62,7 @@ function EditPerDiemCurrencyPage({route}: EditPerDiemCurrencyPageProps) { title={translate('common.currency')} onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID))} /> - + {translate('workspace.perDiem.editCurrencySubtitle', {destination: selectedRate?.name ?? ''})} Date: Thu, 5 Dec 2024 15:04:17 +0530 Subject: [PATCH 035/323] Fix onyx update when doing delete operation --- src/libs/actions/Policy/PerDiem.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts index 5f11eeff0b97..6d7cfcaf642f 100644 --- a/src/libs/actions/Policy/PerDiem.ts +++ b/src/libs/actions/Policy/PerDiem.ts @@ -205,6 +205,10 @@ function clearPolicyPerDiemRatesErrorFields(policyID: string, customUnitID: stri }); } +type DeletePerDiemCustomUnitOnyxType = Omit & { + rates: Record; +}; + function prepareNewCustomUnit(customUnit: CustomUnit, subRatesToBeDeleted: SubRateData[]) { const mappedDeletedSubRatesToRate = subRatesToBeDeleted.reduce((acc, subRate) => { if (subRate.rateID in acc) { @@ -217,6 +221,7 @@ function prepareNewCustomUnit(customUnit: CustomUnit, subRatesToBeDeleted: SubRa // Copy the custom unit and remove the sub rates that are to be deleted const newCustomUnit: CustomUnit = lodashDeepClone(customUnit); + const customUnitOnyxUpdate: DeletePerDiemCustomUnitOnyxType = lodashDeepClone(customUnit); for (const rateID in mappedDeletedSubRatesToRate) { if (!(rateID in newCustomUnit.rates)) { // eslint-disable-next-line no-continue @@ -225,19 +230,23 @@ function prepareNewCustomUnit(customUnit: CustomUnit, subRatesToBeDeleted: SubRa const subRates = mappedDeletedSubRatesToRate[rateID]; if (subRates.length === newCustomUnit.rates[rateID].subRates?.length) { delete newCustomUnit.rates[rateID]; + customUnitOnyxUpdate.rates[rateID] = null; } else { const newSubRates = newCustomUnit.rates[rateID].subRates?.filter((subRate) => !subRates.some((subRateToBeDeleted) => subRateToBeDeleted.subRateID === subRate.id)); newCustomUnit.rates[rateID].subRates = newSubRates; + if (!isEmptyObject(customUnitOnyxUpdate.rates[rateID])) { + customUnitOnyxUpdate.rates[rateID].subRates = newSubRates; + } } } - return newCustomUnit; + return {newCustomUnit, customUnitOnyxUpdate}; } function deleteWorkspacePerDiemRates(policyID: string, customUnit: CustomUnit | undefined, subRatesToBeDeleted: SubRateData[]) { if (!policyID || isEmptyObject(customUnit) || !subRatesToBeDeleted.length) { return; } - const newCustomUnit = prepareNewCustomUnit(customUnit, subRatesToBeDeleted); + const {newCustomUnit, customUnitOnyxUpdate} = prepareNewCustomUnit(customUnit, subRatesToBeDeleted); const onyxData: OnyxData = { optimisticData: [ { @@ -245,7 +254,7 @@ function deleteWorkspacePerDiemRates(policyID: string, customUnit: CustomUnit | key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { - [customUnit.customUnitID]: newCustomUnit, + [customUnit.customUnitID]: customUnitOnyxUpdate, }, }, }, From 7203e2e352b8c519f7bd67bd2adc295730c7ae47 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Thu, 5 Dec 2024 15:12:44 +0530 Subject: [PATCH 036/323] Fix ts lint --- src/libs/actions/Policy/PerDiem.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts index 6d7cfcaf642f..71823e80f512 100644 --- a/src/libs/actions/Policy/PerDiem.ts +++ b/src/libs/actions/Policy/PerDiem.ts @@ -234,9 +234,7 @@ function prepareNewCustomUnit(customUnit: CustomUnit, subRatesToBeDeleted: SubRa } else { const newSubRates = newCustomUnit.rates[rateID].subRates?.filter((subRate) => !subRates.some((subRateToBeDeleted) => subRateToBeDeleted.subRateID === subRate.id)); newCustomUnit.rates[rateID].subRates = newSubRates; - if (!isEmptyObject(customUnitOnyxUpdate.rates[rateID])) { - customUnitOnyxUpdate.rates[rateID].subRates = newSubRates; - } + customUnitOnyxUpdate.rates[rateID] = {...customUnitOnyxUpdate.rates[rateID], subRates: newSubRates}; } } return {newCustomUnit, customUnitOnyxUpdate}; From a04d1c851bd29b986a724b41c6c135ca66f822e6 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 5 Dec 2024 22:39:19 +0700 Subject: [PATCH 037/323] fix transaction order --- .../ReportActionItem/ReportPreview.tsx | 1 + src/libs/TransactionUtils/index.ts | 25 +++++++------------ src/libs/actions/IOU.ts | 3 ++- src/types/onyx/Transaction.ts | 3 +++ 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 19ab01a27c57..9e01353f6845 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -156,6 +156,7 @@ function ReportPreview({ const isInvoiceRoom = ReportUtils.isInvoiceRoom(chatReport); const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); + console.log(TransactionUtils.getAllSortedTransactions(iouReport?.reportID ?? '-1')); const isApproved = ReportUtils.isReportApproved(iouReport, action); const canAllowSettlement = ReportUtils.hasUpdatedTotal(iouReport, policy); const numberOfRequests = allTransactions.length; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index ede9cc69cc54..08fac743f4dc 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1248,23 +1248,16 @@ function buildTransactionsMergeParams(reviewDuplicates: OnyxEntry> { - // We need sort all transactions by sorting the parent report actions because `created` of the transaction only has format `YYYY-MM-DD` which can cause the wrong sorting - const allCreatedIOUActions = Object.values(ReportActionsUtils.getAllReportActions(iouReportID)) - ?.filter((reportAction): reportAction is ReportAction => { - if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) { - return false; - } - const message = ReportActionsUtils.getOriginalMessage(reportAction); - if (!message?.IOUTransactionID) { - return false; - } - return true; - }) - .sort((actionA, actionB) => (actionA.created < actionB.created ? -1 : 1)); + return getAllReportTransactions(iouReportID).sort((transA, transB) => { + if (transA.created < transB.created) { + return -1; + } + + if (transA.created > transB.created) { + return 1; + } - return allCreatedIOUActions.map((iouAction) => { - const transactionID = ReportActionsUtils.getOriginalMessage(iouAction)?.IOUTransactionID ?? '-1'; - return getTransaction(transactionID); + return (transA.inserted ?? '') < (transB.inserted ?? '') ? -1 : 1; }); } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 438fba5fef94..9cc2010199cb 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7123,6 +7123,7 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: const managerID = isLastApprover(approvalChain) ? expenseReport?.managerID : getNextApproverAccountID(expenseReport); const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, predictedNextStatus); + console.log(expenseReport, predictedNextStatus, optimisticNextStep); const chatReport = ReportUtils.getReportOrDraftReport(expenseReport?.chatReportID); const optimisticReportActionsData: OnyxUpdate = { @@ -7262,7 +7263,7 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: optimisticHoldReportExpenseActionIDs, }; - API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); + // API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); } function unapproveExpenseReport(expenseReport: OnyxEntry) { diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 547e41463c70..27ecb4ff3bf1 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -461,6 +461,9 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** The card transaction's posted date */ posted?: string; + + /** The inserted time of the transaction */ + inserted?: string; }, keyof Comment | keyof TransactionCustomUnit | 'attendees' >; From c79a16141f209d2753255632e4f4a08624feab54 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 5 Dec 2024 22:40:10 +0700 Subject: [PATCH 038/323] remove log --- src/components/ReportActionItem/ReportPreview.tsx | 1 - src/libs/actions/IOU.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 9e01353f6845..19ab01a27c57 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -156,7 +156,6 @@ function ReportPreview({ const isInvoiceRoom = ReportUtils.isInvoiceRoom(chatReport); const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); - console.log(TransactionUtils.getAllSortedTransactions(iouReport?.reportID ?? '-1')); const isApproved = ReportUtils.isReportApproved(iouReport, action); const canAllowSettlement = ReportUtils.hasUpdatedTotal(iouReport, policy); const numberOfRequests = allTransactions.length; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 9cc2010199cb..680f86aa4697 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7123,7 +7123,6 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: const managerID = isLastApprover(approvalChain) ? expenseReport?.managerID : getNextApproverAccountID(expenseReport); const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, predictedNextStatus); - console.log(expenseReport, predictedNextStatus, optimisticNextStep); const chatReport = ReportUtils.getReportOrDraftReport(expenseReport?.chatReportID); const optimisticReportActionsData: OnyxUpdate = { From 77eccd92260ff996267aecf27469088e383af6a7 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 5 Dec 2024 23:01:18 +0700 Subject: [PATCH 039/323] fix lint --- src/libs/DebugUtils.ts | 2 ++ src/libs/TransactionUtils/index.ts | 14 +------------- src/libs/actions/IOU.ts | 2 +- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 5687333370f0..81479d18ae38 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -925,6 +925,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) return validateString(value); case 'created': case 'modifiedCreated': + case 'inserted': case 'posted': return validateDate(value); case 'isLoading': @@ -1050,6 +1051,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) cardNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION, managedCard: CONST.RED_BRICK_ROAD_PENDING_ACTION, posted: CONST.RED_BRICK_ROAD_PENDING_ACTION, + inserted: CONST.RED_BRICK_ROAD_PENDING_ACTION, }, 'string', ); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 08fac743f4dc..2ff6e6cbac13 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -25,19 +25,7 @@ import type {IOURequestType} from '@userActions/IOU'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type { - OnyxInputOrEntry, - Policy, - RecentWaypoint, - Report, - ReportAction, - ReviewDuplicates, - TaxRate, - TaxRates, - Transaction, - TransactionViolation, - TransactionViolations, -} from '@src/types/onyx'; +import type {OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {SearchPolicy, SearchReport} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 680f86aa4697..438fba5fef94 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7262,7 +7262,7 @@ function approveMoneyRequest(expenseReport: OnyxEntry, full?: optimisticHoldReportExpenseActionIDs, }; - // API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); + API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); } function unapproveExpenseReport(expenseReport: OnyxEntry) { From 95ac5024ccfb25f090066c85d96fea036e4a8f21 Mon Sep 17 00:00:00 2001 From: Julian Kobrynski Date: Thu, 5 Dec 2024 21:13:33 +0100 Subject: [PATCH 040/323] 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 014995e49fe387b8959fb0e581040a754443da92 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Fri, 6 Dec 2024 13:03:41 +0530 Subject: [PATCH 041/323] Fix parameters --- .../API/parameters/UpdateWorkspaceCustomUnitParams.ts | 2 +- src/libs/actions/Policy/PerDiem.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts b/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts index 8ff704c4a0dd..fa1fc3d8c911 100644 --- a/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts +++ b/src/libs/API/parameters/UpdateWorkspaceCustomUnitParams.ts @@ -1,6 +1,6 @@ type UpdateWorkspaceCustomUnitParams = { policyID: string; - customUnitData: string; + customUnit: string; }; export default UpdateWorkspaceCustomUnitParams; diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts index 71823e80f512..ec6f4e7bc216 100644 --- a/src/libs/actions/Policy/PerDiem.ts +++ b/src/libs/actions/Policy/PerDiem.ts @@ -261,7 +261,7 @@ function deleteWorkspacePerDiemRates(policyID: string, customUnit: CustomUnit | const parameters = { policyID, - customUnitData: JSON.stringify(newCustomUnit), + customUnit: JSON.stringify(newCustomUnit), }; API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); @@ -291,7 +291,7 @@ function editPerDiemRateDestination(policyID: string, rateID: string, customUnit const parameters = { policyID, - customUnitData: JSON.stringify(newCustomUnit), + customUnit: JSON.stringify(newCustomUnit), }; API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); @@ -326,7 +326,7 @@ function editPerDiemRateSubrate(policyID: string, rateID: string, subRateID: str const parameters = { policyID, - customUnitData: JSON.stringify(newCustomUnit), + customUnit: JSON.stringify(newCustomUnit), }; API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); @@ -361,7 +361,7 @@ function editPerDiemRateAmount(policyID: string, rateID: string, subRateID: stri const parameters = { policyID, - customUnitData: JSON.stringify(newCustomUnit), + customUnit: JSON.stringify(newCustomUnit), }; API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); @@ -391,7 +391,7 @@ function editPerDiemRateCurrency(policyID: string, rateID: string, customUnit: C const parameters = { policyID, - customUnitData: JSON.stringify(newCustomUnit), + customUnit: JSON.stringify(newCustomUnit), }; API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, parameters, onyxData); From 5950c1180c199ba9364b2b19a3dcf1691ba82b14 Mon Sep 17 00:00:00 2001 From: daledah Date: Fri, 6 Dec 2024 14:48:37 +0700 Subject: [PATCH 042/323] 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 043/323] 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 (