diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 14e492ac2430..246d8983d528 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -375,6 +375,13 @@ function Search({ openSearch(); }, []); + useEffect(() => { + if (!prevIsOffline || isOffline) { + return; + } + openSearch(); + }, [isOffline, prevIsOffline]); + const {newSearchResultKeys, handleSelectionListScroll, newTransactions} = useSearchHighlightAndScroll({ searchResults, transactions, diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index fa512574ecfd..28bc50300346 100644 --- a/src/hooks/useSearchTypeMenu.tsx +++ b/src/hooks/useSearchTypeMenu.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; +import {useSearchContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import {clearAllFilters} from '@libs/actions/Search'; @@ -24,6 +25,7 @@ import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import useSearchTypeMenuSections from './useSearchTypeMenuSections'; import useSingleExecution from './useSingleExecution'; +import useSuggestedSearchDefaultNavigation from './useSuggestedSearchDefaultNavigation'; import useTheme from './useTheme'; import useThemeStyles from './useThemeStyles'; import useWindowDimensions from './useWindowDimensions'; @@ -36,7 +38,8 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { const {singleExecution} = useSingleExecution(); const {windowHeight} = useWindowDimensions(); const {translate} = useLocalize(); - const {typeMenuSections} = useSearchTypeMenuSections(); + const {typeMenuSections, shouldShowSuggestedSearchSkeleton} = useSearchTypeMenuSections(); + const {clearSelectedTransactions} = useSearchContext(); const {showDeleteModal, DeleteConfirmModal} = useDeleteSavedSearch(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const personalDetails = usePersonalDetails(); @@ -52,6 +55,14 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { const allCards = useMemo(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, userCardList), [userCardList, workspaceCardFeeds]); const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, {canBeMissing: true}); + const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [typeMenuSections]); + + useSuggestedSearchDefaultNavigation({ + shouldShowSkeleton: shouldShowSuggestedSearchSkeleton, + flattenedMenuItems, + similarSearchHash, + clearSelectedTransactions, + }); // this is a performance fix, rendering popover menu takes a lot of time and we don't need this component initially, that's why we postpone rendering it until everything else is rendered const [delayPopoverMenuFirstRender, setDelayPopoverMenuFirstRender] = useState(true); @@ -132,9 +143,8 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { return -1; } - const flattenedMenuItems = typeMenuSections.map((section) => section.menuItems).flat(); return flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); - }, [similarSearchHash, isSavedSearchActive, typeMenuSections]); + }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems]); const popoverMenuItems = useMemo(() => { return typeMenuSections diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 847c3e99a53b..7c1df75f7aee 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -113,6 +113,12 @@ const useSearchTypeMenuSections = () => { openCreateReportConfirmation(); }, [pendingReportCreation, openCreateReportConfirmation]); + const isSuggestedSearchDataReady = useMemo(() => { + const policiesList = Object.values(allPolicies ?? {}).filter((policy): policy is NonNullable => policy !== null && policy !== undefined); + + return policiesList.some((policy) => policy.employeeList !== undefined && policy.exporter !== undefined); + }, [allPolicies]); + const typeMenuSections = useMemo( () => createTypeMenuSections( @@ -145,7 +151,11 @@ const useSearchTypeMenuSections = () => { ], ); - return {typeMenuSections, CreateReportConfirmationModal}; + return { + typeMenuSections, + CreateReportConfirmationModal, + shouldShowSuggestedSearchSkeleton: !isSuggestedSearchDataReady && !isOffline, + }; }; export default useSearchTypeMenuSections; diff --git a/src/hooks/useSuggestedSearchDefaultNavigation.ts b/src/hooks/useSuggestedSearchDefaultNavigation.ts new file mode 100644 index 000000000000..04b59d3ffff8 --- /dev/null +++ b/src/hooks/useSuggestedSearchDefaultNavigation.ts @@ -0,0 +1,43 @@ +import {useEffect, useRef} from 'react'; +import {clearAllFilters} from '@libs/actions/Search'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SearchTypeMenuItem} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +type UseSuggestedSearchDefaultNavigationParams = { + shouldShowSkeleton: boolean; + flattenedMenuItems: SearchTypeMenuItem[]; + similarSearchHash?: number; + clearSelectedTransactions: () => void; +}; + +function useSuggestedSearchDefaultNavigation({shouldShowSkeleton, flattenedMenuItems, similarSearchHash, clearSelectedTransactions}: UseSuggestedSearchDefaultNavigationParams) { + const hasShownSkeleton = useRef(false); + + useEffect(() => { + if (shouldShowSkeleton) { + hasShownSkeleton.current = true; + return; + } + + if (!hasShownSkeleton.current) { + return; + } + + hasShownSkeleton.current = false; + + const defaultMenuItem = + flattenedMenuItems.find((item) => item.key === CONST.SEARCH.SEARCH_KEYS.APPROVE) ?? flattenedMenuItems.find((item) => item.key === CONST.SEARCH.SEARCH_KEYS.SUBMIT); + + if (!defaultMenuItem || similarSearchHash === defaultMenuItem.similarSearchHash) { + return; + } + + clearAllFilters(); + clearSelectedTransactions(); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: defaultMenuItem.searchQuery})); + }, [shouldShowSkeleton, flattenedMenuItems, similarSearchHash, clearSelectedTransactions]); +} + +export default useSuggestedSearchDefaultNavigation; diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 6129184ca20a..dd87b2cdef9a 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -1,10 +1,9 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; -import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useContext, useLayoutEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, ScrollViewProps} from 'react-native'; -import Animated, {FadeIn} from 'react-native-reanimated'; import MenuItem from '@components/MenuItem'; import type {MenuItemWithLink} from '@components/MenuItemList'; import MenuItemList from '@components/MenuItemList'; @@ -21,6 +20,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; import useSingleExecution from '@hooks/useSingleExecution'; +import useSuggestedSearchDefaultNavigation from '@hooks/useSuggestedSearchDefaultNavigation'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearAllFilters} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; @@ -35,6 +35,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; +import SuggestedSearchSkeleton from './SuggestedSearchSkeleton'; type SearchTypeMenuProps = { queryJSON: SearchQueryJSON | undefined; @@ -47,7 +48,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const {singleExecution} = useSingleExecution(); const {translate} = useLocalize(); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {canBeMissing: true}); - const {typeMenuSections, CreateReportConfirmationModal} = useSearchTypeMenuSections(); + const {typeMenuSections, CreateReportConfirmationModal, shouldShowSuggestedSearchSkeleton} = useSearchTypeMenuSections(); const isFocused = useIsFocused(); const { shouldShowProductTrainingTooltip: shouldShowSavedSearchTooltip, @@ -69,19 +70,15 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const taxRates = getAllTaxRates(allPolicies); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: false}); const {clearSelectedTransactions} = useSearchContext(); - const initialSearchKeys = useRef([]); - // The first time we render all of the sections the user can see, we need to mark these as 'rendered', such that we - // dont animate them in. We only animate in items that a user gains access to later on - useEffect(() => { - if (initialSearchKeys.current.length) { - return; - } + const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [typeMenuSections]); - initialSearchKeys.current = typeMenuSections.flatMap((section) => { - return section.menuItems.map((item) => item.key); - }); - }, [typeMenuSections]); + useSuggestedSearchDefaultNavigation({ + shouldShowSkeleton: shouldShowSuggestedSearchSkeleton, + flattenedMenuItems, + similarSearchHash, + clearSelectedTransactions, + }); const getOverflowMenu = useCallback((itemName: string, itemHash: number, itemQuery: string) => getOverflowMenuUtil(itemName, itemHash, itemQuery, showDeleteModal), [showDeleteModal]); const createSavedSearchMenuItem = useCallback( @@ -206,9 +203,8 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { return -1; } - const flattenedMenuItems = typeMenuSections.map((section) => section.menuItems).flat(); return flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); - }, [similarSearchHash, isSavedSearchActive, typeMenuSections]); + }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems]); return ( <> @@ -218,39 +214,38 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { ref={scrollViewRef} showsVerticalScrollIndicator={false} > - - {typeMenuSections.map((section, sectionIndex) => ( - - {translate(section.translationPath)} - - {section.translationPath === 'search.savedSearchesMenuItemTitle' ? ( - <> - {renderSavedSearchesSection(savedSearchesMenuItems)} - {/* DeleteConfirmModal is a stable JSX element returned by the hook. - Returning the element directly keeps the component identity across re-renders so React - can play its exit animation instead of removing it instantly. */} - {DeleteConfirmModal} - - ) : ( - <> - {section.menuItems.map((item, itemIndex) => { - const previousItemCount = typeMenuSections.slice(0, sectionIndex).reduce((acc, sec) => acc + sec.menuItems.length, 0); - const flattenedIndex = previousItemCount + itemIndex; - const focused = activeItemIndex === flattenedIndex; + {shouldShowSuggestedSearchSkeleton ? ( + + + + ) : ( + + {typeMenuSections.map((section, sectionIndex) => ( + + {translate(section.translationPath)} - const onPress = singleExecution(() => { - clearAllFilters(); - clearSelectedTransactions(); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); - }); + {section.translationPath === 'search.savedSearchesMenuItemTitle' ? ( + <> + {renderSavedSearchesSection(savedSearchesMenuItems)} + {/* DeleteConfirmModal is a stable JSX element returned by the hook. + Returning the element directly keeps the component identity across re-renders so React + can play its exit animation instead of removing it instantly. */} + {DeleteConfirmModal} + + ) : ( + <> + {section.menuItems.map((item, itemIndex) => { + const previousItemCount = typeMenuSections.slice(0, sectionIndex).reduce((acc, sec) => acc + sec.menuItems.length, 0); + const flattenedIndex = previousItemCount + itemIndex; + const focused = activeItemIndex === flattenedIndex; - const isInitialItem = !initialSearchKeys.current.length || initialSearchKeys.current.includes(item.key); + const onPress = singleExecution(() => { + clearAllFilters(); + clearSelectedTransactions(); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); + }); - return ( - + return ( - - ); - })} - - )} - - ))} - + ); + })} + + )} + + ))} + + )} ); diff --git a/src/pages/Search/SuggestedSearchSkeleton.tsx b/src/pages/Search/SuggestedSearchSkeleton.tsx new file mode 100644 index 000000000000..5072b717a3eb --- /dev/null +++ b/src/pages/Search/SuggestedSearchSkeleton.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import {View} from 'react-native'; +import {Rect} from 'react-native-svg'; +import ItemListSkeletonView from '@components/Skeletons/ItemListSkeletonView'; +import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + +const SUGGESTED_SEARCH_SKELETON_TEST_ID = 'SuggestedSearchSkeleton'; +const NAV_ITEM_HEIGHT = 52; +const SECTION_MENU_ITEM_HORIZONTAL_PADDING = 16; +const SECTION_HEADER_HEIGHT = 32; +const SECTION_HEADER_RECT_HEIGHT = 4; +const ICON_LABEL_GAP = 12; +const ICON_CONTAINER_SIZE = 20; +const LOADING_ICON_SIZE = variables.iconSizeSmall; +const ICON_CONTAINER_VERTICAL_OFFSET = (NAV_ITEM_HEIGHT - ICON_CONTAINER_SIZE) / 2; +const ICON_INNER_OFFSET = (ICON_CONTAINER_SIZE - LOADING_ICON_SIZE) / 2; +const ICON_VERTICAL_OFFSET = ICON_CONTAINER_VERTICAL_OFFSET + ICON_INNER_OFFSET; +const LABEL_VERTICAL_OFFSET = 4; + +/** ---- Relative layout tokens ---- */ +const LHN = { + icon: { + xVal: SECTION_MENU_ITEM_HORIZONTAL_PADDING + ICON_INNER_OFFSET, + yVal: ICON_VERTICAL_OFFSET, + w: LOADING_ICON_SIZE, + h: LOADING_ICON_SIZE, + r: 4, + }, + label: { + xVal: SECTION_MENU_ITEM_HORIZONTAL_PADDING + ICON_CONTAINER_SIZE + ICON_LABEL_GAP, + yVal: ICON_VERTICAL_OFFSET + LABEL_VERTICAL_OFFSET, + w: 104, + h: 8, + }, + // header bar in the small loader above each group + header: { + xVal: 8, + yVal: (SECTION_HEADER_HEIGHT - SECTION_HEADER_RECT_HEIGHT) / 2, + w: 36, + h: SECTION_HEADER_RECT_HEIGHT, + }, +}; + +function SuggestedSearchSkeleton() { + const theme = useTheme(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const renderNavigationItem = () => { + const {icon, label} = LHN; + + return ( + <> + + + + ); + }; + + const shouldRenderNavigationColumn = !shouldUseNarrowLayout; + + const navigationColumnGroup = ( + <> + + + + + + + ); + + const navigationColumn = shouldRenderNavigationColumn ? ( + + + {[0, 1, 2].map((i) => ( + + {navigationColumnGroup} + + ))} + + + ) : null; + + return ( + + {navigationColumn} + + ); +} + +SuggestedSearchSkeleton.displayName = 'SuggestedSearchSkeleton'; +export default SuggestedSearchSkeleton; diff --git a/tests/unit/hooks/useSuggestedSearchDefaultNavigationTest.ts b/tests/unit/hooks/useSuggestedSearchDefaultNavigationTest.ts new file mode 100644 index 000000000000..b8d56fbba1b1 --- /dev/null +++ b/tests/unit/hooks/useSuggestedSearchDefaultNavigationTest.ts @@ -0,0 +1,196 @@ +import {renderHook} from '@testing-library/react-native'; +import useSuggestedSearchDefaultNavigation from '@hooks/useSuggestedSearchDefaultNavigation'; +import {clearAllFilters} from '@libs/actions/Search'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; +import type {SearchTypeMenuItem} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ROUTES from '@src/ROUTES'; +import type IconAsset from '@src/types/utils/IconAsset'; + +jest.mock('@libs/actions/Search', () => ({ + clearAllFilters: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), +})); + +function createApproveMenuItem(): SearchTypeMenuItem { + return { + key: CONST.SEARCH.SEARCH_KEYS.APPROVE, + translationPath: 'common.approve' as TranslationPaths, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + icon: {} as IconAsset, + searchQuery: buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + action: CONST.SEARCH.ACTION_FILTERS.APPROVE, + }), + searchQueryJSON: undefined, + hash: 1, + similarSearchHash: 101, + }; +} + +function createSubmitMenuItem(): SearchTypeMenuItem { + return { + key: CONST.SEARCH.SEARCH_KEYS.SUBMIT, + translationPath: 'iou.submitExpense' as TranslationPaths, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + icon: {} as IconAsset, + searchQuery: buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + action: CONST.SEARCH.ACTION_FILTERS.SUBMIT, + }), + searchQueryJSON: undefined, + hash: 2, + similarSearchHash: 202, + }; +} + +function createExpenseMenuItem(): SearchTypeMenuItem { + return { + key: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + translationPath: 'common.expenses' as TranslationPaths, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + icon: {} as IconAsset, + searchQuery: buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + }), + searchQueryJSON: undefined, + hash: 3, + similarSearchHash: 303, + }; +} + +function createExpenseReportMenuItem(): SearchTypeMenuItem { + return { + key: CONST.SEARCH.SEARCH_KEYS.REPORTS, + translationPath: 'common.reports' as TranslationPaths, + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + icon: {} as IconAsset, + searchQuery: buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + }), + searchQueryJSON: undefined, + hash: 4, + similarSearchHash: 404, + }; +} + +function createChatMenuItem(): SearchTypeMenuItem { + return { + key: CONST.SEARCH.SEARCH_KEYS.CHATS, + translationPath: 'common.chats' as TranslationPaths, + type: CONST.SEARCH.DATA_TYPES.CHAT, + icon: {} as IconAsset, + searchQuery: buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.CHAT, + }), + searchQueryJSON: undefined, + hash: 5, + similarSearchHash: 505, + }; +} + +describe('useSuggestedSearchDefaultNavigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to the Approve search when skeleton hides and approve menu item is available', () => { + const clearSelectedTransactions = jest.fn(); + const approveMenuItem = createApproveMenuItem(); + const submitMenuItem = createSubmitMenuItem(); + const expenseMenuItem = createExpenseMenuItem(); + const expenseReportMenuItem = createExpenseReportMenuItem(); + const chatMenuItem = createChatMenuItem(); + + const {rerender} = renderHook((props: Parameters[0]) => useSuggestedSearchDefaultNavigation(props), { + initialProps: { + shouldShowSkeleton: true, + flattenedMenuItems: [approveMenuItem, submitMenuItem, expenseMenuItem, expenseReportMenuItem, chatMenuItem], + similarSearchHash: undefined, + clearSelectedTransactions, + }, + }); + + rerender({ + shouldShowSkeleton: false, + flattenedMenuItems: [approveMenuItem, submitMenuItem, expenseMenuItem, expenseReportMenuItem, chatMenuItem], + similarSearchHash: undefined, + clearSelectedTransactions, + }); + + expect(clearAllFilters).toHaveBeenCalledTimes(1); + expect(clearSelectedTransactions).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: approveMenuItem.searchQuery})); + }); + + it('goes to Submit search when Approve is unavailable and Submit menu item is available', () => { + const clearSelectedTransactions = jest.fn(); + const submitMenuItem = createSubmitMenuItem(); + const expenseMenuItem = createExpenseMenuItem(); + const expenseReportMenuItem = createExpenseReportMenuItem(); + const chatMenuItem = createChatMenuItem(); + + const {rerender} = renderHook((props: Parameters[0]) => useSuggestedSearchDefaultNavigation(props), { + initialProps: { + shouldShowSkeleton: true, + flattenedMenuItems: [submitMenuItem, expenseMenuItem, expenseReportMenuItem, chatMenuItem], + similarSearchHash: undefined, + clearSelectedTransactions, + }, + }); + + rerender({ + shouldShowSkeleton: false, + flattenedMenuItems: [submitMenuItem, expenseMenuItem, expenseReportMenuItem, chatMenuItem], + similarSearchHash: undefined, + clearSelectedTransactions, + }); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: submitMenuItem.searchQuery})); + }); + + it('does not navigate if skeleton never rendered', () => { + const clearSelectedTransactions = jest.fn(); + const approveMenuItem = createApproveMenuItem(); + const submitMenuItem = createSubmitMenuItem(); + const expenseMenuItem = createExpenseMenuItem(); + const expenseReportMenuItem = createExpenseReportMenuItem(); + const chatMenuItem = createChatMenuItem(); + renderHook((props: Parameters[0]) => useSuggestedSearchDefaultNavigation(props), { + initialProps: { + shouldShowSkeleton: false, + flattenedMenuItems: [approveMenuItem, submitMenuItem, expenseMenuItem, expenseReportMenuItem, chatMenuItem], + similarSearchHash: undefined, + clearSelectedTransactions, + }, + }); + + expect(clearAllFilters).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('does not navigate if search already active', () => { + const clearSelectedTransactions = jest.fn(); + const approveMenuItem = createApproveMenuItem(); + const submitMenuItem = createSubmitMenuItem(); + const expenseMenuItem = createExpenseMenuItem(); + const expenseReportMenuItem = createExpenseReportMenuItem(); + const chatMenuItem = createChatMenuItem(); + renderHook((props: Parameters[0]) => useSuggestedSearchDefaultNavigation(props), { + initialProps: { + shouldShowSkeleton: false, + flattenedMenuItems: [approveMenuItem, submitMenuItem, expenseMenuItem, expenseReportMenuItem, chatMenuItem], + similarSearchHash: expenseMenuItem.similarSearchHash, + clearSelectedTransactions, + }, + }); + + expect(clearAllFilters).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useSearchTypeMenuSectionsTest.ts b/tests/unit/useSearchTypeMenuSectionsTest.ts new file mode 100644 index 000000000000..0047d6188cd5 --- /dev/null +++ b/tests/unit/useSearchTypeMenuSectionsTest.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {renderHook} from '@testing-library/react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import useNetwork from '@hooks/useNetwork'; +import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +jest.mock('@libs/ReportUtils', () => ({ + getPersonalDetailsForAccountID: jest.fn(), + hasEmptyReportsForPolicy: jest.fn(() => false), + hasViolations: jest.fn(() => false), +})); + +jest.mock('@userActions/Report', () => ({ + createNewReport: jest.fn(() => ({reportID: 'mock-report-id'})), +})); + +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}, defaultExpensifyCard: null}))); +jest.mock('@hooks/useCreateEmptyReportConfirmation', () => jest.fn(() => ({openCreateReportConfirmation: jest.fn(), CreateReportConfirmationModal: null}))); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: jest.fn(() => false)}))); + +const onyxData: Record = {}; + +const mockUseOnyx = jest.fn( + ( + key: string, + options?: { + selector?: (value: unknown) => unknown; + }, + ) => { + const value = onyxData[key]; + const selectedValue = options?.selector ? options.selector(value as never) : value; + return [selectedValue]; + }, +); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: (key: string, options?: {selector?: (value: unknown) => unknown}) => mockUseOnyx(key, options), +})); + +jest.mock('@selectors/Policy', () => ({ + createPoliciesSelector: jest.fn((policies: OnyxCollection, policySelector: (policy: OnyxEntry) => OnyxEntry) => { + if (!policies) { + return policies; + } + + return Object.fromEntries(Object.entries(policies).map(([policyKey, policyValue]) => [policyKey, policyValue ? policySelector(policyValue) : policyValue])); + }), +})); + +describe('useSearchTypeMenuSections', () => { + beforeEach(() => { + onyxData[ONYXKEYS.COLLECTION.POLICY] = {}; + onyxData[ONYXKEYS.SESSION] = {email: 'test@example.com', accountID: 1}; + onyxData[ONYXKEYS.SAVED_SEARCHES] = {}; + onyxData[ONYXKEYS.COLLECTION.REPORT] = {}; + + mockUseOnyx.mockClear(); + }); + + it('shows suggested search skeleton when policies are missing employeeList', () => { + onyxData[ONYXKEYS.COLLECTION.POLICY] = { + policy1: { + id: 'policy1', + employeeList: undefined, + exporter: 'test@gmail.com', + }, + }; + + const {result} = renderHook(() => useSearchTypeMenuSections()); + + expect(result.current.shouldShowSuggestedSearchSkeleton).toBe(true); + }); + + it('shows suggested search skeleton when policies are missing exporter', () => { + onyxData[ONYXKEYS.COLLECTION.POLICY] = { + policy1: { + id: 'policy1', + employeeList: {'test@gmail.com': {accountID: 10000}}, + exporter: undefined, + }, + }; + + const {result} = renderHook(() => useSearchTypeMenuSections()); + + expect(result.current.shouldShowSuggestedSearchSkeleton).toBe(true); + }); + + it('hides suggested search skeleton when at least one policy has required data', () => { + onyxData[ONYXKEYS.COLLECTION.POLICY] = { + policy1: { + id: 'policy1', + employeeList: {'test@gmail.com': {accountID: 10000}}, + exporter: '', + }, + policy2: { + id: 'policy2', + employeeList: undefined, + exporter: undefined, + }, + }; + + const {result} = renderHook(() => useSearchTypeMenuSections()); + + expect(result.current.shouldShowSuggestedSearchSkeleton).toBe(false); + }); + + it('does not show suggested search skeleton when offline', () => { + (useNetwork as jest.Mock).mockReturnValue({ + isOffline: true, + }); + const {result} = renderHook(() => useSearchTypeMenuSections()); + + expect(result.current.shouldShowSuggestedSearchSkeleton).toBe(false); + }); +});