-
Notifications
You must be signed in to change notification settings - Fork 3.7k
[Search v1] Add bulk actions #44385
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
[Search v1] Add bulk actions #44385
Changes from all commits
da896c4
bf09dba
1ed881d
750768b
ebe5046
a3de9b7
0614be1
3cd114a
3edbdb1
b288a7c
f832e43
1c71750
3638a40
4f0a8e1
9023d74
93f2e50
c4256e2
bcf6cbb
381b750
8f13ca5
5f3b4a0
437fc2a
ca1bd1d
4804ccc
311e18d
b39c0c2
3a2cef8
aa22412
e9d58ea
4c82eac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); |
| 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(() => { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the convention used in list components. Check for example: |
||||||
| 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, ''); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'll still need to redirect the user to the reason page. Maybe that's something that @Kicu can address in his PR? |
||||||
| }, | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| 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; | ||||||
Uh oh!
There was an error while loading. Please reload this page.