Skip to content

Commit

Permalink
Merge pull request #51913 from Expensify/cmartins-addBulkActions
Browse files Browse the repository at this point in the history
Add bulk actions
  • Loading branch information
deetergp authored Nov 28, 2024
2 parents 05a9e52 + ba9811d commit aba4d32
Show file tree
Hide file tree
Showing 11 changed files with 162 additions and 25 deletions.
2 changes: 2 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5979,6 +5979,8 @@ const CONST = {
},
BULK_ACTION_TYPES: {
EXPORT: 'export',
APPROVE: 'approve',
PAY: 'pay',
HOLD: 'hold',
UNHOLD: 'unhold',
DELETE: 'delete',
Expand Down
11 changes: 6 additions & 5 deletions src/components/Search/SearchContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, {useCallback, useContext, useMemo, useState} from 'react';
import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
import {isMoneyRequestReport} from '@libs/ReportUtils';
import * as SearchUIUtils from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import type {SearchContext, SelectedTransactions} from './types';

Expand All @@ -22,13 +24,12 @@ const Context = React.createContext<SearchContext>(defaultSearchContext);
function getReportsFromSelectedTransactions(data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[], selectedTransactions: SelectedTransactions) {
return (data ?? [])
.filter(
(item) =>
!SearchUIUtils.isTransactionListItemType(item) &&
!SearchUIUtils.isReportActionListItemType(item) &&
item.reportID &&
(item): item is ReportListItemType =>
SearchUIUtils.isReportListItemType(item) &&
isMoneyRequestReport(item) &&
item?.transactions?.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected),
)
.map((item) => item.reportID);
.map((item) => ({reportID: item.reportID, action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, total: item.total ?? 0, policyID: item.policyID ?? ''}));
}

function SearchContextProvider({children}: ChildrenProps) {
Expand Down
71 changes: 69 additions & 2 deletions src/components/Search/SearchPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import ROUTES from '@src/ROUTES';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
import {useSearchContext} from './SearchContext';
import SearchPageHeaderInput from './SearchPageHeaderInput';
import type {SearchQueryJSON} from './types';
import type {PaymentData, SearchQueryJSON} from './types';

type SearchPageHeaderProps = {queryJSON: SearchQueryJSON};

Expand All @@ -50,6 +50,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) {
const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST);
const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
const [lastPaymentMethods = {}] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD);
const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false);
const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false);
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
Expand Down Expand Up @@ -79,6 +80,71 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) {
}

const options: Array<DropdownOption<SearchHeaderOptionValue>> = [];
const isAnyTransactionOnHold = Object.values(selectedTransactions).some((transaction) => transaction.isHeld);

const shouldShowApproveOption =
!isOffline &&
!isAnyTransactionOnHold &&
(selectedReports.length
? selectedReports.every((report) => report.action === CONST.SEARCH.ACTION_TYPES.APPROVE)
: selectedTransactionsKeys.every((id) => selectedTransactions[id].action === CONST.SEARCH.ACTION_TYPES.APPROVE));

if (shouldShowApproveOption) {
options.push({
icon: Expensicons.ThumbsUp,
text: translate('search.bulkActions.approve'),
value: CONST.SEARCH.BULK_ACTION_TYPES.APPROVE,
shouldCloseModalOnSelect: true,
onSelected: () => {
if (isOffline) {
setIsOfflineModalVisible(true);
return;
}

const transactionIDList = selectedReports.length ? undefined : Object.keys(selectedTransactions);
const reportIDList = !selectedReports.length
? Object.values(selectedTransactions).map((transaction) => transaction.reportID)
: selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? [];
SearchActions.approveMoneyRequestOnSearch(hash, reportIDList, transactionIDList);
},
});
}

