Skip to content

Add selection to table on MoneyRequestReport page #59433

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
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
df1e9f1
Add checkboxes to table
jnowakow Mar 26, 2025
1e4e8a8
Merge branch 'kicu/57508-improve-money-report-view' into jnowakow/575…
jnowakow Mar 28, 2025
aa7d938
Merge branch 'kicu/57508-improve-money-report-view' into jnowakow/575…
jnowakow Mar 31, 2025
a4f1e84
Add actions on selected transaction to the header
jnowakow Mar 31, 2025
cd3eae9
Add hold page
jnowakow Mar 31, 2025
024d3bd
Refresh actions on hold/unhold
jnowakow Mar 31, 2025
2b910eb
Small refactor
jnowakow Mar 31, 2025
4996cf8
Merge branch 'main' of github.com:Expensify/App into jnowakow/57508-a…
jnowakow Apr 1, 2025
35056a6
Rerender transactions list on transaction change
jnowakow Apr 1, 2025
bb135a7
Link follow-up issue and add new call
jnowakow Apr 1, 2025
97c2185
Merge branch 'main' of github.com:Expensify/App into jnowakow/57508-a…
jnowakow Apr 1, 2025
11a424a
Fix linter issues
jnowakow Apr 1, 2025
887a779
Fix linter and typecheck
jnowakow Apr 1, 2025
bd7eca8
Merge branch 'main' of github.com:Expensify/App into jnowakow/57508-a…
jnowakow Apr 2, 2025
22648d5
Fixes after review
jnowakow Apr 2, 2025
c5d8c27
Typescript & prettier
jnowakow Apr 2, 2025
59ba04c
Merge branch 'main' of github.com:Expensify/App into jnowakow/57508-a…
jnowakow Apr 2, 2025
fbb6d37
Merge branch 'main' of github.com:Expensify/App into jnowakow/57508-a…
jnowakow Apr 3, 2025
56ae9b4
Fix table UI and change method to hold/unhold functions
jnowakow Apr 3, 2025
7a4718c
fix eslint
jnowakow Apr 3, 2025
3a639da
Don not show checkboxes on mobile
jnowakow Apr 3, 2025
4374368
Merge branch 'main' of github.com:Expensify/App into jnowakow/57508-a…
jnowakow Apr 4, 2025
108ef07
Make transaction list react to merchant and messages count change
jnowakow Apr 4, 2025
34b4afd
Show hold option only for when one transaction is selected
jnowakow Apr 4, 2025
1f1f049
Merge branch 'main' of github.com:Expensify/App into jnowakow/57508-a…
jnowakow Apr 7, 2025
1366364
Merge branch 'main' of github.com:Expensify/App into jnowakow/57508-a…
jnowakow Apr 7, 2025
28954e5
fix padding on table header
jnowakow Apr 7, 2025
8e629ce
Update src/pages/Search/SearchMoneyRequestReportHoldReasonPage.tsx
jnowakow Apr 7, 2025
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
14 changes: 13 additions & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
import Onyx from 'react-native-onyx';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import type {Parameters} from 'storybook/internal/types';
import {MoneyRequestReportContextProvider} from '@components/MoneyRequestReportView/MoneyRequestReportContext';
import ComposeProviders from '@src/components/ComposeProviders';
import HTMLEngineProvider from '@src/components/HTMLEngineProvider';
import {LocaleContextProvider} from '@src/components/LocaleContextProvider';
Expand All @@ -21,7 +22,18 @@ Onyx.init({

const decorators = [
(Story: React.ElementType) => (
<ComposeProviders components={[OnyxProvider, LocaleContextProvider, HTMLEngineProvider, SafeAreaProvider, PortalProvider, EnvironmentProvider, KeyboardStateProvider]}>
<ComposeProviders
components={[
OnyxProvider,
LocaleContextProvider,
HTMLEngineProvider,
SafeAreaProvider,
PortalProvider,
EnvironmentProvider,
KeyboardStateProvider,
MoneyRequestReportContextProvider,
]}
>
<Story />
</ComposeProviders>
),
Expand Down
7 changes: 7 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ const ROUTES = {
return getUrlWithBackToParam(baseRoute, backTo);
},
},
SEARCH_MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS: {
route: 'search/r/:reportID/hold',
getRoute: ({reportID, backTo}: {reportID: string; backTo?: string}) => {
const baseRoute = `search/r/${reportID}/hold` as const;
return getUrlWithBackToParam(baseRoute, backTo);
},
},
TRANSACTION_HOLD_REASON_RHP: 'search/hold',

// This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const SCREENS = {
SEARCH: {
ROOT: 'Search_Root',
MONEY_REQUEST_REPORT: 'Search_Money_Request_Report',
MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS: 'Search_Money_Request_Report_Hold_Transactions',
REPORT_RHP: 'Search_Report_RHP',
ADVANCED_FILTERS_RHP: 'Search_Advanced_Filters_RHP',
ADVANCED_FILTERS_DATE_RHP: 'Search_Advanced_Filters_Date_RHP',
Expand Down
161 changes: 150 additions & 11 deletions src/components/MoneyReportHeader.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {isActionVisibleOnMoneyRequestReport} from '@libs/MoneyRequestReportUtils';
import {
getFirstVisibleReportActionID,
getIOUActionForTransactionID,
getMostRecentIOURequestActionID,
getOneTransactionThreadReportID,
hasNextActionMadeBySameActor,
Expand Down Expand Up @@ -70,9 +71,13 @@ function getParentReportAction(parentReportActions: OnyxEntry<OnyxTypes.ReportAc
return parentReportActions[parentReportActionID];
}

function selectTransactionsForReportID(transactions: OnyxCollection<OnyxTypes.Transaction>, reportID: string) {
function selectTransactionsForReportID(transactions: OnyxCollection<OnyxTypes.Transaction>, reportID: string, reportActions: OnyxTypes.ReportAction[]) {
return Object.values(transactions ?? {}).filter((transaction): transaction is Transaction => {
return transaction?.reportID === reportID;
if (!transaction) {
return false;
}
const action = getIOUActionForTransactionID(reportActions, transaction.transactionID);
return transaction.reportID === reportID && !isDeletedParentAction(action);
});
}

Expand All @@ -97,7 +102,7 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
const transactionThreadReportID = getOneTransactionThreadReportID(reportID, reportActions ?? [], false);
const firstVisibleReportActionID = useMemo(() => getFirstVisibleReportActionID(reportActions, isOffline), [reportActions, isOffline]);
const [transactions = []] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {
selector: (allTransactions): OnyxTypes.Transaction[] => selectTransactionsForReportID(allTransactions, reportID),
selector: (allTransactions): OnyxTypes.Transaction[] => selectTransactionsForReportID(allTransactions, reportID, reportActions),
});
const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID ?? CONST.DEFAULT_NUMBER_ID}`);
const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React, {useCallback, useContext, useMemo, useState} from 'react';
import type ChildrenProps from '@src/types/utils/ChildrenProps';

type TMoneyRequestReportContext = {
selectedTransactionsID: Record<string, string[]>;
setSelectedTransactionsID: (reportID: string) => (transactionsID: string[]) => void;
toggleTransaction: (reportID: string) => (transactionID: string) => void;
removeTransaction: (reportID: string) => (transactionID?: string) => void;
isTransactionSelected: (reportID: string) => (transactionID: string) => boolean;
};

const defaultMoneyRequestReportContext = {
selectedTransactionsID: {},
setSelectedTransactionsID: () => () => {},
toggleTransaction: () => () => {},
removeTransaction: () => () => {},
isTransactionSelected: () => () => false,
};

const Context = React.createContext<TMoneyRequestReportContext>(defaultMoneyRequestReportContext);

// TODO merge it with SearchContext in follow-up - https://github.com/Expensify/App/issues/59431
function MoneyRequestReportContextProvider({children}: ChildrenProps) {
const [selectedTransactionsForReport, setSelectedTransactionsForReport] = useState<Record<string, string[]>>({});

const setSelectedTransactionsID = useCallback(
(reportID: string) => (transactionsID: string[]) => {
setSelectedTransactionsForReport((prev) => ({...prev, [reportID]: transactionsID}));
},
[],
);

const toggleTransaction = useCallback(
(reportID: string) => (transactionID: string) => {
setSelectedTransactionsForReport((prev) => {
const prevTransactions = prev[reportID] ?? [];
if (prevTransactions.includes(transactionID)) {
return {...prev, [reportID]: prevTransactions.filter((t) => t !== transactionID)};
}
return {...prev, [reportID]: [...prevTransactions, transactionID]};
});
},
[],
);

const removeTransaction = useCallback(
(reportID: string) => (transactionID?: string) => {
setSelectedTransactionsForReport((prev) => {
const prevTransactions = prev[reportID] ?? [];
return {...prev, [reportID]: prevTransactions.filter((t) => t !== transactionID)};
});
},
[],
);

const isTransactionSelected = useCallback(
(reportID: string) => (transactionID: string) => (selectedTransactionsForReport[reportID] ?? []).includes(transactionID),
[selectedTransactionsForReport],
);

const context = useMemo(
() => ({
selectedTransactionsID: selectedTransactionsForReport,
setSelectedTransactionsID,
toggleTransaction,
isTransactionSelected,
removeTransaction,
}),
[isTransactionSelected, removeTransaction, selectedTransactionsForReport, setSelectedTransactionsID, toggleTransaction],
);

return <Context.Provider value={context}>{children}</Context.Provider>;
}

function useMoneyRequestReportContext(reportID?: string) {
const context = useContext(Context);

return useMemo(
() => ({
selectedTransactionsID: reportID ? context.selectedTransactionsID[reportID] ?? [] : [],
setSelectedTransactionsID: reportID ? context.setSelectedTransactionsID(reportID) : () => {},
toggleTransaction: reportID ? context.toggleTransaction(reportID) : () => {},
isTransactionSelected: reportID ? context.isTransactionSelected(reportID) : () => false,
removeTransaction: reportID ? context.removeTransaction(reportID) : () => {},
}),
[context, reportID],
);
}

MoneyRequestReportContextProvider.displayName = 'MoneyRequestReportContextProvider';

export {MoneyRequestReportContextProvider, useMoneyRequestReportContext};
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function MoneyRequestReportTableHeader({sortBy, sortOrder, onSortPress, dateColu
const styles = useThemeStyles();

return (
<View style={[styles.peopleRow, styles.listTableHeader, styles.pt4]}>
<View style={[styles.dFlex, styles.flex5]}>
<SortableTableHeader
columns={columnConfig}
shouldShowColumn={shouldShowColumn}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import isEmpty from 'lodash/isEmpty';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import React, {memo, useCallback, useMemo, useState} from 'react';
import {View} from 'react-native';
import type {TupleToUnion} from 'type-fest';
import {getButtonRole} from '@components/Button/utils';
import Checkbox from '@components/Checkbox';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import type {SortOrder} from '@components/Search/types';
import Text from '@components/Text';
Expand All @@ -11,6 +12,7 @@ import useHover from '@hooks/useHover';
import useLocalize from '@hooks/useLocalize';
import {useMouseContext} from '@hooks/useMouseContext';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import {convertToDisplayString} from '@libs/CurrencyUtils';
import {getThreadReportIDsForTransactions} from '@libs/MoneyRequestReportUtils';
Expand All @@ -19,9 +21,11 @@ import {getMoneyRequestSpendBreakdown} from '@libs/ReportUtils';
import {compareValues} from '@libs/SearchUIUtils';
import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransactionYear';
import Navigation from '@navigation/Navigation';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
import {useMoneyRequestReportContext} from './MoneyRequestReportContext';
import MoneyRequestReportTableHeader from './MoneyRequestReportTableHeader';
import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState';
import {setActiveTransactionReportIDs} from './TransactionReportIDRepository';
Expand Down Expand Up @@ -50,7 +54,6 @@ const sortableColumnNames = [
type SortableColumnName = TupleToUnion<typeof sortableColumnNames>;

type SortedTransactions = {
transactions: OnyxTypes.Transaction[];
sortBy: SortableColumnName;
sortOrder: SortOrder;
};
Expand All @@ -62,18 +65,9 @@ const getTransactionKey = (transaction: OnyxTypes.Transaction, key: SortableColu
return key === CONST.SEARCH.TABLE_COLUMNS.DATE ? dateKey : key;
};

const areTransactionValuesEqual = (transactions: OnyxTypes.Transaction[], key: SortableColumnName) => {
const firstValidTransaction = transactions.find((transaction) => transaction !== undefined);
if (!firstValidTransaction) {
return true;
}

const keyOfFirstValidTransaction = getTransactionKey(firstValidTransaction, key);
return transactions.every((transaction) => transaction[getTransactionKey(transaction, key)] === firstValidTransaction[keyOfFirstValidTransaction]);
};

function MoneyRequestReportTransactionList({report, transactions, reportActions, hasComments}: MoneyRequestReportTransactionListProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout();
const displayNarrowVersion = isMediumScreenWidth || shouldUseNarrowLayout;
Expand All @@ -86,29 +80,23 @@ function MoneyRequestReportTransactionList({report, transactions, reportActions,
const {bind} = useHover();
const {isMouseDownOnInput, setMouseUp} = useMouseContext();

const {selectedTransactionsID, setSelectedTransactionsID, toggleTransaction, isTransactionSelected} = useMoneyRequestReportContext(report.reportID);

const handleMouseLeave = (e: React.MouseEvent<Element, MouseEvent>) => {
bind.onMouseLeave();
e.stopPropagation();
setMouseUp();
};

const [sortedData, setSortedData] = useState<SortedTransactions>({
transactions,
const [sortConfig, setSortConfig] = useState<SortedTransactions>({
sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE,
sortOrder: CONST.SEARCH.SORT_ORDER.ASC,
});

const {sortBy, sortOrder} = sortedData;
const {sortBy, sortOrder} = sortConfig;

useEffect(() => {
if (areTransactionValuesEqual(transactions, sortBy)) {
return;
}

setSortedData((prevState) => ({
...prevState,
transactions: [...transactions].sort((a, b) => compareValues(a[getTransactionKey(a, sortBy)], b[getTransactionKey(b, sortBy)], sortOrder, sortBy)),
}));
const sortedTransactions = useMemo(() => {
return [...transactions].sort((a, b) => compareValues(a[getTransactionKey(a, sortBy)], b[getTransactionKey(b, sortBy)], sortOrder, sortBy));
}, [sortBy, sortOrder, transactions]);

const navigateToTransaction = useCallback(
Expand All @@ -123,12 +111,12 @@ function MoneyRequestReportTransactionList({report, transactions, reportActions,

// Single transaction report will open in RHP, and we need to find every other report ID for the rest of transactions
// to display prev/next arrows in RHP for navigating between transactions
const sortedSiblingTransactionReportIDs = getThreadReportIDsForTransactions(reportActions, sortedData.transactions);
const sortedSiblingTransactionReportIDs = getThreadReportIDsForTransactions(reportActions, sortedTransactions);
setActiveTransactionReportIDs(sortedSiblingTransactionReportIDs);

Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: reportIDToNavigate, backTo}));
},
[reportActions, sortedData.transactions],
[reportActions, sortedTransactions],
);

const dateColumnSize = useMemo(() => {
Expand All @@ -143,22 +131,37 @@ function MoneyRequestReportTransactionList({report, transactions, reportActions,
return !isEmpty(transactions) ? (
<>
{!displayNarrowVersion && (
<MoneyRequestReportTableHeader
shouldShowSorting
sortBy={sortBy}
sortOrder={sortOrder}
dateColumnSize={dateColumnSize}
onSortPress={(selectedSortBy, selectedSortOrder) => {
if (!isSortableColumnName(selectedSortBy)) {
return;
}

setSortedData((prevState) => ({...prevState, sortBy: selectedSortBy, sortOrder: selectedSortOrder}));
}}
/>
<View style={[styles.dFlex, styles.flexRow, styles.ph5, styles.pv3, StyleUtils.getPaddingBottom(6), styles.justifyContentCenter, styles.alignItemsCenter]}>
<View style={[styles.pv2, styles.pr4, StyleUtils.getPaddingLeft(variables.w12)]}>
<Checkbox
onPress={() => {
if (selectedTransactionsID.length === transactions.length) {
setSelectedTransactionsID([]);
} else {
setSelectedTransactionsID(transactions.map((t) => t.transactionID));
}
}}
accessibilityLabel={CONST.ROLE.CHECKBOX}
isChecked={selectedTransactionsID.length === transactions.length}
/>
</View>
<MoneyRequestReportTableHeader
shouldShowSorting
sortBy={sortBy}
sortOrder={sortOrder}
dateColumnSize={dateColumnSize}
onSortPress={(selectedSortBy, selectedSortOrder) => {
if (!isSortableColumnName(selectedSortBy)) {
return;
}

setSortConfig((prevState) => ({...prevState, sortBy: selectedSortBy, sortOrder: selectedSortOrder}));
}}
/>
</View>
)}
<View style={[listHorizontalPadding, styles.gap2, styles.pb4, displayNarrowVersion && styles.pt4]}>
{sortedData.transactions.map((transaction) => {
{sortedTransactions.map((transaction) => {
return (
<PressableWithFeedback
onPress={(e) => {
Expand All @@ -180,11 +183,12 @@ function MoneyRequestReportTransactionList({report, transactions, reportActions,
>
<TransactionItemRow
transactionItem={transaction}
isSelected={false}
isSelected={isTransactionSelected(transaction.transactionID)}
shouldShowTooltip
dateColumnSize={dateColumnSize}
shouldUseNarrowLayout={displayNarrowVersion}
shouldShowChatBubbleComponent
onCheckboxPress={toggleTransaction}
/>
</PressableWithFeedback>
);
Expand Down Expand Up @@ -230,4 +234,4 @@ function MoneyRequestReportTransactionList({report, transactions, reportActions,

MoneyRequestReportTransactionList.displayName = 'MoneyRequestReportTransactionList';

export default MoneyRequestReportTransactionList;
Copy link
Contributor

Choose a reason for hiding this comment

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

why do we need to use memo here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The logic behind sorting is quite complicated and as it's done in useEffect we want to minimise numer of unnecessary re-renders of this component.

export default memo(MoneyRequestReportTransactionList);
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe
<MoneyReportHeader
report={report}
policy={policy}
reportActions={[]}
reportActions={reportActions}
transactionThreadReportID={undefined}
shouldDisplayBackButton
onBackButtonPress={() => {
Expand Down
Loading