diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 932f5f5d0af9..787cbcf90191 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -25,8 +25,8 @@ import useReportIsArchived from './useReportIsArchived'; /** * Single read/derive pipeline for the report-actions list: the subscriptions and derivations the skeleton * decision and the list render both need, computed once. Side-effect-free (no writes/navigation/telemetry). - * The guard calls it once and passes `state`/`actions` via `ReportActionsListStateContext` and - * `ReportActionsListActionsContext` so the content doesn't re-subscribe. + * The guard publishes actions directly and gives the source state to `ReportActionsListStateProvider` + * to prepare the actions that content renders. */ function useReportActionsListModel(reportID: string, isReportLoadPending: boolean) { const {isOffline} = useNetworkWithOfflineStatus(); @@ -133,7 +133,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea hasUnreadConciergeMessageFromOthers, }; - // The render state slice on `ReportActionsListStateContext`; this is what drives list re-renders. + // Source state for `ReportActionsListStateProvider`; presentation filtering does not affect readiness. // Ambient state (network, route, archived, concierge chat history) is read locally in the content instead // of carried here. const state = { @@ -170,8 +170,8 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea type ReportActionsListModel = ReturnType; type ReportActionsReadinessSignals = ReportActionsListModel['readinessSignals']; -type ReportActionsListState = ReportActionsListModel['state']; +type ReportActionsListSourceState = ReportActionsListModel['state']; type ReportActionsListActions = ReportActionsListModel['actions']; export default useReportActionsListModel; -export type {ReportActionsReadinessSignals, ReportActionsListState, ReportActionsListActions}; +export type {ReportActionsReadinessSignals, ReportActionsListSourceState, ReportActionsListActions}; diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index 6b11439089c2..c0fe658919ea 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -17,7 +17,6 @@ import useUnreadMarker from '@hooks/useUnreadMarker'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import {shouldHidePayAction} from '@libs/ReportActionsUtils'; import {isArchivedReport} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; @@ -42,7 +41,6 @@ import React, {useState} from 'react'; import type {ReportActionsListProps} from './types'; -import filterHiddenSupportTicketSurveys from './filterHiddenSupportTicketSurveys'; import ReportActionsListFloatingMessageCounter from './ReportActionsListFloatingMessageCounter'; import useReportActionsListData from './useReportActionsListData'; import useReportActionsListRenderItem from './useReportActionsListRenderItem'; @@ -65,20 +63,13 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) hasNewerActions, oldestUnreadReportAction, transactionThreadReport, - sortedAllReportActions, - sortedReportActions: unfilteredReportActions, - sortedVisibleReportActions: unfilteredVisibleReportActions, + sortedReportActions, + sortedVisibleReportActions, isConciergeHiddenHistory, shouldShowConciergeWelcome, isConciergeHistoryExpanded, } = useReportActionsListState(); - // Remove rows hidden by the renderer before calculating summary counts, headers, and scroll positions. - const paymentFilteredReportActions = report?.isWaitingOnBankAccount ? unfilteredReportActions.filter((action) => !shouldHidePayAction(action, true)) : unfilteredReportActions; - const paymentFilteredVisibleReportActions = report?.isWaitingOnBankAccount - ? unfilteredVisibleReportActions.filter((action) => !shouldHidePayAction(action, true)) - : unfilteredVisibleReportActions; - const {handleShowPreviousMessages: onShowPreviousMessages} = useReportActionsListActions(); const {isOffline} = useNetwork(); @@ -95,19 +86,6 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) useRetireMerchantRuleSuggestionOnLeave(reportID); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); - // Hidden surveys must not become unread targets that have no rendered row. - const sortedReportActions = filterHiddenSupportTicketSurveys({ - reportActions: paymentFilteredReportActions, - sortedAllReportActions, - report, - reportNameValuePairs, - }); - const sortedVisibleReportActions = filterHiddenSupportTicketSurveys({ - reportActions: paymentFilteredVisibleReportActions, - sortedAllReportActions, - report, - reportNameValuePairs, - }); const isReportArchived = !!isArchivedReport(reportNameValuePairs); const reportAttributesSelector = (value: OnyxEntry) => { @@ -158,8 +136,6 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) const {listID, listProps, shouldBeAlignedToTop, isFloatingMessageCounterVisible, isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget} = useReportActionsListScroll({ reportID, - // Hidden surveys still prove that the selected page contains the newest action. - sortedVisibleReportActions: paymentFilteredVisibleReportActions, renderedVisibleReportActions: listData, reportActionIDToDisplayIndex, hasScrolledOverThreshold, diff --git a/src/pages/inbox/report/ReportActionsList/types.ts b/src/pages/inbox/report/ReportActionsList/types.ts index c77e6863c03b..6fe258777a81 100644 --- a/src/pages/inbox/report/ReportActionsList/types.ts +++ b/src/pages/inbox/report/ReportActionsList/types.ts @@ -26,7 +26,6 @@ type UseReportActionsListDataParams = { type UseReportActionsListScrollParams = Pick< Parameters[0], | 'reportID' - | 'sortedVisibleReportActions' | 'renderedVisibleReportActions' | 'hasScrolledOverThreshold' | 'markNewestActionAsRead' diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts index e4de34fece9d..e7a146f8dc8b 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -36,7 +36,6 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { /** Owns initial positioning, pagination, unread tracking, and scroll events for the report-actions list. */ function useReportActionsListScroll({ reportID, - sortedVisibleReportActions, renderedVisibleReportActions: listData, reportActionIDToDisplayIndex, hasScrolledOverThreshold, @@ -56,6 +55,7 @@ function useReportActionsListScroll({ report, hasOnceLoadedReportActions, hasNewerActions, + scrollReportActions, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, @@ -126,7 +126,7 @@ function useReportActionsListScroll({ report, transactionThreadReport, parentReportAction, - sortedVisibleReportActions, + sortedVisibleReportActions: scrollReportActions, renderedVisibleReportActions: listData, reportActionIDToDisplayIndex, keyExtractor, diff --git a/src/pages/inbox/report/ReportActionsListContext.tsx b/src/pages/inbox/report/ReportActionsListContext.tsx index c29fb76a9049..5a68ec179b8c 100644 --- a/src/pages/inbox/report/ReportActionsListContext.tsx +++ b/src/pages/inbox/report/ReportActionsListContext.tsx @@ -1,11 +1,17 @@ -import type {ReportActionsListActions, ReportActionsListState, ReportActionsReadinessSignals} from '@hooks/useReportActionsListModel'; +import type {ReportActionsListActions, ReportActionsListSourceState, ReportActionsReadinessSignals} from '@hooks/useReportActionsListModel'; import {isUnread} from '@libs/ReportUtils'; +import type {ReportAction} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {createContext, useContext} from 'react'; +type ReportActionsListState = ReportActionsListSourceState & { + /** Selected page before survey filtering, so hidden surveys still establish scroll freshness. */ + scrollReportActions: ReportAction[]; +}; + /** * Carries the render `state` slice from the guard to `ReportActionsList` so the list renders from the same * derivations as the skeleton decision without re-subscribing. @@ -116,3 +122,4 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin } export {ReportActionsListStateContext, ReportActionsListActionsContext, useReportActionsListState, useReportActionsListActions, computeReportActionsSkeletonState}; +export type {ReportActionsListState}; diff --git a/src/pages/inbox/report/ReportActionsListStateProvider.tsx b/src/pages/inbox/report/ReportActionsListStateProvider.tsx new file mode 100644 index 000000000000..cee22c5184f0 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsListStateProvider.tsx @@ -0,0 +1,57 @@ +/** Prepare visible actions without rerunning pagination when survey metadata changes. */ +import useOnyx from '@hooks/useOnyx'; +import type {ReportActionsListSourceState} from '@hooks/useReportActionsListModel'; + +import {shouldHidePayAction} from '@libs/ReportActionsUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportNameValuePairs} from '@src/types/onyx'; + +import type {ReactNode} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; + +import filterHiddenSupportTicketSurveys from './ReportActionsList/filterHiddenSupportTicketSurveys'; +import {ReportActionsListStateContext} from './ReportActionsListContext'; + +type ReportActionsListStateProviderProps = { + state: ReportActionsListSourceState; + children: ReactNode; +}; + +const reopenedAsReportIDSelector = (reportNameValuePairs: OnyxEntry) => reportNameValuePairs?.reopenedAsReportID; + +function ReportActionsListStateProvider({state, children}: ReportActionsListStateProviderProps) { + const {report, sortedReportActions, sortedVisibleReportActions, sortedAllReportActions} = state; + const [reopenedAsReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`, {selector: reopenedAsReportIDSelector}); + + const paymentFilteredReportActions = report?.isWaitingOnBankAccount ? sortedReportActions.filter((action) => !shouldHidePayAction(action, true)) : sortedReportActions; + const paymentFilteredVisibleReportActions = report?.isWaitingOnBankAccount + ? sortedVisibleReportActions.filter((action) => !shouldHidePayAction(action, true)) + : sortedVisibleReportActions; + const surveyNameValuePairs = {reopenedAsReportID}; + const surveyFilteredReportActions = filterHiddenSupportTicketSurveys({ + reportActions: paymentFilteredReportActions, + sortedAllReportActions, + report, + reportNameValuePairs: surveyNameValuePairs, + }); + const surveyFilteredVisibleReportActions = filterHiddenSupportTicketSurveys({ + reportActions: paymentFilteredVisibleReportActions, + sortedAllReportActions, + report, + reportNameValuePairs: surveyNameValuePairs, + }); + + const value = { + ...state, + sortedReportActions: surveyFilteredReportActions, + sortedVisibleReportActions: surveyFilteredVisibleReportActions, + scrollReportActions: paymentFilteredVisibleReportActions, + }; + + return {children}; +} + +export default ReportActionsListStateProvider; diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index c92e3355a529..c03c89dd909b 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -13,7 +13,8 @@ import type {ReactNode} from 'react'; import React, {useEffect} from 'react'; import AskConciergeWelcome from './AskConciergeWelcome'; -import {computeReportActionsSkeletonState, ReportActionsListActionsContext, ReportActionsListStateContext} from './ReportActionsListContext'; +import {computeReportActionsSkeletonState, ReportActionsListActionsContext} from './ReportActionsListContext'; +import ReportActionsListStateProvider from './ReportActionsListStateProvider'; import ReportActionsLoadingSkeleton from './ReportActionsLoadingSkeleton'; type ReportActionsSkeletonGuardProps = { @@ -96,7 +97,7 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG return ( - {children} + {children} ); } diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 2495122add8e..2bcb1cae9c18 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,4 +1,4 @@ -import {act, render, screen, waitFor} from '@testing-library/react-native'; +import {act, render, renderHook, screen, waitFor} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -7,12 +7,14 @@ import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ import type * as InFlightRequests from '@hooks/useInFlightRequests'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; +import useLoadReportActions from '@hooks/useLoadReportActions'; import useNetwork from '@hooks/useNetwork'; import * as NetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import usePrevious from '@hooks/usePrevious'; +import useReportActionsListModel from '@hooks/useReportActionsListModel'; import type useReportActionsScroll from '@hooks/useReportActionsScroll'; import * as ReportScrollManager from '@hooks/useReportScrollManager'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; @@ -33,6 +35,8 @@ import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/Concierg import CollapsedSystemMessages from '@pages/inbox/report/CollapsedSystemMessages'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; import ReportActionsListFloatingMessageCounter from '@pages/inbox/report/ReportActionsList/ReportActionsListFloatingMessageCounter'; +import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import type {ReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import * as ReportActionsNewActionLiveTail from '@pages/inbox/report/useReportActionsNewActionLiveTail'; @@ -52,7 +56,7 @@ import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; import {buildSystemAction} from '../utils/ReportTestUtils'; -import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const mockUseIsFocused = jest.fn().mockReturnValue(false); jest.mock('@react-navigation/native', () => { @@ -197,7 +201,7 @@ type MockInvertedFlashListProps = { onContentSizeChange?: () => void; }; -const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/FlashList/InvertedFlashList'); +const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => React.ReactElement | null> = jest.requireMock('@components/FlashList/InvertedFlashList'); const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/report/ReportActionItemCreated'); /** Returns the report actions the body fed into the (mocked) inverted list on its latest render. */ @@ -284,6 +288,23 @@ const renderReportActionsList = (props: {reportID?: string} = {}) => { return render(); }; +function renderPublishedReportActionsListState() { + const observeState = jest.fn(); + function StateProbe() { + observeState(useReportActionsListState()); + return null; + } + mockInvertedFlashList.mockImplementation(() => ); + renderReportActionsList(); + return () => { + const state = observeState.mock.lastCall?.[0]; + if (!state) { + throw new Error('Expected the ready report list to publish its state'); + } + return state; + }; +} + // useReportActionsListModel derives app-load state from the request queue via useIsAppLoadPending, // which reads these queue keys through selectors that resolve to a boolean. Returning that boolean // directly mirrors what useOnyx yields once the selector runs. The legacy IS_LOADING_APP flag is kept @@ -319,6 +340,7 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); + mockInvertedFlashList.mockImplementation(() => null); mockUseIsReportLoadPending.mockReturnValue(false); mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}); @@ -374,8 +396,10 @@ describe('ReportActionsList (body)', () => { }); afterEach(async () => { - await waitForBatchedUpdatesWithAct(); - await Onyx.clear(); + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); }); it('leaves counter copy subscriptions in the counter wrapper while forwarding report and navigation props', () => { @@ -413,18 +437,30 @@ describe('ReportActionsList (body)', () => { {hasUnreadMarker: true, hasNewestPage: false}, {hasUnreadMarker: false, hasNewestPage: false}, ])('handles a hidden newest survey (latest page loaded: $hasNewestPage, unread marker: $hasUnreadMarker)', ({hasUnreadMarker, hasNewestPage}) => { - // Given a cached survey newer than the reply, but not necessarily in the loaded page + // Given a cached survey and hidden payment newer than the reply, but not necessarily in the loaded page const reply = {...mockReportActions[1], created: '2023-01-01 00:04:00.000'}; const survey = {...reply, reportActionID: 'hidden-survey', actionName: CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY, created: '2023-01-01 00:05:00.000'}; - const supportReport = {...mockReport, type: CONST.REPORT.TYPE.SUPPORT_TICKET, lastVisibleActionCreated: survey.created}; + const payment: OnyxTypes.ReportAction = { + ...reply, + reportActionID: 'hidden-payment', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2023-01-01 00:06:00.000', + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY}, + }; + const supportReport = { + ...mockReport, + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + isWaitingOnBankAccount: true, + lastVisibleActionCreated: survey.created, + }; mockUseNetwork.mockReturnValue({isOffline: false}); mockUseOnyx.mockImplementation((key, options) => key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}` ? [supportReport, {status: 'loaded'}] : getMockOnyxValue(key, options), ); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, - reportActions: hasNewestPage ? [survey, reply] : [reply], - sortedAllReportActions: [survey, reply], + reportActions: hasNewestPage ? [payment, survey, reply] : [reply], + sortedAllReportActions: [payment, survey, reply], hasNewerActions: !hasNewestPage, }); mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: hasUnreadMarker ? reply.reportActionID : null, unreadMarkerReportActionIndex: hasUnreadMarker ? 0 : -1}); @@ -453,6 +489,8 @@ describe('ReportActionsList (body)', () => { // Then only a loaded latest page is marked read; a cached survey alone cannot prove freshness expect(getCapturedVisibleActions()).not.toContain(survey); + expect(getCapturedVisibleActions()).not.toContain(payment); + expect(mockUseReportActionsScroll.mock.lastCall?.[0].sortedVisibleReportActions).toEqual(hasNewestPage ? [survey, reply] : [reply]); expect(markNewestActionAsRead).toHaveBeenCalledTimes(hasNewestPage ? 1 : 0); expect(openReportAtLiveTail).toHaveBeenCalledTimes(!hasNewestPage && !hasUnreadMarker ? 1 : 0); if (hasUnreadMarker) { @@ -474,12 +512,24 @@ describe('ReportActionsList (body)', () => { }); it('keeps the offline unread divider above the reply when a newer survey is hidden', () => { - // Given a reply received offline and a newer hidden survey - const supportReport = {...mockReport, type: CONST.REPORT.TYPE.SUPPORT_TICKET, lastReadTime: '2023-01-01 00:02:00.000'}; + // Given a reply received offline with a newer hidden survey and payment + const supportReport = { + ...mockReport, + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + isWaitingOnBankAccount: true, + lastReadTime: '2023-01-01 00:02:00.000', + }; const readComment = {...mockReportActions[1], reportActionID: 'read-comment', created: '2023-01-01 00:01:00.000'}; const offlineReply = {...readComment, reportActionID: 'offline-reply', created: '2023-01-01 00:04:00.000'}; const survey = {...readComment, reportActionID: 'hidden-survey', actionName: CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY, created: '2023-01-01 00:05:00.000'}; - const actions = [survey, offlineReply, readComment]; + const payment: OnyxTypes.ReportAction = { + ...readComment, + reportActionID: 'hidden-payment', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2023-01-01 00:06:00.000', + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY}, + }; + const actions = [payment, survey, offlineReply, readComment]; mockUseCurrentUserPersonalDetails.mockReturnValue({...mockUseCurrentUserPersonalDetails(), timezone: {selected: 'Africa/Abidjan', automatic: false}}); mockUseNetwork.mockReturnValue({isOffline: true}); const networkSpy = jest.spyOn(NetworkWithOfflineStatus, 'default').mockReturnValue({ @@ -508,6 +558,10 @@ describe('ReportActionsList (body)', () => { // Then New belongs to the unread reply, not the older read comment expect(getCapturedVisibleActions()).not.toContain(survey); + expect(getCapturedVisibleActions()).not.toContain(payment); + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith( + expect.objectContaining({sortedReportActions: [offlineReply, readComment], sortedVisibleReportActions: [offlineReply, readComment]}), + ); expect(mockUseReportActionsScroll.mock.lastCall?.[0].unreadMarkerReportActionID).toBe(offlineReply.reportActionID); expect(mockUseReportActionsScroll.mock.lastCall?.[0].unreadMarkerReportActionIndex).toBe(0); } finally { @@ -560,7 +614,8 @@ describe('ReportActionsList (body)', () => { return [supportReport, {status: 'loaded'}]; } if (key === `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${mockReport.reportID}`) { - return [ticketState === 'replaced' ? {reopenedAsReportID: 'replacement-ticket'} : {}, {status: 'loaded'}]; + const nameValuePairs = ticketState === 'replaced' ? {reopenedAsReportID: 'replacement-ticket'} : {}; + return [options?.selector ? (options.selector as (value: OnyxTypes.ReportNameValuePairs) => string | undefined)(nameValuePairs) : nameValuePairs, {status: 'loaded'}]; } return getMockOnyxValue(key, options); }); @@ -594,6 +649,12 @@ describe('ReportActionsList (body)', () => { const visibleActions = getCapturedVisibleActions(); expect(visibleActions).not.toContain(obsoleteSurvey); expect(visibleActions?.includes(latestSurvey)).toBe(ticketState === 'resolved'); + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith( + expect.objectContaining({ + sortedReportActions: actions.filter((action) => action !== obsoleteSurvey && (ticketState === 'resolved' || action !== latestSurvey)), + sortedVisibleReportActions: visibleActions, + }), + ); expect(mockUseReportActionsScroll.mock.lastCall?.[0].unreadMarkerReportActionID).toBe(unreadReply.reportActionID); expect(mockUseReportActionsScroll.mock.lastCall?.[0].unreadMarkerReportActionIndex).toBe(visibleActions?.indexOf(unreadReply)); const row = findRenderedElement>( @@ -607,6 +668,366 @@ describe('ReportActionsList (body)', () => { }); }); + describe('Support survey filtering in the published list state', () => { + const reply: OnyxTypes.ReportAction = {...mockReportActions[1], created: '2023-01-01 00:04:00.000'}; + const obsoleteSurvey: OnyxTypes.ReportAction = { + ...reply, + reportActionID: 'obsolete-survey', + actionName: CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY, + created: '2023-01-01 00:03:00.000', + }; + const resolution: OnyxTypes.ReportAction = { + ...reply, + reportActionID: 'resolution', + actionName: CONST.REPORT.ACTIONS.TYPE.CLOSED, + created: '2023-01-01 00:05:00.000', + }; + const latestSurvey: OnyxTypes.ReportAction = { + ...obsoleteSurvey, + reportActionID: 'latest-survey', + created: '2023-01-01 00:06:00.000', + }; + const resolvedReport: OnyxTypes.Report = { + ...mockReport, + type: CONST.REPORT.TYPE.SUPPORT_TICKET, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + }; + + it.each([ + {ticketState: 'unresolved', hasCachedResolution: true, shouldShowLatestSurvey: false}, + {ticketState: 'resolved', hasCachedResolution: true, shouldShowLatestSurvey: true}, + {ticketState: 'replaced', hasCachedResolution: true, shouldShowLatestSurvey: false}, + {ticketState: 'resolved', hasCachedResolution: false, shouldShowLatestSurvey: false}, + ])('filters both published arrays for a $ticketState ticket (cached resolution: $hasCachedResolution)', ({ticketState, hasCachedResolution, shouldShowLatestSurvey}) => { + // Given a selected page whose resolution can only be found in the full cache + const supportReport = ticketState === 'unresolved' ? {...resolvedReport, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN} : resolvedReport; + const page = [latestSurvey, reply, obsoleteSurvey]; + const fullCache = hasCachedResolution ? [latestSurvey, resolution, reply, obsoleteSurvey] : page; + const nameValuePairs = ticketState === 'replaced' ? {reopenedAsReportID: 'replacement-ticket'} : {}; + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseOnyx.mockImplementation((key, options) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`) { + return [supportReport, {status: 'loaded'}]; + } + if (key === `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${mockReport.reportID}`) { + return [options?.selector ? (options.selector as (value: OnyxTypes.ReportNameValuePairs) => string | undefined)(nameValuePairs) : nameValuePairs, {status: 'loaded'}]; + } + return getMockOnyxValue(key, options); + }); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: page, sortedAllReportActions: fullCache}); + + // When the public guard mounts the provider and a consumer reads its render and scroll views + const getPublishedState = renderPublishedReportActionsListState(); + + // Then unread selection and rows share eligibility without losing selected-page freshness or raw pagination + const expectedActions = shouldShowLatestSurvey ? [latestSurvey, reply] : [reply]; + expect(getPublishedState().sortedReportActions).toEqual(expectedActions); + expect(getPublishedState().sortedVisibleReportActions).toEqual(expectedActions); + expect(getPublishedState().scrollReportActions).toEqual(page); + expect(getPublishedState().sortedAllReportActions).toBe(fullCache); + expect(jest.mocked(useLoadReportActions).mock.lastCall?.[0].reportActions).toEqual(page); + }); + + it('reacts to replacement metadata arriving and ticket status changing without changing the loaded page', async () => { + // Given a resolved ticket with cached metadata so its archive subscription has settled before replacement updates + const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}` as const; + const nameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${mockReport.reportID}` as const; + const actualUseOnyx = jest.requireActual<{default: typeof useOnyx}>('@hooks/useOnyx').default; + const page = [latestSurvey, resolution, reply, obsoleteSurvey]; + await act(async () => { + await Onyx.set(reportKey, resolvedReport); + await Onyx.set(nameValuePairsKey, {}); + await waitForBatchedUpdates(); + }); + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseOnyx.mockImplementation((key, options) => { + if (key === reportKey || key === nameValuePairsKey) { + return actualUseOnyx(key, options); + } + return getMockOnyxValue(key, options); + }); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: page, sortedAllReportActions: page}); + const getPublishedState = renderPublishedReportActionsListState(); + await waitFor(() => expect(getPublishedState().sortedVisibleReportActions).toEqual([latestSurvey, resolution, reply])); + const initialModelExecutions = mockUsePaginatedReportActions.mock.calls.length; + + // When the server supplies replacement metadata after the actions have loaded + await act(async () => { + await Onyx.set(nameValuePairsKey, {reopenedAsReportID: 'replacement-ticket'}); + await waitForBatchedUpdates(); + }); + + // Then both render arrays immediately hide surveys, but scroll freshness retains the selected newest action + await waitFor(() => expect(getPublishedState().sortedVisibleReportActions).toEqual([resolution, reply])); + expect(getPublishedState().sortedReportActions).toEqual([resolution, reply]); + expect(getPublishedState().scrollReportActions).toEqual(page); + expect(mockUsePaginatedReportActions).toHaveBeenCalledTimes(initialModelExecutions); + + // When the replacement disappears and the ticket changes from resolved to open and back + await act(async () => { + await Onyx.set(nameValuePairsKey, {}); + await waitForBatchedUpdates(); + }); + await waitFor(() => expect(getPublishedState().sortedVisibleReportActions).toContain(latestSurvey)); + expect(mockUsePaginatedReportActions).toHaveBeenCalledTimes(initialModelExecutions); + await act(async () => { + await Onyx.merge(reportKey, {stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}); + await waitForBatchedUpdates(); + }); + + // Then eligibility follows status as well as metadata, without requiring a new action array + await waitFor(() => expect(getPublishedState().sortedVisibleReportActions).toEqual([resolution, reply])); + expect(getPublishedState().sortedReportActions).toEqual([resolution, reply]); + await act(async () => { + await Onyx.merge(reportKey, {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.CLOSED}); + await waitForBatchedUpdates(); + }); + await waitFor(() => expect(getPublishedState().sortedVisibleReportActions).toEqual([latestSurvey, resolution, reply])); + expect(getPublishedState().scrollReportActions).toEqual(page); + }); + + it.each([false, true])('keeps a hidden-survey-only loaded page ready without backfilling (offline: %s)', (isOffline) => { + // Given an unresolved ticket whose selected page contains only a survey that cannot render + const supportReport = {...resolvedReport, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + const loadOlderChats = jest.fn(); + const loadHookSpy = jest.mocked(useLoadReportActions); + const defaultImplementation = loadHookSpy.getMockImplementation(); + loadHookSpy.mockReturnValue({loadOlderChats, loadNewerChats: jest.fn(), currentReportOldestActionID: latestSurvey.reportActionID}); + mockUseNetwork.mockReturnValue({isOffline}); + mockUseOnyx.mockImplementation((key, options) => + key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}` ? [supportReport, {status: 'loaded'}] : getMockOnyxValue(key, options), + ); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [latestSurvey], + sortedAllReportActions: [latestSurvey], + hasOlderActions: true, + }); + + try { + // When the public list evaluates readiness before survey filtering + renderReportActionsList(); + + // Then an empty presentation mounts normally instead of requesting unrelated older messages + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(getCapturedVisibleActions()).toEqual([]); + expect(mockUseMarkAsRead).toHaveBeenCalled(); + expect(mockUseReportActionsScroll.mock.lastCall?.[0].sortedVisibleReportActions).toEqual([latestSurvey]); + expect(loadOlderChats).not.toHaveBeenCalled(); + } finally { + loadHookSpy.mockReset(); + if (defaultImplementation) { + loadHookSpy.mockImplementation(defaultImplementation); + } + } + }); + + it('keeps presentation subscriptions and effects dormant while a support ticket load is pending', async () => { + // Given a partially cached support ticket whose OpenReport request has not completed + const nameValuePairsKey = `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${mockReport.reportID}` as const; + const actualUseOnyx = jest.requireActual<{default: typeof useOnyx}>('@hooks/useOnyx').default; + await act(async () => { + await Onyx.set(nameValuePairsKey, {}); + await waitForBatchedUpdates(); + }); + const unreadSupportReport = { + ...resolvedReport, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + lastReadTime: '2023-01-01 00:02:00.000', + lastVisibleActionCreated: latestSurvey.created, + lastMessageText: 'Unread support reply', + lastActorAccountID: reply.actorAccountID, + }; + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsReportLoadPending.mockReturnValue(true); + mockUseOnyx.mockImplementation((key, options) => { + if (key === nameValuePairsKey) { + return actualUseOnyx(key, options); + } + return key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}` ? [unreadSupportReport, {status: 'loaded'}] : getMockOnyxValue(key, options); + }); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: [latestSurvey], sortedAllReportActions: [latestSurvey]}); + + // When the guard sees an in-flight report load despite its cached survey + renderReportActionsList(); + await act(async () => { + await waitForBatchedUpdates(); + }); + const initialModelExecutions = mockUsePaginatedReportActions.mock.calls.length; + await act(async () => { + await Onyx.set(nameValuePairsKey, {reopenedAsReportID: 'replacement-ticket'}); + await waitForBatchedUpdates(); + }); + + // Then no content effect can consume unread state or subscribe to live scrolling + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockUseMarkAsRead).not.toHaveBeenCalled(); + expect(mockUseUnreadMarker).not.toHaveBeenCalled(); + expect(mockUseReportActionsScroll).not.toHaveBeenCalled(); + expect(mockInvertedFlashList).not.toHaveBeenCalled(); + expect(mockUsePaginatedReportActions).toHaveBeenCalledTimes(initialModelExecutions); + const replacementSelectors = mockUseOnyx.mock.calls.filter(([key, options]) => { + if (key !== `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${mockReport.reportID}` || !options?.selector) { + return false; + } + return (options.selector as (value: OnyxTypes.ReportNameValuePairs) => unknown)({reopenedAsReportID: 'replacement-ticket'}) === 'replacement-ticket'; + }); + expect(replacementSelectors).toHaveLength(0); + }); + }); + + describe('Payment filtering in the published list state', () => { + const payment: OnyxTypes.ReportAction = { + ...mockReportActions[1], + reportActionID: 'bank-payment', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY}, + }; + + it.each([false, true])('filters waiting payments but preserves direct sends and source pagination (offline: %s)', (isOffline) => { + // Given a loaded page containing a bank payment and a direct send with independent pagination cursors + const waitingReport = { + ...mockReport, + type: CONST.REPORT.TYPE.CHAT, + isWaitingOnBankAccount: true, + }; + const directSend: OnyxTypes.ReportAction = { + ...payment, + reportActionID: 'direct-send', + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + IOUDetails: {amount: 100, currency: CONST.CURRENCY.USD, comment: ''}, + }, + }; + const sourceActions = [payment, directSend, ...mockReportActions]; + const paginationState = { + newestFetchedReportActionID: payment.reportActionID, + }; + mockUseNetwork.mockReturnValue({isOffline}); + mockUseOnyx.mockImplementation((key, options) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`) { + return [waitingReport, {status: 'loaded'}]; + } + if (key === `${ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE}${mockReport.reportID}`) { + return [paginationState, {status: 'loaded'}]; + } + return getMockOnyxValue(key, options); + }); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: sourceActions, + sortedAllReportActions: sourceActions, + hasOlderActions: true, + hasNewerActions: true, + }); + + // When the raw model prepares readiness and pagination before the public provider publishes render state + const {result} = renderHook(() => useReportActionsListModel(mockReport.reportID, false)); + const getPublishedState = renderPublishedReportActionsListState(); + + // Then both render arrays omit only the bank payment, while fetching retains its identity and cursor + expect(getPublishedState().sortedReportActions).toEqual([directSend, ...mockReportActions]); + expect(getPublishedState().sortedVisibleReportActions).toEqual([directSend, ...mockReportActions]); + expect(getPublishedState().scrollReportActions).toEqual([directSend, ...mockReportActions]); + expect(getPublishedState().sortedAllReportActions).toBe(sourceActions); + expect(getPublishedState().persistedReportActionIDs).toContain(payment.reportActionID); + expect(result.current.state.sortedReportActions).toEqual(sourceActions); + expect(result.current.state.sortedVisibleReportActions).toEqual(sourceActions); + expect(result.current.readinessSignals).toMatchObject({ + isMissingReportActions: false, + reportActionsLength: sourceActions.length, + }); + expect(jest.mocked(useLoadReportActions).mock.lastCall?.[0]).toMatchObject({ + reportActions: sourceActions, + allReportActionIDs: sourceActions.map((action) => action.reportActionID), + hasOlderActions: true, + hasNewerActions: true, + newestFetchedReportActionID: payment.reportActionID, + }); + }); + + it.each([false, true])('does not treat a loaded payment-only page as missing or backfill it (offline: %s)', (isOffline) => { + // Given the only loaded action is hidden for a report waiting on its bank account + const waitingReport = { + ...mockReport, + type: CONST.REPORT.TYPE.CHAT, + isWaitingOnBankAccount: true, + }; + const loadOlderChats = jest.fn(); + const loadHookSpy = jest.mocked(useLoadReportActions); + const defaultImplementation = loadHookSpy.getMockImplementation(); + loadHookSpy.mockReturnValue({ + loadOlderChats, + loadNewerChats: jest.fn(), + currentReportOldestActionID: payment.reportActionID, + }); + mockUseNetwork.mockReturnValue({isOffline}); + mockUseOnyx.mockImplementation((key, options) => + key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}` ? [waitingReport, {status: 'loaded'}] : getMockOnyxValue(key, options), + ); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [payment], + hasOlderActions: true, + }); + + try { + // When the public guard receives a page that becomes empty only after payment filtering + renderReportActionsList(); + + // Then the content mounts without a skeleton or recovery request for an already loaded page + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(getCapturedVisibleActions()).toEqual([]); + expect(mockUseMarkAsRead).toHaveBeenCalled(); + expect(mockUseReportActionsScroll).toHaveBeenCalled(); + expect(loadOlderChats).not.toHaveBeenCalled(); + } finally { + loadHookSpy.mockReset(); + if (defaultImplementation) { + loadHookSpy.mockImplementation(defaultImplementation); + } + } + }); + + it.each([false, true])('retains recovery for genuinely absent visible actions (offline: %s)', (isOffline) => { + // Given a raw page has no visible actions but still has an older pagination cursor + const loadOlderChats = jest.fn(); + const loadHookSpy = jest.mocked(useLoadReportActions); + const defaultImplementation = loadHookSpy.getMockImplementation(); + loadHookSpy.mockReturnValue({ + loadOlderChats, + loadNewerChats: jest.fn(), + currentReportOldestActionID: 'invisible-action', + }); + mockUseNetwork.mockReturnValue({isOffline}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + hasOlderActions: true, + }); + + try { + // When the public guard checks readiness before mounting the list content + renderReportActionsList(); + + // Then absent data still gates the content and only an online report starts recovery + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockUseMarkAsRead).not.toHaveBeenCalled(); + expect(mockUseReportActionsScroll).not.toHaveBeenCalled(); + expect(loadOlderChats).toHaveBeenCalledTimes(isOffline ? 0 : 1); + if (!isOffline) { + expect(loadOlderChats).toHaveBeenCalledWith(false); + } + } finally { + loadHookSpy.mockReset(); + if (defaultImplementation) { + loadHookSpy.mockImplementation(defaultImplementation); + } + } + }); + }); + describe('System message presentation', () => { const systemActions = ( [ @@ -976,7 +1397,8 @@ describe('ReportActionsList (body)', () => { it('marks nothing inside the feedback thread the backend opens after a thumbs down', () => { mockUseOnyx.mockImplementation((key, options) => { if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS)) { - return [{conciergeFeedbackForReportActionID: conciergeReply.reportActionID}, {status: 'loaded'}]; + const nameValuePairs = {conciergeFeedbackForReportActionID: conciergeReply.reportActionID}; + return [options?.selector ? (options.selector as (value: OnyxTypes.ReportNameValuePairs) => string | undefined)(nameValuePairs) : nameValuePairs, {status: 'loaded'}]; } return getMockOnyxValue(key, options); }); @@ -1079,11 +1501,17 @@ describe('ReportActionsList (body)', () => { }); it('clears a completed streamed reply when an unrelated survey is filtered out', () => { - // Given an unresolved ticket with a saved reply and an obsolete survey. - const supportReport = {...mockReport, type: CONST.REPORT.TYPE.SUPPORT_TICKET}; + // Given an unresolved ticket with a saved reply, an obsolete survey, and a hidden payment. + const supportReport = {...mockReport, type: CONST.REPORT.TYPE.SUPPORT_TICKET, isWaitingOnBankAccount: true}; const savedReply = {...conciergeDraftReportAction, reportActionID: 'saved-reply'}; const survey = {...conciergeDraftReportAction, reportActionID: 'obsolete-survey', actionName: CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY}; - const actions = [savedReply, survey, ...mockReportActions]; + const payment: OnyxTypes.ReportAction = { + ...savedReply, + reportActionID: 'hidden-payment', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY}, + }; + const actions = [payment, savedReply, survey, ...mockReportActions]; const clearDraft = jest.fn(); mockUseOnyx.mockImplementation((key, options) => key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}` ? [supportReport, {status: 'loaded'}] : getMockOnyxValue(key, options), @@ -1098,6 +1526,7 @@ describe('ReportActionsList (body)', () => { // Then only the saved reply remains, and no synthetic autoscroll or retained reply is needed. expect(getCapturedVisibleActions()?.filter((action) => action.reportActionID === savedReply.reportActionID)).toHaveLength(1); expect(getCapturedVisibleActions()).not.toContain(survey); + expect(getCapturedVisibleActions()).not.toContain(payment); expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)?.draftAutoScrollKey).toBe(''); expect(clearDraft).toHaveBeenCalledTimes(1); });