const shouldShowPayOption =
!isOffline &&
!isAnyTransactionOnHold &&
(selectedReports.length
? selectedReports.every((report) => report.action === CONST.SEARCH.ACTION_TYPES.PAY && report.policyID && lastPaymentMethods[report.policyID])
: selectedTransactionsKeys.every(
(id) => selectedTransactions[id].action === CONST.SEARCH.ACTION_TYPES.PAY && selectedTransactions[id].policyID && lastPaymentMethods[selectedTransactions[id].policyID],
));

if (shouldShowPayOption) {
options.push({
icon: Expensicons.MoneyBag,
text: translate('search.bulkActions.pay'),
value: CONST.SEARCH.BULK_ACTION_TYPES.PAY,
shouldCloseModalOnSelect: true,
onSelected: () => {
if (isOffline) {
setIsOfflineModalVisible(true);
return;
}
const transactionIDList = selectedReports.length ? undefined : Object.keys(selectedTransactions);
const paymentData = (
selectedReports.length
? selectedReports.map((report) => ({reportID: report.reportID, amount: report.total, paymentType: lastPaymentMethods[report.policyID]}))
: Object.values(selectedTransactions).map((transaction) => ({
reportID: transaction.reportID,
amount: transaction.amount,
paymentType: lastPaymentMethods[transaction.policyID],
}))
) as PaymentData[];

SearchActions.payMoneyRequestOnSearch(hash, paymentData, transactionIDList);
},
});
}

options.push({
icon: Expensicons.Download,
Expand All @@ -91,7 +157,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) {
return;
}

const reportIDList = selectedReports.filter((report): report is string => !!report) ?? [];
const reportIDList = selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? [];
SearchActions.exportSearchItemsToCSV(
{query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']},
() => {
Expand Down Expand Up @@ -190,6 +256,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) {
activeWorkspaceID,
selectedReports,
styles.textWrap,
lastPaymentMethods,
]);

if (shouldUseNarrowLayout) {
Expand Down
39 changes: 37 additions & 2 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralP
import * as ReportUtils from '@libs/ReportUtils';
import * as SearchQueryUtils from '@libs/SearchQueryUtils';
import * as SearchUIUtils from '@libs/SearchUIUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import Navigation from '@navigation/Navigation';
import type {AuthScreensParamList} from '@navigation/types';
import EmptySearchView from '@pages/Search/EmptySearchView';
Expand All @@ -49,7 +50,20 @@ const searchHeaderHeight = 54;
const sortableSearchStatuses: SearchStatus[] = [CONST.SEARCH.STATUS.EXPENSE.ALL];

function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] {
return [item.keyForList, {isSelected: true, canDelete: item.canDelete, canHold: item.canHold, canUnhold: item.canUnhold, action: item.action}];
return [
item.keyForList,
{
isSelected: true,
canDelete: item.canDelete,
canHold: item.canHold,
isHeld: TransactionUtils.isOnHold(item),
canUnhold: item.canUnhold,
action: item.action,
reportID: item.reportID,
policyID: item.policyID,
amount: item.modifiedAmount ?? item.amount,
},
];
}

function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean, shouldAnimateInHighlight: boolean) {
Expand Down Expand Up @@ -83,7 +97,20 @@ function prepareTransactionsList(item: TransactionListItemType, selectedTransact
return transactions;
}

return {...selectedTransactions, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, canHold: item.canHold, canUnhold: item.canUnhold, action: item.action}};
return {
...selectedTransactions,
[item.keyForList]: {
isSelected: true,
canDelete: item.canDelete,
canHold: item.canHold,
isHeld: TransactionUtils.isOnHold(item),
canUnhold: item.canUnhold,
action: item.action,
reportID: item.reportID,
policyID: item.policyID,
amount: item.modifiedAmount ?? item.amount,
},
};
}

