From c577abfeeef0c37532fa3ab57a8e5e4c6858defd Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 3 Nov 2025 17:51:27 +0530 Subject: [PATCH 01/16] Add loading UI for search page menu + default to approve section for admins --- src/components/Search/index.tsx | 4 + src/hooks/useSearchTypeMenu.tsx | 16 +- src/hooks/useSearchTypeMenuSections.ts | 16 +- .../useSuggestedSearchDefaultNavigation.ts | 43 ++++ src/pages/Search/SearchTypeMenu.tsx | 122 ++++++----- src/pages/Search/SuggestedSearchSkeleton.tsx | 103 +++++++++ ...useSuggestedSearchDefaultNavigationTest.ts | 196 ++++++++++++++++++ tests/unit/useSearchTypeMenuSectionsTest.ts | 123 +++++++++++ 8 files changed, 566 insertions(+), 57 deletions(-) create mode 100644 src/hooks/useSuggestedSearchDefaultNavigation.ts create mode 100644 src/pages/Search/SuggestedSearchSkeleton.tsx create mode 100644 tests/unit/hooks/useSuggestedSearchDefaultNavigationTest.ts create mode 100644 tests/unit/useSearchTypeMenuSectionsTest.ts diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 26a8eda50c60..3298ad78fb77 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -424,6 +424,10 @@ function Search({ return; } + if (prevIsOffline && !isOffline) { + openSearch(); + } + handleSearch({queryJSON, searchKey, offset, shouldCalculateTotals, prevReportsLength: dataLength}); // We don't need to run the effect on change of isFocused. diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index c5484f9a2288..f6628c9badd0 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(); @@ -51,6 +54,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); @@ -131,9 +142,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..67b92c3fec6b 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -113,6 +113,16 @@ const useSearchTypeMenuSections = () => { openCreateReportConfirmation(); }, [pendingReportCreation, openCreateReportConfirmation]); + const isSuggestedSearchDataReady = useMemo(() => { + const policiesList = Object.values(allPolicies ?? {}).filter((policy): policy is NonNullable => policy !== null && policy !== undefined); + + if (policiesList.length === 0) { + return true; + } + + return policiesList.some((policy) => policy.employeeList !== undefined && policy.exporter !== undefined); + }, [allPolicies]); + const typeMenuSections = useMemo( () => createTypeMenuSections( @@ -145,7 +155,11 @@ const useSearchTypeMenuSections = () => { ], ); - return {typeMenuSections, CreateReportConfirmationModal}; + return { + typeMenuSections, + CreateReportConfirmationModal, + shouldShowSuggestedSearchSkeleton: !isSuggestedSearchDataReady, + }; }; 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 a8592b6e014b..722cabac0531 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -20,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 +36,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 +49,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, @@ -82,6 +84,15 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { }); }, [typeMenuSections]); + const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [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( (item: SaveSearchItem, key: string, index: number) => { @@ -205,9 +216,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 ( <> @@ -217,60 +227,66 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { ref={scrollViewRef} showsVerticalScrollIndicator={false} > - - {typeMenuSections.map((section, sectionIndex) => ( - - {translate(section.translationPath)} + {shouldShowSuggestedSearchSkeleton ? ( + + + + ) : ( + + {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; + {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 onPress = singleExecution(() => { - clearAllFilters(); - clearSelectedTransactions(); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); - }); + const onPress = singleExecution(() => { + clearAllFilters(); + clearSelectedTransactions(); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); + }); - const isInitialItem = !initialSearchKeys.current.length || initialSearchKeys.current.includes(item.key); + const isInitialItem = !initialSearchKeys.current.length || initialSearchKeys.current.includes(item.key); - return ( - - - - ); - })} - - )} - - ))} - + return ( + + + + ); + })} + + )} + + ))} + + )} ); diff --git a/src/pages/Search/SuggestedSearchSkeleton.tsx b/src/pages/Search/SuggestedSearchSkeleton.tsx new file mode 100644 index 000000000000..e128504b3de4 --- /dev/null +++ b/src/pages/Search/SuggestedSearchSkeleton.tsx @@ -0,0 +1,103 @@ +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'; + +const SUGGESTED_SEARCH_SKELETON_TEST_ID = 'SuggestedSearchSkeleton'; +const NAV_ITEM_HEIGHT = 52; + +/** ---- Relative layout tokens ---- */ +const LHN = { + icon: {xVal: 18, yVal: 8, w: 16, h: 16, r: 4}, + // label is positioned relative to icon + label: {dx: 30, dy: 4, w: 104, h: 8}, + // header bar in the small loader above each group + header: {xVal: 8, yVal: 0, w: 36, h: 4}, +}; + +function SuggestedSearchSkeleton() { + const theme = useTheme(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const renderNavigationItem = () => { + const {icon, label} = LHN; + const labelX = icon.xVal + label.dx; + const labelY = icon.yVal + label.dy; + + 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..58042cfac1cb --- /dev/null +++ b/tests/unit/hooks/useSuggestedSearchDefaultNavigationTest.ts @@ -0,0 +1,196 @@ +import {act, renderHook} from '@testing-library/react-native'; +import useSuggestedSearchDefaultNavigation from '@hooks/useSuggestedSearchDefaultNavigation'; +import {clearAllFilters} from '@libs/actions/Search'; +import Navigation from '@libs/Navigation/Navigation'; +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'; +import { buildQueryStringFromFilterFormValues } from '@libs/SearchQueryUtils'; + +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..a188d53bdb53 --- /dev/null +++ b/tests/unit/useSearchTypeMenuSectionsTest.ts @@ -0,0 +1,123 @@ +import {renderHook} from '@testing-library/react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +const mockCreateTypeMenuSections = jest.fn(() => []); + +jest.mock('@libs/SearchUIUtils', () => ({ + createTypeMenuSections: (...args: unknown[]) => mockCreateTypeMenuSections(...args), +})); + +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.NVP_ACTIVE_POLICY_ID] = undefined; + onyxData[ONYXKEYS.SAVED_SEARCHES] = {}; + onyxData[ONYXKEYS.COLLECTION.REPORT] = {}; + onyxData[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS] = {}; + + mockUseOnyx.mockClear(); + mockCreateTypeMenuSections.mockClear(); + }); + + it('does not show suggested search skeleton when no policies exist', () => { + const {result} = renderHook(() => useSearchTypeMenuSections()); + + expect(result.current.shouldShowSuggestedSearchSkeleton).toBe(false); + }); + + 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); + }); +}); From 3ecf2d633bc03de46e97351cb656b541763b35f8 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 3 Nov 2025 18:00:47 +0530 Subject: [PATCH 02/16] Update tests --- .../hooks/useSuggestedSearchDefaultNavigationTest.ts | 4 ++-- tests/unit/useSearchTypeMenuSectionsTest.ts | 11 ++--------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/unit/hooks/useSuggestedSearchDefaultNavigationTest.ts b/tests/unit/hooks/useSuggestedSearchDefaultNavigationTest.ts index 58042cfac1cb..b8d56fbba1b1 100644 --- a/tests/unit/hooks/useSuggestedSearchDefaultNavigationTest.ts +++ b/tests/unit/hooks/useSuggestedSearchDefaultNavigationTest.ts @@ -1,13 +1,13 @@ -import {act, renderHook} from '@testing-library/react-native'; +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'; -import { buildQueryStringFromFilterFormValues } from '@libs/SearchQueryUtils'; jest.mock('@libs/actions/Search', () => ({ clearAllFilters: jest.fn(), diff --git a/tests/unit/useSearchTypeMenuSectionsTest.ts b/tests/unit/useSearchTypeMenuSectionsTest.ts index a188d53bdb53..7332e39bacab 100644 --- a/tests/unit/useSearchTypeMenuSectionsTest.ts +++ b/tests/unit/useSearchTypeMenuSectionsTest.ts @@ -1,15 +1,11 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + import {renderHook} from '@testing-library/react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; -const mockCreateTypeMenuSections = jest.fn(() => []); - -jest.mock('@libs/SearchUIUtils', () => ({ - createTypeMenuSections: (...args: unknown[]) => mockCreateTypeMenuSections(...args), -})); - jest.mock('@libs/ReportUtils', () => ({ getPersonalDetailsForAccountID: jest.fn(), hasEmptyReportsForPolicy: jest.fn(() => false), @@ -59,13 +55,10 @@ describe('useSearchTypeMenuSections', () => { beforeEach(() => { onyxData[ONYXKEYS.COLLECTION.POLICY] = {}; onyxData[ONYXKEYS.SESSION] = {email: 'test@example.com', accountID: 1}; - onyxData[ONYXKEYS.NVP_ACTIVE_POLICY_ID] = undefined; onyxData[ONYXKEYS.SAVED_SEARCHES] = {}; onyxData[ONYXKEYS.COLLECTION.REPORT] = {}; - onyxData[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS] = {}; mockUseOnyx.mockClear(); - mockCreateTypeMenuSections.mockClear(); }); it('does not show suggested search skeleton when no policies exist', () => { From b40a2983e29ac7a78370166ca42c2c1bf69dd1bf Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:08:35 +0530 Subject: [PATCH 03/16] Show loading UI when no policies have loaded --- src/hooks/useSearchTypeMenuSections.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 67b92c3fec6b..a0d537d4a971 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -116,10 +116,6 @@ const useSearchTypeMenuSections = () => { const isSuggestedSearchDataReady = useMemo(() => { const policiesList = Object.values(allPolicies ?? {}).filter((policy): policy is NonNullable => policy !== null && policy !== undefined); - if (policiesList.length === 0) { - return true; - } - return policiesList.some((policy) => policy.employeeList !== undefined && policy.exporter !== undefined); }, [allPolicies]); From b22b467464c86e75b0c59f1965e278f4efcf621d Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:10:43 +0530 Subject: [PATCH 04/16] Prettier fixes --- tests/unit/useSearchTypeMenuSectionsTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/useSearchTypeMenuSectionsTest.ts b/tests/unit/useSearchTypeMenuSectionsTest.ts index 7332e39bacab..02f38378a6ce 100644 --- a/tests/unit/useSearchTypeMenuSectionsTest.ts +++ b/tests/unit/useSearchTypeMenuSectionsTest.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/naming-convention */ - import {renderHook} from '@testing-library/react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; From c06e3d81d9cfd8099ba85332ace1fec0f90a3fe4 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:14:41 +0530 Subject: [PATCH 05/16] Update useSearchTypeMenuSectionsTest --- tests/unit/useSearchTypeMenuSectionsTest.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/unit/useSearchTypeMenuSectionsTest.ts b/tests/unit/useSearchTypeMenuSectionsTest.ts index 02f38378a6ce..d1c4289c4fb5 100644 --- a/tests/unit/useSearchTypeMenuSectionsTest.ts +++ b/tests/unit/useSearchTypeMenuSectionsTest.ts @@ -60,12 +60,6 @@ describe('useSearchTypeMenuSections', () => { mockUseOnyx.mockClear(); }); - it('does not show suggested search skeleton when no policies exist', () => { - const {result} = renderHook(() => useSearchTypeMenuSections()); - - expect(result.current.shouldShowSuggestedSearchSkeleton).toBe(false); - }); - it('shows suggested search skeleton when policies are missing employeeList', () => { onyxData[ONYXKEYS.COLLECTION.POLICY] = { policy1: { From 71fc278e6a4698080723e1f84ad735f57924a184 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:13:19 +0530 Subject: [PATCH 06/16] Handle user offline scenario --- src/hooks/useSearchTypeMenuSections.ts | 2 +- tests/unit/useSearchTypeMenuSectionsTest.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index a0d537d4a971..7c1df75f7aee 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -154,7 +154,7 @@ const useSearchTypeMenuSections = () => { return { typeMenuSections, CreateReportConfirmationModal, - shouldShowSuggestedSearchSkeleton: !isSuggestedSearchDataReady, + shouldShowSuggestedSearchSkeleton: !isSuggestedSearchDataReady && !isOffline, }; }; diff --git a/tests/unit/useSearchTypeMenuSectionsTest.ts b/tests/unit/useSearchTypeMenuSectionsTest.ts index d1c4289c4fb5..0047d6188cd5 100644 --- a/tests/unit/useSearchTypeMenuSectionsTest.ts +++ b/tests/unit/useSearchTypeMenuSectionsTest.ts @@ -1,6 +1,7 @@ /* 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'; @@ -106,4 +107,13 @@ describe('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); + }); }); From b61e5dd606b258f8abad2eb7c99a809b777ebac3 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 6 Nov 2025 01:40:02 +0530 Subject: [PATCH 07/16] Add prevIsOffline to deps --- src/components/Search/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 3298ad78fb77..fb51fe2ea07f 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -433,7 +433,7 @@ function Search({ // We don't need to run the effect on change of isFocused. // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSearch, isOffline, offset, queryJSON, searchKey, shouldCalculateTotals]); + }, [handleSearch, prevIsOffline, offset, queryJSON, searchKey, shouldCalculateTotals]); // When new data load, selectedTransactions is updated in next effect. We use this flag to whether selection is updated const isRefreshingSelection = useRef(false); From 065d45a84e810f8360351289b2f075b86c3b5fae Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 9 Nov 2025 03:56:02 +0530 Subject: [PATCH 08/16] Handle issue of extra API calls --- src/hooks/useSearchHighlightAndScroll.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchHighlightAndScroll.ts index 5d0a1ca2a568..7752bce0d1c8 100644 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ b/src/hooks/useSearchHighlightAndScroll.ts @@ -50,6 +50,7 @@ function useSearchHighlightAndScroll({ const highlightedIDs = useRef>(new Set()); const initializedRef = useRef(false); const hasPendingSearchRef = useRef(false); + const hasProcessedInitialChangeRef = useRef(false); const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; const existingSearchResultIDs = useMemo(() => { @@ -80,6 +81,15 @@ function useSearchHighlightAndScroll({ // Trigger search when a new report action is added while on chat or when a new transaction is added for the other search types. useEffect(() => { + if (!initializedRef.current) { + return; + } + + if (!hasProcessedInitialChangeRef.current) { + hasProcessedInitialChangeRef.current = true; + return; + } + const previousTransactionsIDs = Object.keys(previousTransactions ?? {}); const transactionsIDs = Object.keys(transactions ?? {}); @@ -164,6 +174,7 @@ function useSearchHighlightAndScroll({ highlightedIDs.current = new Set(existingSearchResultIDs); initializedRef.current = true; + hasProcessedInitialChangeRef.current = false; }, [searchResults?.data, isChat, existingSearchResultIDs]); // Detect new items (transactions or report actions) From 6c13582e9fdfad9dc48a4df72abc58f27f7a928e Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 10 Nov 2025 02:03:20 +0530 Subject: [PATCH 09/16] Update --- src/components/Search/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index fb51fe2ea07f..3298ad78fb77 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -433,7 +433,7 @@ function Search({ // We don't need to run the effect on change of isFocused. // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSearch, prevIsOffline, offset, queryJSON, searchKey, shouldCalculateTotals]); + }, [handleSearch, isOffline, offset, queryJSON, searchKey, shouldCalculateTotals]); // When new data load, selectedTransactions is updated in next effect. We use this flag to whether selection is updated const isRefreshingSelection = useRef(false); From ce142104bf0073c62c7a89a8217bd138ea78f827 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:41:01 +0530 Subject: [PATCH 10/16] Update --- src/components/Search/index.tsx | 11 ++++-- src/hooks/useSearchHighlightAndScroll.ts | 50 +++++++++++------------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 3298ad78fb77..7bc4b815b0c0 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -370,6 +370,13 @@ function Search({ openSearch(); }, []); + useEffect(() => { + if (!prevIsOffline || isOffline) { + return; + } + openSearch(); + }, [isOffline, prevIsOffline]); + const {newSearchResultKey, handleSelectionListScroll, newTransactions} = useSearchHighlightAndScroll({ searchResults, transactions, @@ -424,10 +431,6 @@ function Search({ return; } - if (prevIsOffline && !isOffline) { - openSearch(); - } - handleSearch({queryJSON, searchKey, offset, shouldCalculateTotals, prevReportsLength: dataLength}); // We don't need to run the effect on change of isFocused. diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchHighlightAndScroll.ts index 7752bce0d1c8..8cb73b30ea8e 100644 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ b/src/hooks/useSearchHighlightAndScroll.ts @@ -46,11 +46,10 @@ function useSearchHighlightAndScroll({ const searchTriggeredRef = useRef(false); const hasNewItemsRef = useRef(false); const previousSearchResults = usePrevious(searchResults?.data); - const [newSearchResultKey, setNewSearchResultKey] = useState(null); + const [newSearchResultKeys, setNewSearchResultKeys] = useState | null>(null); const highlightedIDs = useRef>(new Set()); const initializedRef = useRef(false); const hasPendingSearchRef = useRef(false); - const hasProcessedInitialChangeRef = useRef(false); const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; const existingSearchResultIDs = useMemo(() => { @@ -81,15 +80,6 @@ function useSearchHighlightAndScroll({ // Trigger search when a new report action is added while on chat or when a new transaction is added for the other search types. useEffect(() => { - if (!initializedRef.current) { - return; - } - - if (!hasProcessedInitialChangeRef.current) { - hasProcessedInitialChangeRef.current = true; - return; - } - const previousTransactionsIDs = Object.keys(previousTransactions ?? {}); const transactionsIDs = Object.keys(transactions ?? {}); @@ -174,7 +164,6 @@ function useSearchHighlightAndScroll({ highlightedIDs.current = new Set(existingSearchResultIDs); initializedRef.current = true; - hasProcessedInitialChangeRef.current = false; }, [searchResults?.data, isChat, existingSearchResultIDs]); // Detect new items (transactions or report actions) @@ -193,11 +182,13 @@ function useSearchHighlightAndScroll({ return; } - const newReportActionID = newReportActionIDs.at(0) ?? ''; - const newReportActionKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${newReportActionID}`; - - setNewSearchResultKey(newReportActionKey); - highlightedIDs.current.add(newReportActionID); + const newKeys = new Set(); + newReportActionIDs.forEach((id) => { + const newReportActionKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${id}`; + highlightedIDs.current.add(newReportActionKey); + newKeys.add(newReportActionKey); + }); + setNewSearchResultKeys(newKeys); } else { const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults); const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data); @@ -209,26 +200,28 @@ function useSearchHighlightAndScroll({ return; } - const newTransactionID = newTransactionIDs.at(0) ?? ''; - const newTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${newTransactionID}`; - - setNewSearchResultKey(newTransactionKey); - highlightedIDs.current.add(newTransactionID); + const newKeys = new Set(); + newTransactionIDs.forEach((id) => { + const newTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`; + highlightedIDs.current.add(newTransactionKey); + newKeys.add(newTransactionKey); + }); + setNewSearchResultKeys(newKeys); } }, [searchResults?.data, previousSearchResults, isChat]); // Reset newSearchResultKey after it's been used useEffect(() => { - if (newSearchResultKey === null) { + if (newSearchResultKeys === null) { return; } const timer = setTimeout(() => { - setNewSearchResultKey(null); + setNewSearchResultKeys(null); }, CONST.ANIMATED_HIGHLIGHT_START_DURATION); return () => clearTimeout(timer); - }, [newSearchResultKey]); + }, [newSearchResultKeys]); /** * Callback to handle scrolling to the new search result. @@ -237,7 +230,8 @@ function useSearchHighlightAndScroll({ (data: SearchListItem[], ref: SelectionListHandle | null) => { // Early return if there's no ref, new transaction wasn't brought in by this hook // or there's no new search result key - if (!ref || !triggeredByHookRef.current || newSearchResultKey === null) { + const newSearchResultKey = newSearchResultKeys?.values().next().value; + if (!ref || !triggeredByHookRef.current || !newSearchResultKey) { return; } @@ -275,10 +269,10 @@ function useSearchHighlightAndScroll({ // Reset the trigger flag to prevent unintended future scrolls and highlights triggeredByHookRef.current = false; }, - [newSearchResultKey, isChat], + [newSearchResultKeys, isChat], ); - return {newSearchResultKey, handleSelectionListScroll, newTransactions}; + return {newSearchResultKeys, handleSelectionListScroll, newTransactions}; } /** From f2de5646fa96f6c95a949298f1b6f2d49bdb46c8 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:09:31 +0530 Subject: [PATCH 11/16] Merge main --- manual-tests-pr-72034.md | 62 ---------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 manual-tests-pr-72034.md diff --git a/manual-tests-pr-72034.md b/manual-tests-pr-72034.md deleted file mode 100644 index 435321b1e919..000000000000 --- a/manual-tests-pr-72034.md +++ /dev/null @@ -1,62 +0,0 @@ -# Manual Tests Derived from PR #72034 Comments - -Each test below comes directly from feedback left on [PR #72034](https://github.com/Expensify/App/pull/72034) and should be added to the PR description so reviewers can verify the scenarios that were called out during review. - -## 1. Search skeleton alignment on wide layouts -- **Source:** https://github.com/Expensify/App/pull/72034#issuecomment-3407040487 -1. Sign in on desktop web (Chrome) with a workspace admin account. -2. Go to `Reports` and open the Search page. -3. In DevTools, throttle the network to `Slow 3G` and reload so the new LHN skeleton is visible. -4. Observe the placeholder rows while data is still loading. -- **Expected:** Every skeleton row aligns with the eventual menu layout (left padding, height, spacing). No row appears offset or clipped on wide screens. - -## 2. iOS menu update after live workspace invite -- **Source:** https://github.com/Expensify/App/pull/72034#issuecomment-3417680190 -1. Log in to the iOS hybrid app with Account A. -2. From Account B (desktop), invite Account A to an existing workspace with submit permission while Account A stays logged in. -3. After the invite processes, open the reports Search menu on Account A. -4. Tap the newly added `Submit` option. -- **Expected:** The `Submit` row appears without requiring an app restart, and tapping it opens the correct modal instead of a blank/blocked sheet. - -## 3. Highlight resets when a new default option appears -- **Source:** https://github.com/Expensify/App/pull/72034#issuecomment-3417682412 -1. Start as a workspace member (no approve permission) on any platform. -2. Open the Search menu and note that `Submit` is the default highlighted entry. -3. Without reloading, promote this user to an approver from another admin account so the `Approve` entry becomes available. -4. Re-open the Search menu. -- **Expected:** Only one entry is highlighted—the newly inserted default (`Approve`). The previously highlighted option is cleared so the UI does not show two simultaneous highlights. - -## 4. Default logic respects manual overrides vs. automatic defaults -- **Sources:** https://github.com/Expensify/App/pull/72034#issuecomment-3417967886 and https://github.com/Expensify/App/pull/72034#issuecomment-3421359484 -1. As a non-approver, manually select a non-default tab (e.g., `Explore`). -2. From another account, promote this user to an approver while they stay logged in. -3. Open the Search menu before reloading, then log out and back in to test the fresh-login path. -- **Expected:** - - In the existing session, the manually selected tab remains active (we do not override explicit user choice). - - After a fresh login, the default switches to `Approve` automatically if the user now has approval permission and never set a manual preference. - -## 5. Mobile popover while data is still loading -- **Sources:** https://github.com/Expensify/App/pull/72034#issuecomment-3417688714 and https://github.com/Expensify/App/pull/72034#issuecomment-3421366059 -1. On a narrow viewport (iOS/Android hybrid app), throttle the connection to `Slow 3G`. -2. Navigate to `Reports` and immediately open the bottom-docked SearchTypeMenu popover while the LHN skeleton is showing. -- **Expected:** The popover presents a clear loading state (skeleton rows or disabled placeholders) instead of flashing stale data or an empty modal. Capture a video for design if the experience regresses. - -## 6. Slow-connection lag when opening the modal -- **Sources:** https://github.com/Expensify/App/pull/72034#issuecomment-3421454469 and https://github.com/Expensify/App/pull/72034#issuecomment-3421608480 -1. On iOS (hybrid app) set the Network Link Conditioner to a very slow profile. -2. Open the Search popover multiple times while data is fetching. -- **Expected:** The modal waits for data before rendering options, but the app remains responsive (no locks or console errors). Any delay is communicated through the loader. - -## 7. Real-time update when workspace membership changes (removal as well as addition) -- **Source:** https://github.com/Expensify/App/pull/72034#discussion_r2448015214 -1. With the app open on iOS, ensure the user currently has submit/approve access and see the corresponding entries in the menu. -2. From another admin account on desktop, remove the user from the workspace (or revoke the permission). -3. Watch the iOS device without reloading, then open the Search menu. -- **Expected:** The `Submit`/`Approve` entries disappear (or get disabled) in real time and the menu no longer references permissions the user lost. - -## 8. Approve tab only appears after policy data is ready (employee list / exporter / reimburser) -- **Source:** https://github.com/Expensify/App/pull/72034#discussion_r2460924733 -1. Use a workspace that has not finished syncing `employeeList`, `exporter`, or `reimburser` data (e.g., immediately after enabling payments). -2. Log in and open the Search menu before those Onyx keys load. -3. Wait for the policy data to arrive (confirm via logs or Onyx inspector) without reloading. -- **Expected:** The `Approve` tab stays hidden until all required policy data is available, then automatically appears once the data finishes loading. From fc1fb2bdbd2428db02301e193cdcb769fc04ed11 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 12 Nov 2025 00:59:48 +0530 Subject: [PATCH 12/16] Update horizontal margin for the loading UI --- src/pages/Search/SearchTypeMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 722cabac0531..5df36d9014b5 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -228,7 +228,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { showsVerticalScrollIndicator={false} > {shouldShowSuggestedSearchSkeleton ? ( - + ) : ( From fd4ce284a1e47817a7c3f3335ed622e8e9474714 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:58:21 +0530 Subject: [PATCH 13/16] Improve design --- src/pages/Search/SearchTypeMenu.tsx | 32 +++++++++------ src/pages/Search/SuggestedSearchSkeleton.tsx | 42 +++++++++++++++----- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 5df36d9014b5..f19655f054cc 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -1,7 +1,7 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef} from 'react'; -import {View} from 'react-native'; +import {StyleSheet, 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'; @@ -197,16 +197,23 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { }, [createSavedSearchMenuItem, savedSearches]); const renderSavedSearchesSection = useCallback( - (menuItems: MenuItemWithLink[]) => ( - - ), + (menuItems: MenuItemWithLink[]) => { + const menuItemsWithIconSpacing = menuItems.map((menuItem) => ({ + ...menuItem, + iconStyles: [menuItem.iconStyles, styles.mr3], + })); + + return ( + + ); + }, [styles], ); @@ -228,7 +235,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { showsVerticalScrollIndicator={false} > {shouldShowSuggestedSearchSkeleton ? ( - + ) : ( @@ -273,6 +280,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { icon={item.icon} iconWidth={variables.iconSizeNormal} iconHeight={variables.iconSizeNormal} + iconStyles={styles.mr3} wrapperStyle={styles.sectionMenuItem} focused={focused} onPress={onPress} diff --git a/src/pages/Search/SuggestedSearchSkeleton.tsx b/src/pages/Search/SuggestedSearchSkeleton.tsx index e128504b3de4..5072b717a3eb 100644 --- a/src/pages/Search/SuggestedSearchSkeleton.tsx +++ b/src/pages/Search/SuggestedSearchSkeleton.tsx @@ -6,17 +6,43 @@ 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: 18, yVal: 8, w: 16, h: 16, r: 4}, - // label is positioned relative to icon - label: {dx: 30, dy: 4, w: 104, h: 8}, + 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: 0, w: 36, h: 4}, + header: { + xVal: 8, + yVal: (SECTION_HEADER_HEIGHT - SECTION_HEADER_RECT_HEIGHT) / 2, + w: 36, + h: SECTION_HEADER_RECT_HEIGHT, + }, }; function SuggestedSearchSkeleton() { @@ -26,8 +52,6 @@ function SuggestedSearchSkeleton() { const renderNavigationItem = () => { const {icon, label} = LHN; - const labelX = icon.xVal + label.dx; - const labelY = icon.yVal + label.dy; return ( <> @@ -41,7 +65,7 @@ function SuggestedSearchSkeleton() { ); @@ -53,7 +77,7 @@ function SuggestedSearchSkeleton() { <> {navigationColumn} From d907a63890083bd0fe7286a1db244aedc21e9a64 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:15:37 +0530 Subject: [PATCH 14/16] Improve design --- src/pages/Search/SearchTypeMenu.tsx | 30 +++++++++++------------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index f19655f054cc..fe7d85bee133 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -1,7 +1,7 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef} from 'react'; -import {StyleSheet, View} from 'react-native'; +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'; @@ -197,23 +197,16 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { }, [createSavedSearchMenuItem, savedSearches]); const renderSavedSearchesSection = useCallback( - (menuItems: MenuItemWithLink[]) => { - const menuItemsWithIconSpacing = menuItems.map((menuItem) => ({ - ...menuItem, - iconStyles: [menuItem.iconStyles, styles.mr3], - })); - - return ( - - ); - }, + (menuItems: MenuItemWithLink[]) => ( + + ), [styles], ); @@ -280,7 +273,6 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { icon={item.icon} iconWidth={variables.iconSizeNormal} iconHeight={variables.iconSizeNormal} - iconStyles={styles.mr3} wrapperStyle={styles.sectionMenuItem} focused={focused} onPress={onPress} From 9460f85853ddcea3656233a607b1c262b0a7f675 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:19:22 +0530 Subject: [PATCH 15/16] Lint fixes --- src/hooks/useSearchTypeMenu.tsx | 1 + src/pages/Search/SearchTypeMenu.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index f6628c9badd0..0f96e3704c07 100644 --- a/src/hooks/useSearchTypeMenu.tsx +++ b/src/hooks/useSearchTypeMenu.tsx @@ -14,6 +14,7 @@ import {buildSearchQueryJSON, buildUserReadableQueryString} from '@libs/SearchQu import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; +// eslint-disable-next-line no-restricted-imports import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index fe7d85bee133..2ebe49fffe1c 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -30,6 +30,7 @@ import {buildSearchQueryJSON, buildUserReadableQueryString} from '@libs/SearchQu import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; +// eslint-disable-next-line no-restricted-imports import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; From b1ee4a3d69104b1c5827add243679d93598c26ef Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:29:08 +0530 Subject: [PATCH 16/16] Update to remove fade-in animation logic --- src/pages/Search/SearchTypeMenu.tsx | 49 +++++++++-------------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 2ebe49fffe1c..69ea63487644 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'; @@ -71,19 +70,6 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const taxRates = getAllTaxRates(); 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; - } - - initialSearchKeys.current = typeMenuSections.flatMap((section) => { - return section.menuItems.map((item) => item.key); - }); - }, [typeMenuSections]); const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [typeMenuSections]); @@ -259,27 +245,20 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); }); - const isInitialItem = !initialSearchKeys.current.length || initialSearchKeys.current.includes(item.key); - return ( - - - + ); })}