Skip to content

[Better Expense Reports] Add MoneyRequestReportView #58360

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
5 changes: 5 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,11 @@ const CONST = {
},
THREAD_DISABLED: ['CREATED'],
},
TRANSACTION_LIST: {
COLUMNS: {
COMMENTS: 'comments',
},
},
CANCEL_PAYMENT_REASONS: {
ADMIN: 'CANCEL_REASON_ADMIN',
},
Expand Down
9 changes: 7 additions & 2 deletions src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,14 @@ type MoneyReportHeaderProps = {
// eslint-disable-next-line react/no-unused-prop-types
transactionThreadReportID: string | undefined;

/** Whether back button should be displayed in header */
shouldDisplayBackButton?: boolean;

/** Method to trigger when pressing close button of the header */
onBackButtonPress: () => void;
};

function MoneyReportHeader({policy, report: moneyRequestReport, transactionThreadReportID, reportActions, onBackButtonPress}: MoneyReportHeaderProps) {
function MoneyReportHeader({policy, report: moneyRequestReport, transactionThreadReportID, reportActions, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) {
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use a correct layout for the hold expense modal https://github.com/Expensify/App/pull/47990#issuecomment-2362382026
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
Expand Down Expand Up @@ -377,6 +380,8 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
setIsDeleteRequestModalVisible(false);
}, [canDeleteRequest]);

const shouldShowBackButton = shouldDisplayBackButton || shouldUseNarrowLayout;

return (
<View style={[styles.pt0, styles.borderBottom]}>
<HeaderWithBackButton
Expand All @@ -385,7 +390,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
shouldShowPinButton={false}
report={moneyRequestReport}
policy={policy}
shouldShowBackButton={shouldUseNarrowLayout}
shouldShowBackButton={shouldShowBackButton}
shouldDisplaySearchRouter={shouldDisplaySearchRouter}
onBackButtonPress={onBackButtonPress}
shouldShowBorderBottom={false}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import type {ListRenderItemInfo} from '@react-native/virtualized-lists/Lists/VirtualizedList';
import React, {useCallback, useMemo} from 'react';
import {InteractionManager, View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import FlatList from '@components/FlatList';
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';
import useLoadReportActions from '@hooks/useLoadReportActions';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {
getFirstVisibleReportActionID,
getMostRecentIOURequestActionID,
getOneTransactionThreadReportID,
hasNextActionMadeBySameActor,
isConsecutiveChronosAutomaticTimerAction,
isDeletedParentAction,
isReversedTransaction,
isTransactionThread,
shouldReportActionBeVisible,
} from '@libs/ReportActionsUtils';
import {canUserPerformWriteAction, chatIncludesChronosWithID, isCanceledTaskReport, isExpenseReport, isInvoiceReport, isIOUReport, isTaskReport} from '@libs/ReportUtils';
import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute';
import ReportActionsListItemRenderer from '@pages/home/report/ReportActionsListItemRenderer';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
import type Transaction from '@src/types/onyx/Transaction';
import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList';

/**
* In this view we are not handling the special single transaction case, we're just handling the report
*/
const EmptyParentReportActionForTransactionThread = undefined;

type MoneyRequestReportListProps = {
/** The report */
report: OnyxTypes.Report;

/** Array of report actions for this report */
reportActions?: OnyxTypes.ReportAction[];

/** If the report has newer actions to load */
hasNewerActions: boolean;

/** If the report has older actions to load */
hasOlderActions: boolean;
};

function getParentReportAction(parentReportActions: OnyxEntry<OnyxTypes.ReportActions>, parentReportActionID: string | undefined): OnyxEntry<OnyxTypes.ReportAction> {
if (!parentReportActions || !parentReportActionID) {
return;
}
return parentReportActions[parentReportActionID];
}

function isChatOnlyReportAction(action: OnyxTypes.ReportAction) {
return action.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU && action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED;
}

function getTransactionsForReportID(transactions: OnyxCollection<OnyxTypes.Transaction>, reportID: string) {
return Object.values(transactions ?? {}).filter((transaction): transaction is Transaction => {
return transaction?.reportID === reportID;
});
}

/**
* TODO make this component have the same functionalities as `ReportActionsList`
* - onLayout
* - onScroll
* - onScrollToIndexFailed
* - shouldEnableAutoScrollToTopThreshold
* - shouldDisplayNewMarker
* - shouldHideThreadDividerLine
*/
function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActions, hasOlderActions}: MoneyRequestReportListProps) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if could we reuse ReportActionList here to avoid duplicated code

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can consider this suggestion in another follow-up PR

const styles = useThemeStyles();
const {isOffline} = useNetwork();

const reportID = report?.reportID;

const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(report?.parentReportID)}`, {
canEvict: false,
selector: (parentReportActions) => getParentReportAction(parentReportActions, report?.parentReportActionID),
});

const mostRecentIOUReportActionID = useMemo(() => getMostRecentIOURequestActionID(reportActions), [reportActions]);
const transactionThreadReportID = getOneTransactionThreadReportID(reportID, reportActions ?? [], false);
const firstVisibleReportActionID = useMemo(() => getFirstVisibleReportActionID(reportActions, isOffline), [reportActions, isOffline]);
const [transactions = []] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {
selector: (allTransactions): OnyxTypes.Transaction[] => getTransactionsForReportID(allTransactions, reportID),
});
const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID ?? CONST.DEFAULT_NUMBER_ID}`);