function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentContainerStyle}: SearchProps) {
Expand Down Expand Up @@ -225,9 +252,13 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo
newTransactionList[transaction.transactionID] = {
action: transaction.action,
canHold: transaction.canHold,
isHeld: TransactionUtils.isOnHold(transaction),
canUnhold: transaction.canUnhold,
isSelected: selectedTransactions[transaction.transactionID].isSelected,
canDelete: transaction.canDelete,
reportID: transaction.reportID,
policyID: transaction.policyID,
amount: transaction.modifiedAmount ?? transaction.amount,
};
});
} else {
Expand All @@ -242,9 +273,13 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo
newTransactionList[transaction.transactionID] = {
action: transaction.action,
canHold: transaction.canHold,
isHeld: TransactionUtils.isOnHold(transaction),
canUnhold: transaction.canUnhold,
isSelected: selectedTransactions[transaction.transactionID].isSelected,
canDelete: transaction.canDelete,
reportID: transaction.reportID,
policyID: transaction.policyID,
amount: transaction.modifiedAmount ?? transaction.amount,
};
});
});
Expand Down
28 changes: 24 additions & 4 deletions src/components/Search/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {ValueOf} from 'type-fest';
import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
import type CONST from '@src/CONST';
import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';

/** Model of the selected transaction */
type SelectedTransactionInfo = {
Expand All @@ -14,16 +14,36 @@ type SelectedTransactionInfo = {
/** If the transaction can be put on hold */
canHold: boolean;

/** Whether the transaction is currently held */
isHeld: boolean;

/** If the transaction can be removed from hold */
canUnhold: boolean;

/** The action that can be performed for the transaction */
action: string;
action: ValueOf<typeof CONST.SEARCH.ACTION_TYPES>;

/** The reportID of the transaction */
reportID: string;

/** The policyID tied to the report the transaction is reported on */
policyID: string;

/** The transaction amount */
amount: number;
};

/** Model of selected results */
/** Model of selected transactons */
type SelectedTransactions = Record<string, SelectedTransactionInfo>;

/** Model of selected reports */
type SelectedReports = {
reportID: string;
policyID: string;
action: ValueOf<typeof CONST.SEARCH.ACTION_TYPES>;
total: number;
};

/** Model of payment data used by Search bulk actions */
type PaymentData = {
reportID: string;
Expand All @@ -42,7 +62,7 @@ type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus
type SearchContext = {
currentSearchHash: number;
selectedTransactions: SelectedTransactions;
selectedReports: Array<SearchReport['reportID']>;
selectedReports: SelectedReports[];
setCurrentSearchHash: (hash: number) => void;
setSelectedTransactions: (selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]) => void;
clearSelectedTransactions: (hash?: number) => void;
Expand Down
1 change: 1 addition & 0 deletions src/components/SelectionList/Search/ReportListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ function ReportListItem<TItem extends ListItem>({
onFocus={onFocus}
onLongPressRow={onLongPressRow}
shouldSyncFocus={shouldSyncFocus}
isLoading={reportItem.isActionLoading}
/>
);
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/SelectionList/Search/TransactionListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function TransactionListItem<TItem extends ListItem>({
onFocus,
onLongPressRow,
shouldSyncFocus,
isLoading,
}: TransactionListItemProps<TItem>) {
const transactionItem = item as unknown as TransactionListItemType;
const styles = useThemeStyles();
Expand Down Expand Up @@ -85,7 +86,7 @@ function TransactionListItem<TItem extends ListItem>({
canSelectMultiple={!!canSelectMultiple}
isButtonSelected={item.isSelected}
shouldShowTransactionCheckbox={false}
isLoading={transactionItem.isActionLoading}
isLoading={isLoading ?? transactionItem.isActionLoading}
/>
</BaseListItem>
);
Expand Down
5 changes: 4 additions & 1 deletion src/components/SelectionList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,10 @@ type RadioListItemProps<TItem extends ListItem> = ListItemProps<TItem>;

type TableListItemProps<TItem extends ListItem> = ListItemProps<TItem>;

type TransactionListItemProps<TItem extends ListItem> = ListItemProps<TItem>;
type TransactionListItemProps<TItem extends ListItem> = ListItemProps<TItem> & {
/** Whether the item's action is loading */
isLoading?: boolean;
};

type ReportListItemProps<TItem extends ListItem> = ListItemProps<TItem>;

Expand Down
2 changes: 2 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4562,6 +4562,8 @@ const translations = {
savedSearchesMenuItemTitle: 'Saved',
groupedExpenses: 'grouped expenses',
bulkActions: {
approve: 'Approve',
pay: 'Pay',
delete: 'Delete',
hold: 'Hold',
unhold: 'Unhold',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4611,6 +4611,8 @@ const translations = {
deleteSavedSearchConfirm: '¿Estás seguro de que quieres eliminar esta búsqueda?',
groupedExpenses: 'gastos agrupados',
bulkActions: {
approve: 'Aprobar',
pay: 'Pagar',
delete: 'Eliminar',
hold: 'Bloquear',
unhold: 'Desbloquear',
Expand Down
23 changes: 13 additions & 10 deletions src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ Onyx.connect({
});

function handleActionButtonPress(hash: number, item: TransactionListItemType | ReportListItemType, goToItem: () => void) {
// The transactionID is needed to handle actions taken on `status:all` where transactions on single expense reports can be approved/paid.
// The transactionIDList is needed to handle actions taken on `status:all` where transactions on single expense reports can be approved/paid.
// We need the transactionID to display the loading indicator for that list item's action.
const transactionID = isTransactionListItemType(item) ? item.transactionID : undefined;
const transactionID = isTransactionListItemType(item) ? [item.transactionID] : undefined;
const data = (allSnapshots?.[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]?.data ?? {}) as SearchResults['data'];
const allReportTransactions = (
isReportListItemType(item)
Expand Down Expand Up @@ -85,7 +85,7 @@ function getPayActionCallback(hash: number, item: TransactionListItemType | Repo

const report = (allSnapshots?.[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`] ?? {}) as SearchReport;
const amount = Math.abs((report?.total ?? 0) - (report?.nonReimbursableTotal ?? 0));
const transactionID = isTransactionListItemType(item) ? item.transactionID : undefined;
const transactionID = isTransactionListItemType(item) ? [item.transactionID] : undefined;

if (lastPolicyPaymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) {
payMoneyRequestOnSearch(hash, [{reportID: item.reportID, amount, paymentType: lastPolicyPaymentMethod}], transactionID);
Expand Down Expand Up @@ -242,33 +242,36 @@ function holdMoneyRequestOnSearch(hash: number, transactionIDList: string[], com
API.write(WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList, comment}, {optimisticData, finallyData});
}

function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], transactionID?: string) {
function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], transactionIDList?: string[]) {
const createActionLoadingData = (isLoading: boolean): OnyxUpdate[] => [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
value: {
data: transactionID
? {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {isActionLoading: isLoading}}
data: transactionIDList
? (Object.fromEntries(
transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {isActionLoading: isLoading}]),
) as Partial<SearchTransaction>)
: (Object.fromEntries(reportIDList.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {isActionLoading: isLoading}])) as Partial<SearchReport>),
},
},
];

const optimisticData: OnyxUpdate[] = createActionLoadingData(true);
const finallyData: OnyxUpdate[] = createActionLoadingData(false);

API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH, {hash, reportIDList}, {optimisticData, finallyData});
}

function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], transactionID?: string) {
function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], transactionIDList?: string[]) {
const createActionLoadingData = (isLoading: boolean): OnyxUpdate[] => [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
value: {
data: transactionID
? {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {isActionLoading: isLoading}}
data: transactionIDList
? (Object.fromEntries(
transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {isActionLoading: isLoading}]),
) as Partial<SearchTransaction>)
: (Object.fromEntries(paymentData.map((item) => [`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, {isActionLoading: isLoading}])) as Partial<SearchReport>),
},
},
Expand Down

0 comments on commit aba4d32

Please sign in to comment.