diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index 8646f103ef01..f3739c106841 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -2,7 +2,9 @@ import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import PrevNextButtons from '@components/PrevNextButtons'; +import {useSearchStateContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; +import useFilterPendingDeleteReports from '@hooks/useFilterPendingDeleteReports'; import useOnyx from '@hooks/useOnyx'; import useSearchSections from '@hooks/useSearchSections'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -20,6 +22,12 @@ type MoneyRequestReportNavigationProps = { shouldDisplayNarrowVersion: boolean; }; +type MoneyRequestReportNavigationContentProps = MoneyRequestReportNavigationProps & { + allReports: Array; + isSearchLoading: boolean; + lastSearchQuery: OnyxEntry; +}; + type SnapshotGuard = { hasMultiple: boolean; includesReport: boolean; @@ -71,8 +79,7 @@ const buildSnapshotGuardSelector = return {hasMultiple: count > 1, includesReport}; }; -function MoneyRequestReportNavigationInner({reportID, shouldDisplayNarrowVersion}: MoneyRequestReportNavigationProps) { - const {allReports, isSearchLoading, lastSearchQuery} = useSearchSections(); +function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersion, allReports, isSearchLoading, lastSearchQuery}: MoneyRequestReportNavigationContentProps) { const styles = useThemeStyles(); const liveCurrentIndex = allReports.indexOf(reportID); @@ -192,19 +199,43 @@ function MoneyRequestReportNavigationInner({reportID, shouldDisplayNarrowVersion ); } +// All Onyx subscriptions via useSearchSections. Mounts if there are no sorted report IDs in the context. +function MoneyRequestReportNavigationStandalone({reportID, shouldDisplayNarrowVersion}: MoneyRequestReportNavigationProps) { + const {allReports, isSearchLoading, lastSearchQuery} = useSearchSections(); + + return ( + + ); +} + function MoneyRequestReportNavigation({reportID, shouldDisplayNarrowVersion}: MoneyRequestReportNavigationProps) { + // Guard: only mount inner tree when snapshot confirms multiple expense reports const [isExpenseReportSearch] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, {selector: selectIsExpenseReportSearch}); const [hash] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, {selector: selectQueryHash}); const snapshotGuardSelector = buildSnapshotGuardSelector(reportID); const [snapshotGuard = EMPTY_GUARD] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, {selector: snapshotGuardSelector}); + // Fast-path hooks (always called to satisfy rules of hooks) + const {sortedReportIDs} = useSearchStateContext(); + const [lastSearchQuery] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const searchLoadingSelector = (data: OnyxEntry) => !!data?.search?.isLoading; + const [isSearchLoading = false] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${lastSearchQuery?.queryJSON?.hash}`, { + selector: searchLoadingSelector, + }); + const allReports = useFilterPendingDeleteReports(sortedReportIDs); + const isLiveGuardSatisfied = isExpenseReportSearch && snapshotGuard.hasMultiple && snapshotGuard.includesReport; // Once the live snapshot has satisfied the guard during this mount, keep the inner component // mounted even if the guard later flips false (e.g. the current report is removed from the // snapshot after approving it). The inner component falls back to a cached list so the - // carousel stays visible for continued navigation. setState during render is the React- - // recommended pattern for storing information from previous renders. + // carousel stays visible for continued navigation. const [shouldKeepMounted, setShouldKeepMounted] = useState(false); if (isLiveGuardSatisfied && !shouldKeepMounted) { setShouldKeepMounted(true); @@ -214,8 +245,22 @@ function MoneyRequestReportNavigation({reportID, shouldDisplayNarrowVersion}: Mo return null; } + // Fast path: use pre-computed IDs from context when available and no pagination is in flight. + // During pagination fall back to full subscription so new pages are reflected immediately. + if (allReports.length > 0 && !isSearchLoading) { + return ( + + ); + } + return ( - diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index c3cb96349aa5..2f3a2bc8e7e5 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -55,6 +55,7 @@ const defaultSearchContextData: SearchContextData = { currentSearchHash: -1, currentSimilarSearchHash: -1, suggestedSearches: {} as Record, + sortedReportIDs: CONST.EMPTY_ARRAY, }; const defaultSearchStateContext: SearchStateContextValue = { @@ -77,6 +78,7 @@ const defaultSearchActionsContext: SearchActionsContextValue = { setShouldShowSelectAllMatchingItems: () => {}, selectAllMatchingItems: () => {}, setShouldResetSearchQuery: () => {}, + setSortedReportIDs: () => {}, }; const SearchStateContext = React.createContext(defaultSearchStateContext); @@ -316,6 +318,15 @@ function SearchContextProvider({children}: SearchContextProps) { })); }; + const setSortedReportIDs = (newIDs: ReadonlyArray) => { + setSearchContextData((prev) => { + // ensure that we don't save the same report IDs unless they are really different + const hasChanged = prev.sortedReportIDs.length !== newIDs.length || prev.sortedReportIDs.some((id, i) => id !== newIDs.at(i)); + + return hasChanged ? {...prev, sortedReportIDs: newIDs} : prev; + }); + }; + const searchStateContextValue: SearchStateContextValue = { ...searchContextData, suggestedSearches, @@ -342,6 +353,7 @@ function SearchContextProvider({children}: SearchContextProps) { setShouldShowSelectAllMatchingItems, selectAllMatchingItems, setShouldResetSearchQuery, + setSortedReportIDs, }; return ( diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index db66d401a73e..fefeffb47585 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -24,6 +24,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; import useReportAttributes from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useStableArrayReference from '@hooks/useStableArrayReference'; @@ -1286,6 +1287,8 @@ function Search({ [type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, isChat, newSearchResultKeys, hash], ); + useSaveSortedReportIDs(type, sortedData); + const {stableSortedData, hasCachedOptimisticItem} = useStableOptimisticSortedData(sortedData, searchResults, optimisticTrackingState); useEffect(() => { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index b402cfd6ab0a..5687cac135a2 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -180,6 +180,7 @@ type SearchContextData = { isOnSearch: boolean; shouldTurnOffSelectionMode: boolean; shouldResetSearchQuery: boolean; + sortedReportIDs: ReadonlyArray; /** True when at least one transaction is selected. */ hasSelectedTransactions: boolean; }; @@ -212,6 +213,7 @@ type SearchActionsContextValue = { setShouldShowSelectAllMatchingItems: (shouldShow: boolean) => void; selectAllMatchingItems: (on: boolean) => void; setShouldResetSearchQuery: (shouldReset: boolean) => void; + setSortedReportIDs: (ids: ReadonlyArray) => void; }; type ASTNode = { diff --git a/src/hooks/useFilterPendingDeleteReports.ts b/src/hooks/useFilterPendingDeleteReports.ts new file mode 100644 index 000000000000..fa457f622466 --- /dev/null +++ b/src/hooks/useFilterPendingDeleteReports.ts @@ -0,0 +1,39 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import {isReportPendingDelete} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import useOnyx from './useOnyx'; + +/** + * Returns sorted keys of reports pending deletion. + * Sorted string[] keeps Onyx comparison cheap (PERF-11). + */ +const selectPendingDeleteReportKeys = (reports: OnyxCollection): string[] => { + const keys: string[] = []; + for (const [key, report] of Object.entries(reports ?? {})) { + if (isReportPendingDelete(report)) { + keys.push(key); + } + } + return keys.sort(); +}; + +/** + * Filters out report IDs whose corresponding reports have a pending DELETE action. + * Subscribes to the REPORT collection with a lightweight selector to minimize re-renders. + */ +function useFilterPendingDeleteReports(ids: ReadonlyArray): Array { + const [pendingDeleteReportKeys = CONST.EMPTY_ARRAY] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: selectPendingDeleteReportKeys}); + const pendingDeleteReportKeysSet = new Set(pendingDeleteReportKeys); + + return ids.filter((id) => { + if (!id) { + return false; + } + return !pendingDeleteReportKeysSet.has(`${ONYXKEYS.COLLECTION.REPORT}${id}`); + }); +} + +export {selectPendingDeleteReportKeys}; +export default useFilterPendingDeleteReports; diff --git a/src/hooks/useSaveSortedReportIDs.ts b/src/hooks/useSaveSortedReportIDs.ts new file mode 100644 index 000000000000..17626ed0d327 --- /dev/null +++ b/src/hooks/useSaveSortedReportIDs.ts @@ -0,0 +1,30 @@ +import {useEffect} from 'react'; +import {useSearchActionsContext} from '@components/Search/SearchContext'; +import CONST from '@src/CONST'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; + +/** + * Persists sorted report IDs to Search context so that MoneyRequestReportNavigation + * can read them without recomputing getSortedSections. + * Only stores IDs for expense-report searches; clears the context for all other types + * to force the fallback computation in the navigation header. + */ +function useSaveSortedReportIDs(type: SearchDataTypes, items: Array<{reportID?: string | undefined}>) { + const {setSortedReportIDs} = useSearchActionsContext(); + + useEffect(() => { + // Only expense-report searches produce report-level IDs suitable for navigation arrows. + // For all other types (expense, invoice, etc.) the items are transaction-level and share + // reportIDs, so we clear the context to force the fallback computation in navigation. + if (type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + setSortedReportIDs([]); + return; + } + + const reportIDs = items.map((item) => item.reportID); + + setSortedReportIDs(reportIDs); + }, [type, items, setSortedReportIDs]); +} + +export default useSaveSortedReportIDs; diff --git a/src/hooks/useSearchSections.ts b/src/hooks/useSearchSections.ts index 31cb3eca0a35..7c1f1d012377 100644 --- a/src/hooks/useSearchSections.ts +++ b/src/hooks/useSearchSections.ts @@ -1,32 +1,17 @@ -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {isReportPendingDelete, selectFilteredReportActions} from '@libs/ReportUtils'; +import type {OnyxEntry} from 'react-native-onyx'; +import {selectFilteredReportActions} from '@libs/ReportUtils'; import {getSections, getSortedSections} from '@libs/SearchUIUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; import type LastSearchParams from '@src/types/onyx/ReportNavigation'; import useActionLoadingReportIDs from './useActionLoadingReportIDs'; import useArchivedReportsIdSet from './useArchivedReportsIdSet'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useFilterPendingDeleteReports, {selectPendingDeleteReportKeys} from './useFilterPendingDeleteReports'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import useReportAttributes from './useReportAttributes'; -/** - * Returns sorted keys of reports pending deletion. - * Sorted string[] keeps Onyx comparison cheap (PERF-11). - */ -const selectPendingDeleteReportKeys = (reports: OnyxCollection): string[] => { - const keys: string[] = []; - for (const [key, report] of Object.entries(reports ?? {})) { - if (isReportPendingDelete(report)) { - keys.push(key); - } - } - return keys.sort(); -}; - type UseSearchSectionsResult = { allReports: Array; isSearchLoading: boolean; @@ -36,8 +21,6 @@ type UseSearchSectionsResult = { function useSearchSections(): UseSearchSectionsResult { const [lastSearchQuery] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${lastSearchQuery?.queryJSON?.hash}`); - const [pendingDeleteReportKeys = CONST.EMPTY_ARRAY] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: selectPendingDeleteReportKeys}); - const pendingDeleteReportKeysSet = new Set(pendingDeleteReportKeys); const currentUserDetails = useCurrentUserPersonalDetails(); const {localeCompare, formatPhoneNumber, translate} = useLocalize(); const isActionLoadingSet = useActionLoadingReportIDs(); @@ -86,14 +69,7 @@ function useSearchSections(): UseSearchSectionsResult { results = getSortedSections(type, status ?? '', searchData, localeCompare, translate, sortBy, sortOrder, groupBy).map((value) => value.reportID); } - const allReports = results.filter((id) => { - if (!id) { - return false; - } - return !pendingDeleteReportKeysSet.has(`${ONYXKEYS.COLLECTION.REPORT}${id}`); - }); - - return {allReports, isSearchLoading: !!currentSearchResults?.search?.isLoading, lastSearchQuery}; + return {allReports: useFilterPendingDeleteReports(results), isSearchLoading: !!currentSearchResults?.search?.isLoading, lastSearchQuery}; } export {selectPendingDeleteReportKeys}; diff --git a/tests/ui/CategoryListItemHeaderTest.tsx b/tests/ui/CategoryListItemHeaderTest.tsx index 6931285b8051..355e82517a0b 100644 --- a/tests/ui/CategoryListItemHeaderTest.tsx +++ b/tests/ui/CategoryListItemHeaderTest.tsx @@ -41,6 +41,7 @@ const mockSearchStateContext = { shouldUseLiveData: false, currentSimilarSearchHash: -1, suggestedSearches: {} as SearchStateContextValue['suggestedSearches'], + sortedReportIDs: [], hasSelectedTransactions: false, } satisfies SearchStateContextValue; @@ -54,6 +55,7 @@ const mockSearchActionsContext = { setShouldShowSelectAllMatchingItems: jest.fn(), selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), + setSortedReportIDs: jest.fn(), } satisfies SearchActionsContextValue; const createCategoryListItem = (category: string, options: Partial = {}): TransactionCategoryGroupListItemType => ({ diff --git a/tests/ui/MerchantListItemHeaderTest.tsx b/tests/ui/MerchantListItemHeaderTest.tsx index b7ac8ee1c4f9..6e8bb26f4f4c 100644 --- a/tests/ui/MerchantListItemHeaderTest.tsx +++ b/tests/ui/MerchantListItemHeaderTest.tsx @@ -41,6 +41,7 @@ const mockSearchStateContext = { shouldUseLiveData: false, currentSimilarSearchHash: -1, suggestedSearches: {} as SearchStateContextValue['suggestedSearches'], + sortedReportIDs: [], hasSelectedTransactions: false, } satisfies SearchStateContextValue; @@ -54,6 +55,7 @@ const mockSearchActionsContext = { setShouldShowSelectAllMatchingItems: jest.fn(), selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), + setSortedReportIDs: jest.fn(), } satisfies SearchActionsContextValue; const createMerchantListItem = (merchant: string, options: Partial = {}): TransactionMerchantGroupListItemType => ({ diff --git a/tests/ui/MonthListItemHeaderTest.tsx b/tests/ui/MonthListItemHeaderTest.tsx index 9f5fda8c521a..84bdae31886a 100644 --- a/tests/ui/MonthListItemHeaderTest.tsx +++ b/tests/ui/MonthListItemHeaderTest.tsx @@ -41,6 +41,7 @@ const mockSearchStateContext = { shouldUseLiveData: false, currentSimilarSearchHash: -1, suggestedSearches: {} as SearchStateContextValue['suggestedSearches'], + sortedReportIDs: [], hasSelectedTransactions: false, } satisfies SearchStateContextValue; @@ -54,6 +55,7 @@ const mockSearchActionsContext = { setShouldShowSelectAllMatchingItems: jest.fn(), selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), + setSortedReportIDs: jest.fn(), } satisfies SearchActionsContextValue; const createMonthListItem = (year: number, month: number, options: Partial = {}): TransactionMonthGroupListItemType => ({ diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx index d87789e1428f..7447eb0cb750 100644 --- a/tests/ui/WeekListItemHeaderTest.tsx +++ b/tests/ui/WeekListItemHeaderTest.tsx @@ -40,6 +40,7 @@ const mockSearchStateContext = { shouldUseLiveData: false, currentSimilarSearchHash: -1, suggestedSearches: {} as SearchStateContextValue['suggestedSearches'], + sortedReportIDs: [], hasSelectedTransactions: false, } satisfies SearchStateContextValue; @@ -53,6 +54,7 @@ const mockSearchActionsContext = { setShouldShowSelectAllMatchingItems: jest.fn(), selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), + setSortedReportIDs: jest.fn(), } satisfies SearchActionsContextValue; const createWeekListItem = (week: string, options: Partial = {}): TransactionWeekGroupListItemType => ({ diff --git a/tests/ui/YearListItemHeaderTest.tsx b/tests/ui/YearListItemHeaderTest.tsx index 5483298246c0..f20ee9ee12b4 100644 --- a/tests/ui/YearListItemHeaderTest.tsx +++ b/tests/ui/YearListItemHeaderTest.tsx @@ -41,6 +41,7 @@ const mockSearchStateContext = { shouldUseLiveData: false, currentSimilarSearchHash: -1, suggestedSearches: {} as SearchStateContextValue['suggestedSearches'], + sortedReportIDs: [], hasSelectedTransactions: false, } satisfies SearchStateContextValue; @@ -54,6 +55,7 @@ const mockSearchActionsContext = { setShouldShowSelectAllMatchingItems: jest.fn(), selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), + setSortedReportIDs: jest.fn(), } satisfies SearchActionsContextValue; const createYearListItem = (year: number, options: Partial = {}): TransactionYearGroupListItemType => ({ diff --git a/tests/unit/components/MoneyRequestReportNavigation.test.tsx b/tests/unit/components/MoneyRequestReportNavigation.test.tsx new file mode 100644 index 000000000000..1b2475d95681 --- /dev/null +++ b/tests/unit/components/MoneyRequestReportNavigation.test.tsx @@ -0,0 +1,104 @@ +import {renderHook} from '@testing-library/react-native'; +import {useSearchStateContext} from '@components/Search/SearchContext'; +import useFilterPendingDeleteReports from '@hooks/useFilterPendingDeleteReports'; +import CONST from '@src/CONST'; + +/** + * These tests verify the routing logic of MoneyRequestReportNavigation + * without rendering the full component tree. + * They do so by testing the hooks that drive the two paths. + */ + +let mockSortedReportIDs: ReadonlyArray = CONST.EMPTY_ARRAY; + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchStateContext: () => ({sortedReportIDs: mockSortedReportIDs}), +})); + +const mockUseOnyx = jest.fn(); +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + default: (...args: unknown[]) => mockUseOnyx(...args), +})); + +const mockUseSearchSections = jest.fn(); +jest.mock('@hooks/useSearchSections', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + default: () => mockUseSearchSections(), +})); + +jest.mock('@hooks/useFilterPendingDeleteReports', () => ({ + __esModule: true, + default: (ids: ReadonlyArray) => ids.filter(Boolean), +})); + +describe('MoneyRequestReportNavigation', () => { + beforeEach(() => { + mockSortedReportIDs = CONST.EMPTY_ARRAY; + mockUseOnyx.mockReturnValue([undefined]); + mockUseSearchSections.mockClear(); + mockUseSearchSections.mockReturnValue({allReports: [], isSearchLoading: false, lastSearchQuery: undefined}); + }); + + describe('fast path', () => { + it('does not call useSearchSections when context has IDs and search is not loading', () => { + mockSortedReportIDs = ['1', '2', '3']; + mockUseOnyx.mockReturnValue([undefined]); + + // Simulate what the wrapper does: reads context + filter + const {result} = renderHook(() => { + const {sortedReportIDs} = useSearchStateContext(); + const allReports = useFilterPendingDeleteReports(sortedReportIDs); + return {sortedReportIDs, allReports}; + }); + + expect(result.current.allReports).toEqual(['1', '2', '3']); + expect(mockUseSearchSections).not.toHaveBeenCalled(); + }); + }); + + describe('path selection', () => { + it('uses fast path when context has IDs and not loading', () => { + mockSortedReportIDs = ['1', '2']; + // isSearchLoading = false (useOnyx returns undefined for snapshot) + mockUseOnyx.mockReturnValue([undefined]); + + const {result} = renderHook(() => { + const {sortedReportIDs} = useSearchStateContext(); + const allReports = useFilterPendingDeleteReports(sortedReportIDs); + const isSearchLoading = false; + return allReports.length > 0 && !isSearchLoading ? 'fast' : 'full'; + }); + + expect(result.current).toBe('fast'); + expect(mockUseSearchSections).not.toHaveBeenCalled(); + }); + + it('uses full path when context is empty', () => { + mockSortedReportIDs = CONST.EMPTY_ARRAY; + + const {result} = renderHook(() => { + const {sortedReportIDs} = useSearchStateContext(); + const allReports = useFilterPendingDeleteReports(sortedReportIDs); + return allReports.length > 0 ? 'fast' : 'full'; + }); + + expect(result.current).toBe('full'); + }); + + it('uses full path when search is loading (pagination)', () => { + mockSortedReportIDs = ['1', '2']; + const isSearchLoading = true; + + const {result} = renderHook(() => { + const {sortedReportIDs} = useSearchStateContext(); + const allReports = useFilterPendingDeleteReports(sortedReportIDs); + return allReports.length > 0 && !isSearchLoading ? 'fast' : 'full'; + }); + + expect(result.current).toBe('full'); + }); + }); +}); diff --git a/tests/unit/hooks/useFilterPendingDeleteReports.test.ts b/tests/unit/hooks/useFilterPendingDeleteReports.test.ts new file mode 100644 index 000000000000..4b9e78a49528 --- /dev/null +++ b/tests/unit/hooks/useFilterPendingDeleteReports.test.ts @@ -0,0 +1,104 @@ +import {renderHook} from '@testing-library/react-native'; +import type {OnyxCollection} from 'react-native-onyx'; +import useFilterPendingDeleteReports, {selectPendingDeleteReportKeys} from '@hooks/useFilterPendingDeleteReports'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +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), +})); + +describe('useFilterPendingDeleteReports', () => { + beforeEach(() => { + for (const key of Object.keys(onyxData)) { + delete onyxData[key]; + } + mockUseOnyx.mockClear(); + }); + + describe('selectPendingDeleteReportKeys', () => { + it('returns empty array for null/undefined collection', () => { + expect(selectPendingDeleteReportKeys(null as unknown as OnyxCollection)).toEqual([]); + expect(selectPendingDeleteReportKeys(undefined as unknown as OnyxCollection)).toEqual([]); + }); + + it('returns empty array when no reports are pending delete', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1'} as Report, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2'} as Report, + }; + expect(selectPendingDeleteReportKeys(reports)).toEqual([]); + }); + + it('returns keys of reports with pendingAction DELETE', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} as Report, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2'} as Report, + }; + expect(selectPendingDeleteReportKeys(reports)).toEqual([`${ONYXKEYS.COLLECTION.REPORT}1`]); + }); + + it('returns keys of reports with pendingFields.preview DELETE', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1'} as Report, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2', pendingFields: {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}} as Report, + }; + expect(selectPendingDeleteReportKeys(reports)).toEqual([`${ONYXKEYS.COLLECTION.REPORT}2`]); + }); + + it('returns sorted keys when multiple reports are pending delete', () => { + const reports: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}3`]: {reportID: '3', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} as Report, + [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1', pendingFields: {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}} as Report, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2'} as Report, + }; + expect(selectPendingDeleteReportKeys(reports)).toEqual([`${ONYXKEYS.COLLECTION.REPORT}1`, `${ONYXKEYS.COLLECTION.REPORT}3`]); + }); + }); + + describe('useFilterPendingDeleteReports', () => { + it('returns all IDs when no reports are pending delete', () => { + const {result} = renderHook(() => useFilterPendingDeleteReports(['1', '2', '3'])); + expect(result.current).toEqual(['1', '2', '3']); + }); + + it('filters out IDs of reports with pending delete', () => { + onyxData[ONYXKEYS.COLLECTION.REPORT] = { + [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} as Report, + }; + + const {result} = renderHook(() => useFilterPendingDeleteReports(['1', '2', '3'])); + expect(result.current).toEqual(['1', '3']); + }); + + it('filters out undefined entries', () => { + const {result} = renderHook(() => useFilterPendingDeleteReports(['1', undefined, '3'])); + expect(result.current).toEqual(['1', '3']); + }); + + it('returns empty array when all reports are pending delete', () => { + onyxData[ONYXKEYS.COLLECTION.REPORT] = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} as Report, + [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} as Report, + }; + + const {result} = renderHook(() => useFilterPendingDeleteReports(['1', '2'])); + expect(result.current).toEqual([]); + }); + + it('returns empty array for empty input', () => { + const {result} = renderHook(() => useFilterPendingDeleteReports([])); + expect(result.current).toEqual([]); + }); + }); +}); diff --git a/tests/unit/hooks/useSaveSortedReportIDs.test.ts b/tests/unit/hooks/useSaveSortedReportIDs.test.ts new file mode 100644 index 000000000000..46f0b72744c1 --- /dev/null +++ b/tests/unit/hooks/useSaveSortedReportIDs.test.ts @@ -0,0 +1,42 @@ +import {renderHook} from '@testing-library/react-native'; +import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; +import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; +import CONST from '@src/CONST'; + +const mockSetSortedReportIDs = jest.fn(); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchActionsContext: () => ({setSortedReportIDs: mockSetSortedReportIDs}), +})); + +describe('useSaveSortedReportIDs', () => { + beforeEach(() => { + mockSetSortedReportIDs.mockClear(); + }); + + describe('saving report IDs to context', () => { + it('stores empty array in context when sorted list is empty', () => { + renderHook(() => useSaveSortedReportIDs(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, [])); + + expect(mockSetSortedReportIDs).toHaveBeenCalledWith([]); + }); + + it('stores only the provided report IDs in context', () => { + renderHook(() => useSaveSortedReportIDs(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, [{reportID: '1'}, {reportID: '3'}] as TransactionListItemType[])); + + expect(mockSetSortedReportIDs).toHaveBeenCalledWith(['1', '3']); + }); + + it('stores all IDs in context', () => { + renderHook(() => useSaveSortedReportIDs(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, [{reportID: '1'}, {reportID: '2'}, {reportID: '3'}] as TransactionListItemType[])); + + expect(mockSetSortedReportIDs).toHaveBeenCalledWith(['1', '2', '3']); + }); + + it('clears context when type is not expense-report', () => { + renderHook(() => useSaveSortedReportIDs(CONST.SEARCH.DATA_TYPES.EXPENSE, [{reportID: '1'}, {reportID: '2'}] as TransactionListItemType[])); + + expect(mockSetSortedReportIDs).toHaveBeenCalledWith([]); + }); + }); +}); diff --git a/tests/unit/hooks/useSearchSections.test.ts b/tests/unit/hooks/useSearchSections.test.ts index f0a1ce8831a4..0b669b05e9ee 100644 --- a/tests/unit/hooks/useSearchSections.test.ts +++ b/tests/unit/hooks/useSearchSections.test.ts @@ -1,35 +1,21 @@ import {renderHook} from '@testing-library/react-native'; -import type {OnyxCollection} from 'react-native-onyx'; -import useSearchSections, {selectPendingDeleteReportKeys} from '@hooks/useSearchSections'; +import useSearchSections from '@hooks/useSearchSections'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; 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]; - }, -); +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('@hooks/useCurrentUserPersonalDetails', () => ({ - __esModule: true, - default: () => ({accountID: 1, email: 'test@test.com'}), -})); - jest.mock('@hooks/useLocalize', () => ({ __esModule: true, default: () => ({ @@ -39,6 +25,11 @@ jest.mock('@hooks/useLocalize', () => ({ }), })); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: 1, email: 'test@test.com'}), +})); + jest.mock('@hooks/useActionLoadingReportIDs', () => ({ __esModule: true, default: () => new Set(), @@ -49,12 +40,13 @@ jest.mock('@hooks/useArchivedReportsIdSet', () => ({ default: () => new Set(), })); -// Mock getSections/getSortedSections to return controlled report IDs const mockGetSortedSections = jest.fn(); +const mockGetSections = jest.fn(); jest.mock('@libs/SearchUIUtils', () => ({ - getSections: () => [[], {}], // eslint-disable-next-line @typescript-eslint/no-unsafe-return getSortedSections: (...args: unknown[]) => mockGetSortedSections(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + getSections: (...args: unknown[]) => mockGetSections(...args), })); describe('useSearchSections', () => { @@ -65,119 +57,57 @@ describe('useSearchSections', () => { mockUseOnyx.mockClear(); mockGetSortedSections.mockReset(); mockGetSortedSections.mockReturnValue([]); + mockGetSections.mockReset(); + mockGetSections.mockReturnValue([[]]); }); - describe('selectPendingDeleteReportKeys', () => { - it('returns empty array for null/undefined collection', () => { - expect(selectPendingDeleteReportKeys(null as unknown as OnyxCollection)).toEqual([]); - expect(selectPendingDeleteReportKeys(undefined as unknown as OnyxCollection)).toEqual([]); - }); - - it('returns empty array when no reports are pending delete', () => { - const reports: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1'} as Report, - [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2'} as Report, + describe('report ID computation', () => { + it('calls getSortedSections and returns results', () => { + mockGetSortedSections.mockReturnValue([{reportID: '1'}, {reportID: '2'}]); + mockGetSections.mockReturnValue([[{reportID: '1'}, {reportID: '2'}]]); + onyxData[ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY] = { + queryJSON: {hash: '123', type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, status: CONST.SEARCH.STATUS.EXPENSE.ALL}, }; - expect(selectPendingDeleteReportKeys(reports)).toEqual([]); - }); - - it('returns keys of reports with pendingAction DELETE', () => { - const reports: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} as Report, - [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2'} as Report, + onyxData[`${ONYXKEYS.COLLECTION.SNAPSHOT}123`] = { + data: {reports: {}}, + search: {isLoading: false}, }; - expect(selectPendingDeleteReportKeys(reports)).toEqual([`${ONYXKEYS.COLLECTION.REPORT}1`]); - }); - it('returns keys of reports with pendingFields.preview DELETE', () => { - const reports: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1'} as Report, - [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2', pendingFields: {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}} as Report, - }; - expect(selectPendingDeleteReportKeys(reports)).toEqual([`${ONYXKEYS.COLLECTION.REPORT}2`]); - }); - - it('returns sorted keys when multiple reports are pending delete', () => { - const reports: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}3`]: {reportID: '3', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} as Report, - [`${ONYXKEYS.COLLECTION.REPORT}1`]: {reportID: '1', pendingFields: {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}} as Report, - [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2'} as Report, - }; - const result = selectPendingDeleteReportKeys(reports); - expect(result).toEqual([`${ONYXKEYS.COLLECTION.REPORT}1`, `${ONYXKEYS.COLLECTION.REPORT}3`]); - }); - }); - - describe('pending delete filtering', () => { - it('returns empty allReports when no search query exists', () => { const {result} = renderHook(() => useSearchSections()); - expect(result.current.allReports).toEqual([]); + expect(mockGetSortedSections).toHaveBeenCalled(); + expect(result.current.allReports).toEqual(['1', '2']); }); - it('filters out pending delete reports from allReports', () => { - const pendingDeleteKeys = [`${ONYXKEYS.COLLECTION.REPORT}2`]; - const snapshotHash = 12345; - + it('filters out pending delete reports from results', () => { mockGetSortedSections.mockReturnValue([{reportID: '1'}, {reportID: '2'}, {reportID: '3'}]); - - mockUseOnyx.mockImplementation( - ( - key: string, - options?: { - selector?: (value: unknown) => unknown; - }, - ) => { - if (key === ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY) { - return [{queryJSON: {type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, hash: snapshotHash}}]; - } - if (key === `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}`) { - return [{data: {}, search: {isLoading: false}}]; - } - if (key === ONYXKEYS.COLLECTION.REPORT) { - return [pendingDeleteKeys]; - } - const value = onyxData[key]; - const selectedValue = options?.selector ? options.selector(value as never) : value; - return [selectedValue]; - }, - ); + mockGetSections.mockReturnValue([[{reportID: '1'}, {reportID: '2'}, {reportID: '3'}]]); + onyxData[ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY] = { + queryJSON: {hash: '123', type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, status: CONST.SEARCH.STATUS.EXPENSE.ALL}, + }; + onyxData[`${ONYXKEYS.COLLECTION.SNAPSHOT}123`] = { + data: {reports: {}}, + search: {isLoading: false}, + }; + onyxData[ONYXKEYS.COLLECTION.REPORT] = { + [`${ONYXKEYS.COLLECTION.REPORT}2`]: {reportID: '2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} as unknown as Report, + }; const {result} = renderHook(() => useSearchSections()); expect(result.current.allReports).toEqual(['1', '3']); }); - it('returns all reports when none are pending delete', () => { - const snapshotHash = 12345; - - mockGetSortedSections.mockReturnValue([{reportID: '1'}, {reportID: '2'}, {reportID: '3'}]); - - mockUseOnyx.mockImplementation( - ( - key: string, - options?: { - selector?: (value: unknown) => unknown; - }, - ) => { - if (key === ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY) { - return [{queryJSON: {type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, hash: snapshotHash}}]; - } - if (key === `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}`) { - return [{data: {}, search: {isLoading: false}}]; - } - if (key === ONYXKEYS.COLLECTION.REPORT) { - return [[]]; - } - const value = onyxData[key]; - const selectedValue = options?.selector ? options.selector(value as never) : value; - return [selectedValue]; - }, - ); + it('returns empty allReports when search results are not yet loaded', () => { + onyxData[ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY] = { + queryJSON: {hash: '123', type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, status: CONST.SEARCH.STATUS.EXPENSE.ALL}, + }; + // No snapshot data — simulates deep-link before search has run const {result} = renderHook(() => useSearchSections()); - expect(result.current.allReports).toEqual(['1', '2', '3']); + expect(mockGetSortedSections).not.toHaveBeenCalled(); + expect(result.current.allReports).toEqual([]); }); }); });