Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ const ExportDownloadStatusContext = createContext<ExportDownloadStatusContextVal
type ExportDownloadStatusProviderProps = {
/** The children to render inside the provider */
children: React.ReactNode;

/** Extra cleanup to run once the status modal is dismissed. Defaults to clearing the current selection. */
onCleanup?: () => void;
};

/**
* Owns the queued export status modal for the money report header. The state lives here, above the
* two mutually-exclusive layout branches in MoneyReportHeader, so the modal survives orientation /
* layout changes that remount the header actions subtree.
* Owns the queued export status modal for a surface that triggers tracked exports (the money report header,
* the Search page). The state lives here, above the two mutually-exclusive layout branches those surfaces
* render, so the modal survives orientation / layout changes that remount the branch it was triggered from.
*/
function ExportDownloadStatusProvider({children}: ExportDownloadStatusProviderProps) {
function ExportDownloadStatusProvider({children, onCleanup}: ExportDownloadStatusProviderProps) {
const {clearSelectedTransactions} = useSearchSelectionActions();
const {trackExport, exportDownloadStatusModal} = useExportDownloadStatusModal(() => clearSelectedTransactions(true));
const {trackExport, exportDownloadStatusModal} = useExportDownloadStatusModal(() => (onCleanup ? onCleanup() : clearSelectedTransactions(true)));

return (
<ExportDownloadStatusContext.Provider value={{trackExport}}>
Expand Down
2 changes: 0 additions & 2 deletions src/components/Search/SearchBulkActionsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
handleExpensifyCardStatementPDFModalHide,
isExpensifyCardStatementMultiFeedAlertVisible,
handleExpensifyCardStatementMultiFeedAlertClose,
exportDownloadStatusModal,
dismissModalAndUpdateUseHold,
dismissRejectModalBasedOnAction,
isDuplicateOptionVisible,
Expand Down Expand Up @@ -321,7 +320,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
isDM={areAllTransactionsFromDMReports}
/>
)}
{exportDownloadStatusModal}
</>
);
}
Expand Down
12 changes: 5 additions & 7 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider';
import type {PaymentMethodType} from '@components/KYCWall/types';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import {useExportDownloadStatus} from '@components/MoneyReportHeaderActions/ExportDownloadStatusProvider';
import type {PopoverMenuItem} from '@components/PopoverMenu';
import {useOpenSearchReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor';
import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext';
Expand Down Expand Up @@ -124,7 +125,6 @@ import useDefaultExpensePolicy from './useDefaultExpensePolicy';
import useDelegateAccountID from './useDelegateAccountID';
import useDeleteTransactions from './useDeleteTransactions';
import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations';
import useExportDownloadStatusModal from './useExportDownloadStatusModal';
import {useMemoizedLazyExpensifyIcons} from './useLazyAsset';
import useLocalize from './useLocalize';
import useNetwork from './useNetwork';
Expand Down Expand Up @@ -431,7 +431,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const {selectedTransactions, excludedTransactions = getEmptyObject<SelectedTransactions>(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {currentSearchResults} = useSearchResultsContext();
const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext();
const {clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions();
const {clearSelectedTransactions} = useSearchSelectionActions();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const {accountID, email, login: currentUserLogin, localCurrencyCode} = currentUserPersonalDetails;
const delegateAccountID = useDelegateAccountID();
Expand Down Expand Up @@ -496,10 +496,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
> | null>(null);

const [emptyReportsCount, setEmptyReportsCount] = useState<number>(0);
const {trackExport, exportDownloadStatusModal} = useExportDownloadStatusModal(() => {
selectAllMatchingItems(false);
clearSelectedTransactions(undefined, true);
});
// Owned by SearchPage (above the narrow/wide layout ternary) so an in-flight export isn't dropped when this
// subtree remounts on a layout change.
const {trackExport} = useExportDownloadStatus();

const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION);
const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION);
Expand Down Expand Up @@ -2874,7 +2873,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
handleExpensifyCardStatementPDFModalHide,
isExpensifyCardStatementMultiFeedAlertVisible,
handleExpensifyCardStatementMultiFeedAlertClose,
exportDownloadStatusModal,
dismissModalAndUpdateUseHold,
dismissRejectModalBasedOnAction,
isDuplicateOptionVisible,
Expand Down
63 changes: 37 additions & 26 deletions src/pages/Search/SearchPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {ExportDownloadStatusProvider} from '@components/MoneyReportHeaderActions/ExportDownloadStatusProvider';
import {ReportSubmitToPopoverHost, SEARCH_REPORT_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT} from '@components/ReportSubmitToPopoverAnchor';
import {useSearchQueryContext, useSearchResultsActions, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext';
import type {SearchParams} from '@components/Search/types';
Expand Down Expand Up @@ -42,7 +43,7 @@ function SearchPage({route}: SearchPageProps) {
const styles = useThemeStyles();
const {lastSearchType, currentSearchResults, shouldUseLiveData} = useSearchResultsContext();
const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext();
const {clearSelectedTransactions} = useSearchSelectionActions();
const {clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions();
const {setLastSearchType} = useSearchResultsActions();

const isMobileSelectionModeEnabled = useMobileSelectionMode(clearSelectedTransactions);
Expand Down Expand Up @@ -123,6 +124,12 @@ function SearchPage({route}: SearchPageProps) {
setIsSorting(true);
}, []);

// Runs once the queued-export status modal is dismissed, mirroring what the bulk action bar used to do itself.
const clearSelectionAfterExport = useCallback(() => {
selectAllMatchingItems(false);
clearSelectedTransactions(undefined, true);
}, [clearSelectedTransactions, selectAllMatchingItems]);

const overlayContentContainerStyle = !isMobileSelectionModeEnabled ? styles.searchListContentContainerStyles(!!hasFilterBars) : undefined;
const overlayEndSubmitSpans = useEndSubmitNavigationSpans();
const {searchOverlayContent, onSearchContentReady, isOverlayActive} = useSearchOverlay({
Expand All @@ -138,31 +145,35 @@ function SearchPage({route}: SearchPageProps) {
return (
<ReportSubmitToPopoverHost anchorAlignment={SEARCH_REPORT_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT}>
<PaymentContextProvider>
<Animated.View style={[styles.flex1]}>
{shouldUseNarrowLayout ? (
<SearchPageNarrow
queryJSON={currentSearchQueryJSON}
searchResults={searchResults}
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
onSortPressedCallback={onSortPressedCallback}
searchOverlayContent={searchOverlayContent}
onSearchContentReady={onSearchContentReady}
hasFilterBars={hasFilterBars}
isOverlayActive={isOverlayActive}
/>
) : (
<SearchPageWide
queryJSON={currentSearchQueryJSON}
searchResults={searchResults}
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
handleSearchAction={handleSearchAction}
onSortPressedCallback={onSortPressedCallback}
route={route}
searchOverlayContent={searchOverlayContent}
onSearchContentReady={onSearchContentReady}
/>
)}
</Animated.View>
{/* The export tracking state lives above the narrow/wide ternary below so crossing the layout
breakpoint mid-export doesn't unmount it and drop the in-flight download. */}
<ExportDownloadStatusProvider onCleanup={clearSelectionAfterExport}>
<Animated.View style={[styles.flex1]}>
{shouldUseNarrowLayout ? (
<SearchPageNarrow
queryJSON={currentSearchQueryJSON}
searchResults={searchResults}
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
onSortPressedCallback={onSortPressedCallback}
searchOverlayContent={searchOverlayContent}
onSearchContentReady={onSearchContentReady}
hasFilterBars={hasFilterBars}
isOverlayActive={isOverlayActive}
/>
) : (
<SearchPageWide
queryJSON={currentSearchQueryJSON}
searchResults={searchResults}
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
handleSearchAction={handleSearchAction}
onSortPressedCallback={onSortPressedCallback}
route={route}
searchOverlayContent={searchOverlayContent}
onSearchContentReady={onSearchContentReady}
/>
)}
</Animated.View>
</ExportDownloadStatusProvider>
</PaymentContextProvider>
</ReportSubmitToPopoverHost>
);
Expand Down
113 changes: 113 additions & 0 deletions tests/unit/Search/ExportDownloadStatusProviderTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {fireEvent, render, screen} from '@testing-library/react-native';

import {ExportDownloadStatusProvider, useExportDownloadStatus} from '@components/MoneyReportHeaderActions/ExportDownloadStatusProvider';
import Text from '@components/Text';

import useExportDownloadStatusModal from '@hooks/useExportDownloadStatusModal';

import React from 'react';

const mockExportDownloadStatusModal = jest.fn(({exportID}: {exportID: string}) => <Text testID="export-status-modal">{exportID}</Text>);
jest.mock('@components/ExportDownloadStatusModal', () => ({
__esModule: true,
default: (props: {exportID: string}) => mockExportDownloadStatusModal(props),
}));

jest.mock('@components/Search/SearchContext', () => ({
useSearchSelectionActions: () => ({clearSelectedTransactions: jest.fn()}),
}));

jest.mock('@hooks/useOnyx', () => ({
__esModule: true,
default: () => [undefined],
}));

// Two distinct component types, like SearchPageNarrow / SearchPageWide: swapping between them unmounts one
// subtree and mounts the other, so any state owned inside a branch is destroyed.
function WideBranch() {
const {trackExport} = useExportDownloadStatus();
return (
<Text
testID="track-export"
onPress={() => trackExport('export-1')}
>
wide
</Text>
);
}

function NarrowBranch() {
const {trackExport} = useExportDownloadStatus();
return (
<Text
testID="track-export"
onPress={() => trackExport('export-1')}
>
narrow
</Text>
);
}

function ProviderHarness({shouldUseNarrowLayout}: {shouldUseNarrowLayout: boolean}) {
return <ExportDownloadStatusProvider>{shouldUseNarrowLayout ? <NarrowBranch /> : <WideBranch />}</ExportDownloadStatusProvider>;
}

// The pre-fix shape: each branch owns the tracking state and renders the modal itself.
function WideBranchOwningState() {
const {trackExport, exportDownloadStatusModal} = useExportDownloadStatusModal();
return (
<>
<Text
testID="track-export"
onPress={() => trackExport('export-1')}
>
wide
</Text>
{exportDownloadStatusModal}
</>
);
}

function NarrowBranchOwningState() {
const {trackExport, exportDownloadStatusModal} = useExportDownloadStatusModal();
return (
<>
<Text
testID="track-export"
onPress={() => trackExport('export-1')}
>
narrow
</Text>
{exportDownloadStatusModal}
</>
);
}

function InBranchHarness({shouldUseNarrowLayout}: {shouldUseNarrowLayout: boolean}) {
return shouldUseNarrowLayout ? <NarrowBranchOwningState /> : <WideBranchOwningState />;
}

describe('ExportDownloadStatusProvider', () => {
it('keeps tracking an in-flight export when the layout branch below it remounts', () => {
const {rerender} = render(<ProviderHarness shouldUseNarrowLayout={false} />);

fireEvent.press(screen.getByTestId('track-export'));
expect(screen.getByTestId('export-status-modal')).toBeOnTheScreen();

// Cross the narrow/wide breakpoint mid-export, e.g. by resizing the browser.
rerender(<ProviderHarness shouldUseNarrowLayout />);

expect(screen.getByTestId('export-status-modal')).toBeOnTheScreen();
});

it('loses the in-flight export when the tracking state lives inside the layout branch', () => {
const {rerender} = render(<InBranchHarness shouldUseNarrowLayout={false} />);

fireEvent.press(screen.getByTestId('track-export'));
expect(screen.getByTestId('export-status-modal')).toBeOnTheScreen();

rerender(<InBranchHarness shouldUseNarrowLayout />);

expect(screen.queryByTestId('export-status-modal')).not.toBeOnTheScreen();
});
});
7 changes: 6 additions & 1 deletion tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ jest.mock('@libs/actions/CompanyCards', () => ({
getExpensifyCardStatementPDF: jest.fn(() => Promise.resolve({statementKey: 'statement-key'})),
}));

const mockTrackExport = jest.fn();
jest.mock('@components/MoneyReportHeaderActions/ExportDownloadStatusProvider', () => ({
useExportDownloadStatus: () => ({trackExport: mockTrackExport}),
}));

let mockIsOffline = false;
jest.mock('@hooks/useNetwork', () => ({
__esModule: true,
Expand Down Expand Up @@ -464,7 +469,7 @@ describe('useSearchBulkActions - Download as PDF', () => {
expect(exportReportsToPDF).toHaveBeenCalledTimes(1);
expect(exportReportsToPDF).toHaveBeenCalledWith(expect.arrayContaining(['1', '2']));
expect(exportReportToPDF).not.toHaveBeenCalled();
expect(result.current.exportDownloadStatusModal).not.toBeNull();
expect(mockTrackExport).toHaveBeenCalledWith('mock-export-id');
});

it('should show Export as PDF for selected Expensify Card settlement groups', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ jest.mock('@libs/actions/Report', () => ({
exportReportToPDF: jest.fn(),
}));

const mockTrackExport = jest.fn();
jest.mock('@components/MoneyReportHeaderActions/ExportDownloadStatusProvider', () => ({
useExportDownloadStatus: () => ({trackExport: mockTrackExport}),
}));

let mockIsOffline = false;
jest.mock('@hooks/useNetwork', () => ({
__esModule: true,
Expand Down Expand Up @@ -316,7 +321,7 @@ describe('useSearchBulkActions - Download receipts', () => {

expect(exportReceiptsToZip).toHaveBeenCalledTimes(1);
expect(exportReceiptsToZip).toHaveBeenCalledWith({reportIDs: expect.arrayContaining(['1', '2'])});
expect(result.current.exportDownloadStatusModal).not.toBeNull();
expect(mockTrackExport).toHaveBeenCalledWith('mock-receipts-export-id');
});

it('shows the offline modal and does not export when offline', async () => {
Expand Down
11 changes: 8 additions & 3 deletions tests/unit/hooks/useSearchBulkActionsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ jest.mock('@libs/actions/Export', () => ({
clearExportDownload: jest.fn(),
}));

const mockTrackExport = jest.fn();
jest.mock('@components/MoneyReportHeaderActions/ExportDownloadStatusProvider', () => ({
useExportDownloadStatus: () => ({trackExport: mockTrackExport}),
}));

jest.mock('@libs/actions/Search', () => ({
getExportTemplates: jest.fn(() => ({customTemplates: [], defaultTemplates: []})),
exportSearchItemsToCSV: jest.fn(),
Expand Down Expand Up @@ -286,7 +291,7 @@ describe('useSearchBulkActions - CSV export flow', () => {

expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalled();
expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalledWith(expect.objectContaining({excludedTransactionIDList: ['tx2']}));
expect(result.current.exportDownloadStatusModal).not.toBeNull();
expect(mockTrackExport).toHaveBeenCalledWith('mock-export-id');
});

it('exports an excluded unloaded group as a query filter instead of a transaction ID', async () => {
Expand Down Expand Up @@ -466,7 +471,7 @@ describe('useSearchBulkActions - CSV export flow', () => {
});

expect(mockQueueExportSearchItemsToCSV).not.toHaveBeenCalled();
expect(result.current.exportDownloadStatusModal).toBeNull();
expect(mockTrackExport).not.toHaveBeenCalled();
});

it('beginExportWithTemplate tracks the export', async () => {
Expand All @@ -492,7 +497,7 @@ describe('useSearchBulkActions - CSV export flow', () => {
});

expect(mockQueueExportSearchWithTemplate).toHaveBeenCalled();
expect(result.current.exportDownloadStatusModal).not.toBeNull();
expect(mockTrackExport).toHaveBeenCalledWith('mock-template-export-id');
});

it('hides template exports when an all-matching expense selection has exclusions', async () => {
Expand Down
Loading