Skip to content
Merged
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
88 changes: 36 additions & 52 deletions src/components/MoneyRequestReportView/MoneyRequestReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import MoneyReportHeader from '@components/MoneyReportHeader';
import MoneyRequestHeader from '@components/MoneyRequestHeader';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestReceiptView';
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';
import ReportActionsSkeletonCover, {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover';
import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView';

import useContentHeaderHeight from '@hooks/useContentHeaderHeight';
Expand Down Expand Up @@ -45,7 +45,7 @@ import type {LayoutChangeEvent} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';

import {PortalHost} from '@gorhom/portal';
import React, {useCallback, useEffect, useMemo} from 'react';
import {useEffect} from 'react';
// We use Animated for all functionality related to wide RHP to make it easier
// to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated.
// eslint-disable-next-line no-restricted-imports
Expand Down Expand Up @@ -108,7 +108,7 @@ function InitialLoadingSkeleton({styles, onLayout}: {styles: ThemeStyles; onLayo
<View style={[styles.appContentHeader, contentHeaderHeightStyle, styles.borderBottom]}>
<ReportHeaderSkeletonView onBackButtonPress={() => {}} />
</View>
<ReportActionsSkeletonView />
<ReportActionsAnimatedSkeletonCover />
</View>
);
}
Expand All @@ -129,34 +129,23 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState,

const {reportActions: unfilteredReportActions} = usePaginatedReportActions(reportID);

const reportActions = useMemo(() => {
return getFilteredReportActionsForReportView(unfilteredReportActions);
}, [unfilteredReportActions]);
const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions);

const reportTransactions = useReportTransactionsCollection(reportID);
const transactions = useMemo(() => getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true), [reportTransactions, reportActions, isOffline]);
const transactions = getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true);

const visibleTransactions = useMemo(() => {
if (isOffline) {
return transactions;
}

// When there are no pending delete transactions, which is most of the time, we can return the same transactions keeping the same reference avoiding extra work
const hasPendingDelete = transactions.some((transaction) => transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
if (!hasPendingDelete) {
return transactions;
}

return transactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
}, [transactions, isOffline]);
// When there are no pending delete transactions, which is most of the time, return the same transactions to keep the same reference and avoid extra work.
const hasPendingDelete = transactions.some((transaction) => transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
const visibleTransactions =
isOffline || !hasPendingDelete ? transactions : transactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
const reportErrors = visibleTransactions.length === 1 && visibleTransactions.at(0)?.errors ? undefined : allReportErrors;
const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID);
const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs);

const isReportLoadPending = useIsReportLoadPending(reportID);
const dismissReportCreationError = useCallback(() => {
const dismissReportCreationError = () => {
goBackFromSearchMoneyRequest({afterTransition: () => removeFailedReport(reportID)});
}, [reportID]);
};