const canPerformWriteAction = canUserPerformWriteAction(report);

// We are reversing actions because in this View we are starting at the top and don't use Inverted list
const visibleReportActions = useMemo(() => {
const filteredActions = reportActions.filter((reportAction) => {
const isChatAction = isChatOnlyReportAction(reportAction);

return (
isChatAction &&
(isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) &&
shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction)
);
});

return filteredActions.toReversed();
}, [reportActions, isOffline, canPerformWriteAction]);

const reportActionIDs = useMemo(() => {
return reportActions?.map((action) => action.reportActionID) ?? [];
}, [reportActions]);

const {loadOlderChats, loadNewerChats} = useLoadReportActions({
reportID,
reportActions,
allReportActionIDs: reportActionIDs,
transactionThreadReport,
hasOlderActions,
hasNewerActions,
});

const onStartReached = useCallback(() => {
if (!isSearchTopmostFullScreenRoute()) {
loadNewerChats(false);
return;
}

InteractionManager.runAfterInteractions(() => requestAnimationFrame(() => loadNewerChats(false)));
}, [loadNewerChats]);

const onEndReached = useCallback(() => {
loadOlderChats(false);
}, [loadOlderChats]);

const shouldUseThreadDividerLine = useMemo(() => {
const topReport = reportActions.length > 0 ? reportActions.at(reportActions.length - 1) : null;

if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) {
return false;
}

if (isTransactionThread(parentReportAction)) {
return !isDeletedParentAction(parentReportAction) && !isReversedTransaction(parentReportAction);
}

if (isTaskReport(report)) {
return !isCanceledTaskReport(report, parentReportAction);
}

return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report);
}, [parentReportAction, report, reportActions]);

const renderItem = useCallback(
({item: reportAction, index}: ListRenderItemInfo<OnyxTypes.ReportAction>) => {
const displayAsGroup =
!isConsecutiveChronosAutomaticTimerAction(visibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID)) &&
hasNextActionMadeBySameActor(visibleReportActions, index);

return (
<ReportActionsListItemRenderer
reportAction={reportAction}
reportActions={reportActions}
parentReportAction={parentReportAction}
parentReportActionForTransactionThread={EmptyParentReportActionForTransactionThread}
index={index}
report={report}
transactionThreadReport={transactionThreadReport}
displayAsGroup={displayAsGroup}
mostRecentIOUReportActionID={mostRecentIOUReportActionID}
shouldDisplayNewMarker={false}
shouldHideThreadDividerLine
shouldUseThreadDividerLine={shouldUseThreadDividerLine}
shouldDisplayReplyDivider={visibleReportActions.length > 1}
isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID}
/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed passing linkedReportActionID here for message highlighting when clicking header to go back to a linkedReportAction, leading to this issue:

);
},
[visibleReportActions, reportActions, parentReportAction, report, transactionThreadReport, mostRecentIOUReportActionID, shouldUseThreadDividerLine, firstVisibleReportActionID],
);

