-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #44385 from software-mansion-labs/search/bulk-actions
[Search v1] Add bulk actions
- Loading branch information
Showing
25 changed files
with
478 additions
and
49 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import type {ForwardedRef} from 'react'; | ||
import React, {forwardRef, useEffect, useMemo, useState} from 'react'; | ||
import SelectionList from '@components/SelectionList'; | ||
import type {BaseSelectionListProps, ReportListItemType, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types'; | ||
import * as SearchUtils from '@libs/SearchUtils'; | ||
import CONST from '@src/CONST'; | ||
import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; | ||
import SearchPageHeader from './SearchPageHeader'; | ||
import type {SelectedTransactionInfo, SelectedTransactions} from './types'; | ||
|
||
type SearchListWithHeaderProps = Omit<BaseSelectionListProps<ReportListItemType | TransactionListItemType>, 'onSelectAll' | 'onCheckboxPress' | 'sections'> & { | ||
query: SearchQuery; | ||
hash: number; | ||
data: TransactionListItemType[] | ReportListItemType[]; | ||
searchType: SearchDataTypes; | ||
}; | ||
|
||
function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] { | ||
return [item.keyForList, {isSelected: true, canDelete: item.canDelete, action: item.action}]; | ||
} | ||
|
||
function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, selectedItems: SelectedTransactions) { | ||
return {...item, isSelected: !!selectedItems[item.keyForList]?.isSelected}; | ||
} | ||
|
||
function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType, selectedItems: SelectedTransactions) { | ||
return SearchUtils.isTransactionListItemType(item) | ||
? mapToTransactionItemWithSelectionInfo(item, selectedItems) | ||
: { | ||
...item, | ||
transactions: item.transactions?.map((tranaction) => mapToTransactionItemWithSelectionInfo(tranaction, selectedItems)), | ||
isSelected: item.transactions.every((transaction) => !!selectedItems[transaction.keyForList]?.isSelected), | ||
}; | ||
} | ||
|
||
function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchType, ...props}: SearchListWithHeaderProps, ref: ForwardedRef<SelectionListHandle>) { | ||
const [selectedItems, setSelectedItems] = useState<SelectedTransactions>({}); | ||
|
||
const clearSelectedItems = () => setSelectedItems({}); | ||
|
||
useEffect(() => { | ||
clearSelectedItems(); | ||
}, [hash]); | ||
|
||
const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { | ||
if (SearchUtils.isTransactionListItemType(item)) { | ||
if (!item.keyForList) { | ||
return; | ||
} | ||
|
||
setSelectedItems((prev) => { | ||
if (prev[item.keyForList]?.isSelected) { | ||
const {[item.keyForList]: omittedTransaction, ...transactions} = prev; | ||
return transactions; | ||
} | ||
return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}}; | ||
}); | ||
|
||
return; | ||
} | ||
|
||
if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) { | ||
const reducedSelectedItems: SelectedTransactions = {...selectedItems}; | ||
|
||
item.transactions.forEach((transaction) => { | ||
delete reducedSelectedItems[transaction.keyForList]; | ||
}); | ||
|
||
setSelectedItems(reducedSelectedItems); | ||
return; | ||
} | ||
|
||
setSelectedItems({ | ||
...selectedItems, | ||
...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)), | ||
}); | ||
}; | ||
|
||
const toggleAllTransactions = () => { | ||
const areItemsOfReportType = searchType === CONST.SEARCH.DATA_TYPES.REPORT; | ||
const flattenedItems = areItemsOfReportType ? (data as ReportListItemType[]).flatMap((item) => item.transactions) : data; | ||
const isAllSelected = flattenedItems.length === Object.keys(selectedItems).length; | ||
|
||
if (isAllSelected) { | ||
clearSelectedItems(); | ||
return; | ||
} | ||
|
||
if (areItemsOfReportType) { | ||
setSelectedItems(Object.fromEntries((data as ReportListItemType[]).flatMap((item) => item.transactions.map(mapTransactionItemToSelectedEntry)))); | ||
|
||
return; | ||
} | ||
|
||
setSelectedItems(Object.fromEntries((data as TransactionListItemType[]).map(mapTransactionItemToSelectedEntry))); | ||
}; | ||
|
||
const sortedSelectedData = useMemo(() => data.map((item) => mapToItemWithSelectionInfo(item, selectedItems)), [data, selectedItems]); | ||
|
||
return ( | ||
<> | ||
<SearchPageHeader | ||
selectedItems={selectedItems} | ||
clearSelectedItems={clearSelectedItems} | ||
query={query} | ||
hash={hash} | ||
/> | ||
<SelectionList<ReportListItemType | TransactionListItemType> | ||
// eslint-disable-next-line react/jsx-props-no-spreading | ||
{...props} | ||
sections={[{data: sortedSelectedData, isDisabled: false}]} | ||
ListItem={ListItem} | ||
onSelectRow={onSelectRow} | ||
ref={ref} | ||
onCheckboxPress={toggleTransaction} | ||
onSelectAll={toggleAllTransactions} | ||
/> | ||
</> | ||
); | ||
} | ||
|
||
SearchListWithHeader.displayName = 'SearchListWithHeader'; | ||
|
||
export default forwardRef(SearchListWithHeader); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
import React, {useCallback} from 'react'; | ||
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; | ||
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; | ||
import HeaderWithBackButton from '@components/HeaderWithBackButton'; | ||
import * as Expensicons from '@components/Icon/Expensicons'; | ||
import * as Illustrations from '@components/Icon/Illustrations'; | ||
import useLocalize from '@hooks/useLocalize'; | ||
import useNetwork from '@hooks/useNetwork'; | ||
import useResponsiveLayout from '@hooks/useResponsiveLayout'; | ||
import useTheme from '@hooks/useTheme'; | ||
import useThemeStyles from '@hooks/useThemeStyles'; | ||
import * as SearchActions from '@libs/actions/Search'; | ||
import variables from '@styles/variables'; | ||
import CONST from '@src/CONST'; | ||
import type {SearchQuery} from '@src/types/onyx/SearchResults'; | ||
import type DeepValueOf from '@src/types/utils/DeepValueOf'; | ||
import type IconAsset from '@src/types/utils/IconAsset'; | ||
import type {SelectedTransactions} from './types'; | ||
|
||
type SearchHeaderProps = { | ||
query: SearchQuery; | ||
selectedItems?: SelectedTransactions; | ||
clearSelectedItems?: () => void; | ||
hash: number; | ||
}; | ||
|
||
type SearchHeaderOptionValue = DeepValueOf<typeof CONST.SEARCH.BULK_ACTION_TYPES> | undefined; | ||
|
||
function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: SearchHeaderProps) { | ||
const {translate} = useLocalize(); | ||
const theme = useTheme(); | ||
const styles = useThemeStyles(); | ||
const {isOffline} = useNetwork(); | ||
const {isSmallScreenWidth} = useResponsiveLayout(); | ||
const headerContent: {[key in SearchQuery]: {icon: IconAsset; title: string}} = { | ||
all: {icon: Illustrations.MoneyReceipts, title: translate('common.expenses')}, | ||
shared: {icon: Illustrations.SendMoney, title: translate('common.shared')}, | ||
drafts: {icon: Illustrations.Pencil, title: translate('common.drafts')}, | ||
finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')}, | ||
}; | ||
|
||
const getHeaderButtons = useCallback(() => { | ||
const options: Array<DropdownOption<SearchHeaderOptionValue>> = []; | ||
const selectedItemsKeys = Object.keys(selectedItems ?? []); | ||
|
||
if (selectedItemsKeys.length === 0) { | ||
return null; | ||
} | ||
|
||
const itemsToDelete = selectedItemsKeys.filter((id) => selectedItems[id].canDelete); | ||
|
||
if (itemsToDelete.length > 0) { | ||
options.push({ | ||
icon: Expensicons.Trashcan, | ||
text: translate('search.bulkActions.delete'), | ||
value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE, | ||
onSelected: () => { | ||
clearSelectedItems?.(); | ||
SearchActions.deleteMoneyRequestOnSearch(hash, itemsToDelete); | ||
}, | ||
}); | ||
} | ||
|
||
const itemsToHold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); | ||
|
||
if (itemsToHold.length > 0) { | ||
options.push({ | ||
icon: Expensicons.Stopwatch, | ||
text: translate('search.bulkActions.hold'), | ||
value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD, | ||
onSelected: () => { | ||
clearSelectedItems?.(); | ||
SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, ''); | ||
}, | ||
}); | ||
} | ||
|
||
const itemsToUnhold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD); | ||
|
||
if (itemsToUnhold.length > 0) { | ||
options.push({ | ||
icon: Expensicons.Stopwatch, | ||
text: translate('search.bulkActions.unhold'), | ||
value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD, | ||
onSelected: () => { | ||
clearSelectedItems?.(); | ||
SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold); | ||
}, | ||
}); | ||
} | ||
|
||
if (options.length === 0) { | ||
const emptyOptionStyle = { | ||
interactive: false, | ||
iconFill: theme.icon, | ||
iconHeight: variables.iconSizeLarge, | ||
iconWidth: variables.iconSizeLarge, | ||
numberOfLinesTitle: 2, | ||
titleStyle: {...styles.colorMuted, ...styles.fontWeightNormal}, | ||
}; | ||
|
||
options.push({ | ||
icon: Expensicons.Exclamation, | ||
text: translate('search.bulkActions.noOptionsAvailable'), | ||
value: undefined, | ||
...emptyOptionStyle, | ||
}); | ||
} | ||
|
||
return ( | ||
<ButtonWithDropdownMenu | ||
onPress={() => null} | ||
shouldAlwaysShowDropdownMenu | ||
pressOnEnter | ||
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} | ||
customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} | ||
options={options} | ||
isSplitButton={false} | ||
isDisabled={isOffline} | ||
/> | ||
); | ||
}, [clearSelectedItems, hash, isOffline, selectedItems, styles.colorMuted, styles.fontWeightNormal, theme.icon, translate]); | ||
|
||
if (isSmallScreenWidth) { | ||
return null; | ||
} | ||
|
||
return ( | ||
<HeaderWithBackButton | ||
title={headerContent[query]?.title} | ||
icon={headerContent[query]?.icon} | ||
shouldShowBackButton={false} | ||
> | ||
{getHeaderButtons()} | ||
</HeaderWithBackButton> | ||
); | ||
} | ||
|
||
SearchPageHeader.displayName = 'SearchPageHeader'; | ||
|
||
export default SearchPageHeader; |
Oops, something went wrong.