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 all 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 @@ -5978,6 +5978,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
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 @@ -4518,6 +4518,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 @@ -4567,6 +4567,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