Skip to content
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

Add bulk actions #51913

Merged
merged 26 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5870,6 +5870,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
67 changes: 65 additions & 2 deletions src/components/Search/SearchPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import type IconAsset from '@src/types/utils/IconAsset';
import {useSearchContext} from './SearchContext';
import SearchButton from './SearchRouter/SearchButton';
import SearchRouterInput from './SearchRouter/SearchRouterInput';
import type {SearchQueryJSON} from './types';
import type {PaymentData, SearchQueryJSON} from './types';

type HeaderWrapperProps = Pick<HeaderWithBackButtonProps, 'icon' | 'children'> & {
text: string;
Expand Down Expand Up @@ -132,6 +132,7 @@ function SearchPageHeader({queryJSON, hash}: 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 @@ -170,6 +171,67 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
}

const options: Array<DropdownOption<SearchHeaderOptionValue>> = [];
const shouldShowApproveOption =
!isOffline &&
(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 &&
(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 @@ -182,7 +244,7 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
return;
}

const reportIDList = selectedReports?.filter((report) => !!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 @@ -281,6 +343,7 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
activeWorkspaceID,
selectedReports,
styles.textWrap,
lastPaymentMethods,
]);

if (shouldUseNarrowLayout) {
Expand Down
34 changes: 32 additions & 2 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,19 @@ 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,
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 @@ -84,7 +96,19 @@ 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,
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 @@ -233,6 +257,9 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo
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 @@ -250,6 +277,9 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo
canUnhold: transaction.canUnhold,
isSelected: selectedTransactions[transaction.transactionID].isSelected,
canDelete: transaction.canDelete,
reportID: transaction.reportID,
policyID: transaction.policyID,
amount: transaction.modifiedAmount ?? transaction.amount,
};
});
});
Expand Down
25 changes: 21 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 @@ -18,12 +18,29 @@ type SelectedTransactionInfo = {
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 +59,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
2 changes: 2 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4402,6 +4402,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 @@ -4449,6 +4449,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
19 changes: 11 additions & 8 deletions src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ 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.
// 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;

switch (item.action) {
case CONST.SEARCH.ACTION_TYPES.PAY: {
Expand Down Expand Up @@ -193,33 +193,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
Loading