return (
<View style={styles.flex1}>
{report ? (
<FlatList
accessibilityLabel="Test"
testID="report-actions-list"
style={styles.overscrollBehaviorContain}
data={visibleReportActions}
renderItem={renderItem}
keyExtractor={(item) => item.reportActionID}
initialNumToRender={10}
onEndReached={onEndReached}
onEndReachedThreshold={0.75}
onStartReached={onStartReached}
onStartReachedThreshold={0.75}
ListHeaderComponent={<MoneyRequestReportTransactionList transactions={transactions} />}
keyboardShouldPersistTaps="handled"
/>
) : (
<ReportActionsSkeletonView />
)}
</View>
);
}

MoneyRequestReportActionsList.displayName = 'MoneyRequestReportActionsList';

export default MoneyRequestReportActionsList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react';
import {View} from 'react-native';
import type {SearchColumnType, SortOrder} from '@components/Search/types';
import SortableTableHeader from '@components/SelectionList/SortableTableHeader';
import type {SortableColumnName} from '@components/SelectionList/types';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';

type ColumnConfig = {
columnName: SortableColumnName;
translationKey: TranslationPaths | undefined;
isColumnSortable?: boolean;
};

const columnConfig: ColumnConfig[] = [
{
columnName: CONST.SEARCH.TABLE_COLUMNS.RECEIPT,
translationKey: 'common.receipt',
isColumnSortable: false,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.TYPE,
translationKey: 'common.type',
isColumnSortable: false,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.DATE,
translationKey: 'common.date',
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.MERCHANT,
translationKey: 'common.merchant',
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.CATEGORY,
translationKey: 'common.category',
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.TAG,
translationKey: 'common.tag',
},
{
columnName: CONST.REPORT.TRANSACTION_LIST.COLUMNS.COMMENTS,
translationKey: undefined, // comments have no title displayed
isColumnSortable: false,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT,
translationKey: 'common.total',
},
];

type SearchTableHeaderProps = {
sortBy?: SearchColumnType;
sortOrder?: SortOrder;
onSortPress: (column: SortableColumnName, order: SortOrder) => void;
shouldShowSorting: boolean;
};

// At this moment with new Report View we have no extra logic for displaying columns
const shouldShowColumn = () => true;

function MoneyRequestReportTableHeader({sortBy, sortOrder, onSortPress, shouldShowSorting}: SearchTableHeaderProps) {
const styles = useThemeStyles();

return (
<View style={[styles.ph8, styles.pv3]}>
<SortableTableHeader
columns={columnConfig}
shouldShowColumn={shouldShowColumn}
dateColumnSize="normal"
shouldShowSorting={shouldShowSorting}
sortBy={sortBy}
sortOrder={sortOrder}
onSortPress={onSortPress}
/>
</View>
);
}

MoneyRequestReportTableHeader.displayName = 'MoneyRequestReportTableHeader';

export default MoneyRequestReportTableHeader;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import {View} from 'react-native';
import TransactionItemRow from '@components/TransactionItemRow';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import type * as OnyxTypes from '@src/types/onyx';
import MoneyRequestReportTableHeader from './MoneyRequestReportTableHeader';

type MoneyRequestReportTransactionListProps = {
/** List of transactions belonging to one report */
transactions: OnyxTypes.Transaction[];
};

function MoneyRequestReportTransactionList({transactions}: MoneyRequestReportTransactionListProps) {
const styles = useThemeStyles();
const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout();

const displayNarrowVersion = isMediumScreenWidth || shouldUseNarrowLayout;

return (
<>
{!displayNarrowVersion && (
<MoneyRequestReportTableHeader
shouldShowSorting
sortBy="date"
sortOrder="desc"
onSortPress={() => {}}
/>
)}
<View style={[styles.pv2, styles.ph5]}>
{transactions.map((transaction) => {
return (
<View style={[styles.mb2]}>
<TransactionItemRow
transactionItem={transaction}
isSelected={false}
shouldShowTooltip
shouldUseNarrowLayout={displayNarrowVersion}
/>
</View>
);
})}
</View>
</>
);
}

MoneyRequestReportTransactionList.displayName = 'MoneyRequestReportTransactionList';

export default MoneyRequestReportTransactionList;
Loading
Loading