// Special case handling a report that is a transaction thread
// If true we will use the standard `ReportActionsList` to display report data and a special header, anything else is handled via `MoneyRequestReportActionsList`
Expand All @@ -174,33 +163,29 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState,
const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`);
const shouldShowWideRHPReceipt = visibleTransactions.length === 1 && !isSmallScreenWidth && !!transactionThreadReport;

const reportHeaderView = useMemo(
() =>
isTransactionThreadView ? (
<MoneyRequestHeader
reportID={report?.reportID}
onBackButtonPress={() => {
if (!backToRoute) {
goBackFromSearchMoneyRequest();
return;
}
Navigation.goBack(backToRoute);
}}
/>
) : (
<MoneyReportHeader
reportID={report?.reportID}
shouldDisplayBackButton
onBackButtonPress={() => {
if (!backToRoute) {
goBackFromSearchMoneyRequest();
return;
}
Navigation.goBack(backToRoute);
}}
/>
),
[backToRoute, isTransactionThreadView, report?.reportID],
const reportHeaderView = isTransactionThreadView ? (
<MoneyRequestHeader
reportID={report?.reportID}
onBackButtonPress={() => {
if (!backToRoute) {
goBackFromSearchMoneyRequest();
return;
}
Navigation.goBack(backToRoute);
}}
/>
) : (
<MoneyReportHeader
reportID={report?.reportID}
shouldDisplayBackButton
onBackButtonPress={() => {
if (!backToRoute) {
goBackFromSearchMoneyRequest();
return;
}
Navigation.goBack(backToRoute);
}}
/>
);

// We need to cancel telemetry span when user leaves the screen before full report data is loaded
Expand All @@ -220,7 +205,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState,
}

if (shouldShowEmptyActionsSkeleton) {
return <ReportActionsSkeletonView shouldAnimate={false} />;
return <ReportActionsSkeletonCover />;
}

if (!report) {
Expand All @@ -230,8 +215,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState,
if (shouldShowAppLoadSkeleton) {
return (
<View style={styles.flex1}>
<ReportHeaderSkeletonView />
<ReportActionsSkeletonView />
<InitialLoadingSkeleton styles={styles} />
{shouldDisplayReportFooter ? <ReportFooter /> : null}
</View>
);
Expand Down
48 changes: 48 additions & 0 deletions src/components/ReportActionsSkeletonCover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import useThemeStyles from '@hooks/useThemeStyles';

import type {ReactNode} from 'react';

import React from 'react';
import {View} from 'react-native';

import ReportActionsSkeletonView from './ReportActionsSkeletonView';

type ReportActionsSkeletonContainerProps = {
/** The skeleton content to place at the bottom of the report viewport */
children: ReactNode;
};

/** Fills the report-actions viewport with a consistently positioned static loading skeleton. */
function ReportActionsSkeletonCover() {
return (
<ReportActionsSkeletonContainer>
<ReportActionsSkeletonView shouldAnimate={false} />
</ReportActionsSkeletonContainer>
);
}

/** Fills the report-actions viewport with a consistently positioned animated loading skeleton. */
function ReportActionsAnimatedSkeletonCover() {
return (
<ReportActionsSkeletonContainer>
<ReportActionsSkeletonView shouldAnimate />
</ReportActionsSkeletonContainer>
);
}

function ReportActionsSkeletonContainer({children}: ReportActionsSkeletonContainerProps) {
const styles = useThemeStyles();

return (
<View
pointerEvents="none"
testID="ReportActionsSkeletonCover"
style={[styles.flex1, styles.appBG, styles.overflowHidden, styles.justifyContentEnd, styles.pb4]}
>
{children}
</View>
);
}

export {ReportActionsAnimatedSkeletonCover};
export default ReportActionsSkeletonCover;
3 changes: 2 additions & 1 deletion src/pages/inbox/report/ReportActionsList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView';
import InvertedFlashList from '@components/FlashList/InvertedFlashList';
import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner';
import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover';
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';

import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime';
Expand Down Expand Up @@ -449,7 +450,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct
// It narrows `report` to non-undefined for the render below and stays a safe fallback if the report
// is cleared mid-session while the latch keeps the content mounted.
if (!report) {
return <ReportActionsSkeletonView />;
return <ReportActionsAnimatedSkeletonCover />;
}

return (
Expand Down
6 changes: 4 additions & 2 deletions src/pages/inbox/report/ReportActionsLoadingSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';
import ReportActionsSkeletonCover, {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover';

import useCancelSendMessageSpanOnSkeleton from '@hooks/useCancelSendMessageSpanOnSkeleton';
import type {SkeletonName} from '@hooks/useCancelSendMessageSpanOnSkeleton';
Expand Down Expand Up @@ -28,7 +28,9 @@ type ReportActionsLoadingSkeletonProps = {
function ReportActionsLoadingSkeleton({reportID, skeletonName, shouldAnimate = true, shouldMarkOpenReportEnd = true}: ReportActionsLoadingSkeletonProps) {
useCancelSendMessageSpanOnSkeleton(reportID, skeletonName);
useMarkOpenReportEndOnSkeleton(reportID, shouldMarkOpenReportEnd);
return <ReportActionsSkeletonView shouldAnimate={shouldAnimate} />;
const SkeletonCover = shouldAnimate ? ReportActionsAnimatedSkeletonCover : ReportActionsSkeletonCover;

return <SkeletonCover />;
}

ReportActionsLoadingSkeleton.displayName = 'ReportActionsLoadingSkeleton';
Expand Down
56 changes: 52 additions & 4 deletions tests/ui/MoneyRequestReportViewTest.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import {render} from '@testing-library/react-native';
import {render, screen} from '@testing-library/react-native';

import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList';
import MoneyRequestReportView from '@components/MoneyRequestReportView/MoneyRequestReportView';
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';
import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView';

import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
Expand Down Expand Up @@ -34,11 +37,15 @@ jest.mock('@hooks/useOnyx', () => jest.fn());
jest.mock('@hooks/useResponsiveLayout', () => jest.fn());
jest.mock('@hooks/usePaginatedReportActions', () => jest.fn());
jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn());
jest.mock('@hooks/useInFlightRequests', () => ({
useIsAppLoadPending: jest.fn(),
useIsReportLoadPending: jest.fn(),
}));

// useThemeStyles throws without a <ThemeStylesProvider>; return a proxy that yields an empty style object
// for any key so the (mostly-mocked) tree renders without wiring up the full provider stack.
// useThemeStyles throws without a <ThemeStylesProvider>; return empty styles for most keys and
// a fixed header height for the app-loading layout assertion.
jest.mock('@hooks/useThemeStyles', () => {
const styleProxy = new Proxy({}, {get: () => ({})});
const styleProxy = new Proxy({}, {get: (_target, key) => (key === 'headerBarHeight' ? {height: 80} : {})});
return jest.fn(() => styleProxy);
});

Expand All @@ -54,19 +61,24 @@ jest.mock('@components/MoneyReportHeader', () => jest.fn(() => null));
jest.mock('@components/MoneyRequestHeader', () => jest.fn(() => null));
jest.mock('@components/CollapsibleHeaderOnKeyboard', () => jest.fn(() => null));
jest.mock('@components/ReportActionItem/MoneyRequestReceiptView', () => jest.fn(() => null));
jest.mock('@components/ReportActionsSkeletonView', () => jest.fn(() => null));
jest.mock('@components/ReportHeaderSkeletonView', () => jest.fn(() => null));
jest.mock('@pages/inbox/report/ReportFooter', () => jest.fn(() => null));
jest.mock('@components/OfflineWithFeedback', () => {
const reactModule = jest.requireActual<typeof React>('react');
return jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children));
});

const mockUseNetwork = useNetwork as jest.MockedFunction<typeof useNetwork>;
const mockUseIsAppLoadPending = jest.mocked(useIsAppLoadPending);
const mockUseIsReportLoadPending = jest.mocked(useIsReportLoadPending);
const mockUseOnyx = useOnyx as jest.MockedFunction<typeof useOnyx>;
const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction<typeof useResponsiveLayout>;
const mockUsePaginatedReportActions = usePaginatedReportActions as jest.MockedFunction<typeof usePaginatedReportActions>;
const mockUseReportTransactionsCollection = useReportTransactionsCollection as jest.MockedFunction<typeof useReportTransactionsCollection>;
const mockMoneyRequestReportActionsList = MoneyRequestReportActionsList as jest.MockedFunction<typeof MoneyRequestReportActionsList>;
const mockReportActionsListBody = ReportActionsList as jest.MockedFunction<typeof ReportActionsList>;
const mockReportActionsSkeletonView = jest.mocked(ReportActionsSkeletonView);
const mockUserTypingEventListener = UserTypingEventListener as jest.MockedFunction<typeof UserTypingEventListener>;

const defaultPaginatedReportActionsResult: ReturnType<typeof usePaginatedReportActions> = {
Expand Down Expand Up @@ -134,6 +146,8 @@ describe('MoneyRequestReportView', () => {
jest.clearAllMocks();

mockUseNetwork.mockReturnValue({isOffline: false});
mockUseIsAppLoadPending.mockReturnValue(false);
mockUseIsReportLoadPending.mockReturnValue(false);
mockUsePaginatedReportActions.mockReturnValue(defaultPaginatedReportActionsResult);
mockUseReportTransactionsCollection.mockReturnValue({});
mockUseResponsiveLayout.mockReturnValue({
Expand Down Expand Up @@ -176,6 +190,40 @@ describe('MoneyRequestReportView', () => {
expect(MoneyRequestReportUtils.shouldWaitForTransactions).toHaveBeenLastCalledWith(mockReport, [], mockReportLoadingState, false, false);
});

it('uses the bottom-padded cover while the report is waiting for transactions', () => {
jest.spyOn(MoneyRequestReportUtils, 'shouldWaitForTransactions').mockReturnValue(true);

renderMoneyRequestReportView(jest.fn());

expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy();
expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true}));
expect(mockReportActionsListBody).not.toHaveBeenCalled();
expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled();
});

it('uses a static bottom-padded cover while report actions are empty', () => {
jest.spyOn(ReportActionsUtils, 'getFilteredReportActionsForReportView').mockReturnValue([]);

renderMoneyRequestReportView(jest.fn());

expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy();
expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false}));
expect(mockReportActionsListBody).not.toHaveBeenCalled();
expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled();
});

it('uses the bottom-padded cover while the app is loading', () => {
mockUseIsAppLoadPending.mockReturnValue(true);

renderMoneyRequestReportView(jest.fn());

expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy();
expect(screen.UNSAFE_getByType(ReportHeaderSkeletonView).parent).toHaveStyle({height: 80});
expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true}));
expect(mockReportActionsListBody).not.toHaveBeenCalled();
expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled();
});

it('mounts the chat list body and the typing listener (not the table view) for a transaction-thread report', () => {
const onLayout = jest.fn();

Expand Down
2 changes: 2 additions & 0 deletions tests/ui/ReportActionsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ describe('ReportActions (orchestrator)', () => {

render(<ReportActions />);

expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy();
expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
expect(mockReportActionsListBody).not.toHaveBeenCalled();
expect(mockMoneyRequestList).not.toHaveBeenCalled();
Expand All @@ -159,6 +160,7 @@ describe('ReportActions (orchestrator)', () => {

render(<ReportActions />);

expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy();
expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy();
expect(mockReportActionsListBody).not.toHaveBeenCalled();
expect(mockMoneyRequestList).not.toHaveBeenCalled();
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/ReportActionsSkeletonCoverTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {render, screen} from '@testing-library/react-native';

import ReportActionsSkeletonCover, {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover';
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';

import React from 'react';

jest.mock('@components/ReportActionsSkeletonView', () => jest.fn(() => null));

const mockReportActionsSkeletonView = jest.mocked(ReportActionsSkeletonView);

describe('ReportActionsSkeletonCover', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('fills, clips, and bottom-aligns the static report-actions skeleton', () => {
render(<ReportActionsSkeletonCover />);

expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({
flex: 1,
overflow: 'hidden',
justifyContent: 'flex-end',
paddingBottom: 16,
});
expect(mockReportActionsSkeletonView).toHaveBeenCalledTimes(1);
expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false}));
});

it('fills the report-actions viewport with an animated skeleton', () => {
render(<ReportActionsAnimatedSkeletonCover />);

expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy();
expect(mockReportActionsSkeletonView).toHaveBeenCalledTimes(1);
expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true}));
});
});
Loading