diff --git a/src/CONST.ts b/src/CONST.ts index d74474978c2b..df2593e87444 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5022,6 +5022,14 @@ const CONST = { ACTION: 'action', TAX_AMOUNT: 'taxAmount', }, + BULK_ACTION_TYPES: { + DELETE: 'delete', + HOLD: 'hold', + UNHOLD: 'unhold', + SUBMIT: 'submit', + APPROVE: 'approve', + PAY: 'pay', + }, }, REFERRER: { diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 1ad2ccb0d717..702f0380ceef 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -23,6 +23,10 @@ type DropdownOption = { iconDescription?: string; onSelected?: () => void; disabled?: boolean; + iconFill?: string; + interactive?: boolean; + numberOfLinesTitle?: number; + titleStyle?: ViewStyle; }; type ButtonWithDropdownMenuProps = { diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 36e33fdda799..154f5c1e1cd3 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -225,6 +225,7 @@ function PopoverMenu({ iconFill={item.iconFill} contentFit={item.contentFit} title={item.text} + titleStyle={item.titleStyle} shouldCheckActionAllowedOnPress={false} description={item.description} numberOfLinesDescription={item.numberOfLinesDescription} @@ -247,6 +248,8 @@ function PopoverMenu({ shouldForceRenderingTooltipLeft={item.shouldForceRenderingTooltipLeft} tooltipWrapperStyle={item.tooltipWrapperStyle} renderTooltipContent={item.renderTooltipContent} + numberOfLinesTitle={item.numberOfLinesTitle} + interactive={item.interactive} /> ))} diff --git a/src/components/Search/SearchListWithHeader.tsx b/src/components/Search/SearchListWithHeader.tsx new file mode 100644 index 000000000000..48d9a2b4ae3a --- /dev/null +++ b/src/components/Search/SearchListWithHeader.tsx @@ -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, '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) { + const [selectedItems, setSelectedItems] = useState({}); + + 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 ( + <> + + + // 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); diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx new file mode 100644 index 000000000000..8d42f9e6da36 --- /dev/null +++ b/src/components/Search/SearchPageHeader.tsx @@ -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 | 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> = []; + 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 ( + 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 ( + + {getHeaderButtons()} + + ); +} + +SearchPageHeader.displayName = 'SearchPageHeader'; + +export default SearchPageHeader; diff --git a/src/components/Search.tsx b/src/components/Search/index.tsx similarity index 85% rename from src/components/Search.tsx rename to src/components/Search/index.tsx index 714993204afb..6414501fb06d 100644 --- a/src/components/Search.tsx +++ b/src/components/Search/index.tsx @@ -3,6 +3,9 @@ import type {StackNavigationProp} from '@react-navigation/stack'; import React, {useCallback, useEffect, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; +import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import TableListItemSkeleton from '@components/Skeletons/TableListItemSkeleton'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -10,8 +13,8 @@ import * as SearchActions from '@libs/actions/Search'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import * as ReportUtils from '@libs/ReportUtils'; -import * as SearchUtils from '@libs/SearchUtils'; import type {SearchColumnType, SortOrder} from '@libs/SearchUtils'; +import * as SearchUtils from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; import type {AuthScreensParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; @@ -19,14 +22,12 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchQuery} from '@src/types/onyx/SearchResults'; import type SearchResults from '@src/types/onyx/SearchResults'; +import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import SelectionList from './SelectionList'; -import SearchTableHeader from './SelectionList/SearchTableHeader'; -import type {ReportListItemType, TransactionListItemType} from './SelectionList/types'; -import TableListItemSkeleton from './Skeletons/TableListItemSkeleton'; +import SearchListWithHeader from './SearchListWithHeader'; +import SearchPageHeader from './SearchPageHeader'; type SearchProps = { query: SearchQuery; @@ -41,11 +42,6 @@ const reportItemTransactionHeight = 52; const listItemPadding = 12; // this is equivalent to 'mb3' on every transaction/report list item const searchHeaderHeight = 54; -function isTransactionListItemType(item: TransactionListItemType | ReportListItemType): item is TransactionListItemType { - const transactionListItem = item as TransactionListItemType; - return transactionListItem.transactionID !== undefined; -} - function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); @@ -55,7 +51,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const getItemHeight = useCallback( (item: TransactionListItemType | ReportListItemType) => { - if (isTransactionListItemType(item)) { + if (SearchUtils.isTransactionListItemType(item)) { return isLargeScreenWidth ? variables.optionRowHeight + listItemPadding : transactionItemMobileHeight + listItemPadding; } @@ -97,22 +93,38 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const shouldShowEmptyState = !isLoadingItems && isEmptyObject(searchResults?.data); if (isLoadingItems) { - return ; + return ( + <> + + + + ); } if (shouldShowEmptyState) { - return ; + return ( + <> + + + + ); } const openReport = (item: TransactionListItemType | ReportListItemType) => { - let reportID = isTransactionListItemType(item) ? item.transactionThreadReportID : item.reportID; + let reportID = SearchUtils.isTransactionListItemType(item) ? item.transactionThreadReportID : item.reportID; if (!reportID) { return; } // If we're trying to open a legacy transaction without a transaction thread, let's create the thread and navigate the user - if (isTransactionListItemType(item) && reportID === '0' && item.moneyRequestReportActionID) { + if (SearchUtils.isTransactionListItemType(item) && reportID === '0' && item.moneyRequestReportActionID) { reportID = ReportUtils.generateReportID(); SearchActions.createTransactionThread(hash, item.transactionID, reportID, item.moneyRequestReportActionID); } @@ -152,7 +164,11 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const shouldShowYear = SearchUtils.shouldShowYear(searchResults?.data); return ( - + } + canSelectMultiple={isLargeScreenWidth} customListHeaderHeight={searchHeaderHeight} // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, // we have configured a larger windowSize and a longer delay between batch renders. @@ -177,8 +194,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { windowSize={111} updateCellsBatchingPeriod={200} ListItem={ListItem} - sections={[{data: sortedData, isDisabled: false}]} - onSelectRow={(item) => openReport(item)} + onSelectRow={openReport} getItemHeight={getItemHeight} shouldDebounceRowSelect shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts new file mode 100644 index 000000000000..3ebc2797947a --- /dev/null +++ b/src/components/Search/types.ts @@ -0,0 +1,17 @@ +/** Model of the selected transaction */ +type SelectedTransactionInfo = { + /** Whether the transaction is selected */ + isSelected: boolean; + + /** If the transaction can be deleted */ + canDelete: boolean; + + /** The action that can be performed for the transaction */ + action: string; +}; + +/** Model of selected results */ +type SelectedTransactions = Record; + +// eslint-disable-next-line import/prefer-default-export +export type {SelectedTransactionInfo, SelectedTransactions}; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 617c70a1d224..878d25da4af4 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -24,6 +24,7 @@ import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset'; import Log from '@libs/Log'; +import * as SearchUtils from '@libs/SearchUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -431,6 +432,13 @@ function BaseSelectionList( // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const showTooltip = shouldShowTooltips && normalizedIndex < 10; + const handleOnCheckboxPress = () => { + if (SearchUtils.isReportListItemType(item)) { + return onCheckboxPress; + } + return onCheckboxPress ? () => onCheckboxPress(item) : undefined; + }; + return ( <> ( showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={() => selectRow(item)} - onCheckboxPress={onCheckboxPress ? () => onCheckboxPress?.(item) : undefined} + onCheckboxPress={handleOnCheckboxPress()} onDismissError={() => onDismissError?.(item)} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} // We're already handling the Enter key press in the useKeyboardShortcut hook, so we don't want the list item to submit the form diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index 6aabfebf0da9..9e0599d839df 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -14,9 +14,10 @@ type ActionCellProps = { onButtonPress: () => void; action?: string; isLargeScreenWidth?: boolean; + isSelected?: boolean; }; -function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true}: ActionCellProps) { +function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true, isSelected = false}: ActionCellProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); @@ -53,6 +54,7 @@ function ActionCell({onButtonPress, action = CONST.SEARCH.ACTION_TYPES.VIEW, isL small pressOnEnter style={[styles.w100]} + innerStyles={isSelected ? styles.buttonDefaultHovered : {}} /> ); } diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index f9e8e1951d9a..553839ae8457 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {ListItem, ReportListItemProps, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import Text from '@components/Text'; @@ -54,6 +55,7 @@ function ReportListItem({ showTooltip, isDisabled, canSelectMultiple, + onCheckboxPress, onSelectRow, onDismissError, onFocus, @@ -104,6 +106,7 @@ function ReportListItem({ showTooltip={showTooltip} isDisabled={isDisabled} canSelectMultiple={canSelectMultiple} + onCheckboxPress={() => onCheckboxPress?.(transactionItem as unknown as TItem)} onSelectRow={() => openReportInRHP(transactionItem)} onDismissError={onDismissError} onFocus={onFocus} @@ -142,10 +145,20 @@ function ReportListItem({ onButtonPress={handleOnButtonPress} /> )} - + - + {canSelectMultiple && ( + onCheckboxPress?.(item)} + isChecked={item.isSelected} + containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!item.isSelected, !!item.isDisabled)]} + disabled={!!isDisabled || item.isDisabledCheckbox} + accessibilityLabel={item.text ?? ''} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} + /> + )} + {reportItem?.reportName} {`${reportItem.transactions.length} ${translate('search.groupedExpenses')}`} @@ -167,6 +180,7 @@ function ReportListItem({ isLargeScreenWidth={isLargeScreenWidth} onButtonPress={handleOnButtonPress} action={reportItem.action} + isSelected={item.isSelected} /> @@ -180,9 +194,13 @@ function ReportListItem({ onButtonPress={() => { openReportInRHP(transaction); }} + onCheckboxPress={() => onCheckboxPress?.(transaction as unknown as TItem)} showItemHeaderOnNarrowLayout={false} containerStyle={styles.mt3} isChildListItem + isDisabled={!!isDisabled} + canSelectMultiple={!!canSelectMultiple} + isButtonSelected={item.isSelected} /> ))} diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index 23ab549dd495..b00ae0703c2e 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -12,6 +12,7 @@ function TransactionListItem({ isDisabled, canSelectMultiple, onSelectRow, + onCheckboxPress, onDismissError, onFocus, shouldSyncFocus, @@ -54,6 +55,10 @@ function TransactionListItem({ onButtonPress={() => { onSelectRow(item); }} + onCheckboxPress={() => onCheckboxPress?.(item)} + isDisabled={!!isDisabled} + canSelectMultiple={!!canSelectMultiple} + isButtonSelected={item.isSelected} /> ); diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index 0adc7ee21fd1..9f0799143373 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import ReceiptImage from '@components/ReceiptImage'; @@ -43,9 +44,13 @@ type TransactionListItemRowProps = { item: TransactionListItemType; showTooltip: boolean; onButtonPress: () => void; + onCheckboxPress: () => void; showItemHeaderOnNarrowLayout?: boolean; containerStyle?: StyleProp; isChildListItem?: boolean; + isDisabled: boolean; + canSelectMultiple: boolean; + isButtonSelected?: boolean; }; const getTypeIcon = (type?: SearchTransactionType) => { @@ -209,7 +214,18 @@ function TaxCell({transactionItem, showTooltip}: TransactionCellProps) { ); } -function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeaderOnNarrowLayout = true, containerStyle, isChildListItem = false}: TransactionListItemRowProps) { +function TransactionListItemRow({ + item, + showTooltip, + isDisabled, + canSelectMultiple, + onButtonPress, + onCheckboxPress, + showItemHeaderOnNarrowLayout = true, + containerStyle, + isChildListItem = false, + isButtonSelected = false, +}: TransactionListItemRowProps) { const styles = useThemeStyles(); const {isLargeScreenWidth} = useWindowDimensions(); const StyleUtils = useStyleUtils(); @@ -280,7 +296,16 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade return ( - + {canSelectMultiple && ( + + )} + diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx index 6ba753273e8c..da840c902f7c 100644 --- a/src/components/SelectionList/SearchTableHeader.tsx +++ b/src/components/SelectionList/SearchTableHeader.tsx @@ -107,7 +107,7 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, isSortingAllowed, } return ( - + {SearchColumns.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { if (!shouldShow(data, metadata)) { diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index fc369adf5169..b26ff9c4eb57 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -172,6 +172,9 @@ type TransactionListItemType = ListItem & * This is true if at least one transaction in the dataset was created in past years */ shouldShowYear: boolean; + + /** Key used internally by React */ + keyForList: string; }; type ReportListItemType = ListItem & diff --git a/src/languages/en.ts b/src/languages/en.ts index 1df7fa5560c1..175f2471ae8f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3024,6 +3024,12 @@ export default { }, }, groupedExpenses: 'grouped expenses', + bulkActions: { + delete: 'Delete', + hold: 'Hold', + unhold: 'Unhold', + noOptionsAvailable: 'No options available for the selected group of expenses.', + }, }, genericErrorPage: { title: 'Uh-oh, something went wrong!', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2b19755dbcf0..bbe59dcb62db 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3059,6 +3059,12 @@ export default { }, }, groupedExpenses: 'gastos agrupados', + bulkActions: { + delete: 'Eliminar', + hold: 'Bloquear', + unhold: 'Desbloquear', + noOptionsAvailable: 'No hay opciones disponibles para el grupo de gastos seleccionado.', + }, }, genericErrorPage: { title: '¡Oh-oh, algo salió mal!', diff --git a/src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts new file mode 100644 index 000000000000..e44774ae671b --- /dev/null +++ b/src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts @@ -0,0 +1,6 @@ +type DeleteMoneyRequestOnSearchParams = { + hash: number; + transactionIDList: string[]; +}; + +export default DeleteMoneyRequestOnSearchParams; diff --git a/src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts new file mode 100644 index 000000000000..46ceed818cb8 --- /dev/null +++ b/src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts @@ -0,0 +1,7 @@ +type HoldMoneyRequestOnSearchParams = { + hash: number; + transactionIDList: string[]; + comment: string; +}; + +export default HoldMoneyRequestOnSearchParams; diff --git a/src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts b/src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts new file mode 100644 index 000000000000..a32b57731999 --- /dev/null +++ b/src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts @@ -0,0 +1,6 @@ +type UnholdMoneyRequestOnSearchParams = { + hash: number; + transactionIDList: string[]; +}; + +export default UnholdMoneyRequestOnSearchParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index f032edf96e36..704b46280978 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -233,5 +233,8 @@ export type {default as UpdateSubscriptionAutoRenewParams} from './UpdateSubscri export type {default as UpdateSubscriptionAddNewUsersAutomaticallyParams} from './UpdateSubscriptionAddNewUsersAutomaticallyParams'; export type {default as GenerateSpotnanaTokenParams} from './GenerateSpotnanaTokenParams'; export type {default as UpdateSubscriptionSizeParams} from './UpdateSubscriptionSizeParams'; +export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyRequestOnSearchParams'; +export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams'; +export type {default as UnholdMoneyRequestOnSearchParams} from './UnholdMoneyRequestOnSearchParams'; export type {default as UpdateNetSuiteSubsidiaryParams} from './UpdateNetSuiteSubsidiaryParams'; export type {default as UpdateNetSuiteGenericTypeParams} from './UpdateNetSuiteGenericTypeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index c448fc9aa1f4..b5ec368da5ec 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -229,6 +229,9 @@ const WRITE_COMMANDS = { UPDATE_SUBSCRIPTION_AUTO_RENEW: 'UpdateSubscriptionAutoRenew', UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY: 'UpdateSubscriptionAddNewUsersAutomatically', UPDATE_SUBSCRIPTION_SIZE: 'UpdateSubscriptionSize', + DELETE_MONEY_REQUEST_ON_SEARCH: 'DeleteMoneyRequestOnSearch', + HOLD_MONEY_REQUEST_ON_SEARCH: 'HoldMoneyRequestOnSearch', + UNHOLD_MONEY_REQUEST_ON_SEARCH: 'UnholdMoneyRequestOnSearch', REQUEST_REFUND: 'User_RefundPurchase', UPDATE_NETSUITE_SUBSIDIARY: 'UpdateNetSuiteSubsidiary', UPDATE_NETSUITE_EXPORTER: 'UpdateNetSuiteExporter', @@ -476,6 +479,11 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_AUTO_RENEW]: Parameters.UpdateSubscriptionAutoRenewParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY]: Parameters.UpdateSubscriptionAddNewUsersAutomaticallyParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_SIZE]: Parameters.UpdateSubscriptionSizeParams; + + [WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; + [WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.HoldMoneyRequestOnSearchParams; + [WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.UnholdMoneyRequestOnSearchParams; + [WRITE_COMMANDS.REQUEST_REFUND]: null; [WRITE_COMMANDS.CONNECT_POLICY_TO_SAGE_INTACCT]: Parameters.ConnectPolicyToSageIntacctParams; diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 460a686766a7..1ab156114c12 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -1,7 +1,7 @@ import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; import ReportListItem from '@components/SelectionList/Search/ReportListItem'; import TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ListItem, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -79,10 +79,15 @@ function getShouldShowMerchant(data: OnyxTypes.SearchResults['data']): boolean { const currentYear = new Date().getFullYear(); -function isReportListItemType(item: TransactionListItemType | ReportListItemType): item is ReportListItemType { +function isReportListItemType(item: ListItem): item is ReportListItemType { return 'transactions' in item; } +function isTransactionListItemType(item: TransactionListItemType | ReportListItemType): item is TransactionListItemType { + const transactionListItem = item as TransactionListItemType; + return transactionListItem.transactionID !== undefined; +} + function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | OnyxTypes.SearchResults['data']): boolean { if (Array.isArray(data)) { return data.some((item: TransactionListItemType | ReportListItemType) => { @@ -278,5 +283,5 @@ function getSearchParams() { return topmostCentralPaneRoute?.params as AuthScreensParamList['Search_Central_Pane']; } -export {getListItem, getQueryHash, getSections, getSortedSections, getShouldShowMerchant, getSearchType, getSearchParams, shouldShowYear}; +export {getListItem, getQueryHash, getSections, getSortedSections, getShouldShowMerchant, getSearchType, getSearchParams, shouldShowYear, isReportListItemType, isTransactionListItemType}; export type {SearchColumnType, SortOrder}; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index ec45298c3910..70f7d2d5b7e0 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; import type {SearchParams} from '@libs/API/parameters'; -import {READ_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchTransaction} from '@src/types/onyx/SearchResults'; import * as Report from './Report'; @@ -15,7 +15,7 @@ Onyx.connect({ }, }); -function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParams) { +function getOnyxLoadingData(hash: number): {optimisticData: OnyxUpdate[]; finallyData: OnyxUpdate[]} { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -40,6 +40,12 @@ function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParam }, ]; + return {optimisticData, finallyData}; +} + +function search({hash, query, policyIDs, offset, sortBy, sortOrder}: SearchParams) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.read(READ_COMMANDS.SEARCH, {hash, query, offset, policyIDs, sortBy, sortOrder}, {optimisticData, finallyData}); } @@ -61,4 +67,19 @@ function createTransactionThread(hash: number, transactionID: string, reportID: Onyx.merge(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, onyxUpdate); } -export {search, createTransactionThread}; +function holdMoneyRequestOnSearch(hash: number, transactionIDList: string[], comment: string) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.write(WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList, comment}, {optimisticData, finallyData}); +} + +function unholdMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.write(WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList}, {optimisticData, finallyData}); +} + +function deleteMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { + const {optimisticData, finallyData} = getOnyxLoadingData(hash); + API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList}, {optimisticData, finallyData}); +} + +export {search, createTransactionThread, deleteMoneyRequestOnSearch, holdMoneyRequestOnSearch, unholdMoneyRequestOnSearch}; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 7890e53f1b3c..6e734fd835d2 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,11 +1,8 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; -import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Navigation from '@libs/Navigation/Navigation'; @@ -14,12 +11,10 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {SearchQuery} from '@src/types/onyx/SearchResults'; -import type IconAsset from '@src/types/utils/IconAsset'; type SearchPageProps = StackScreenProps; function SearchPage({route}: SearchPageProps) { - const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); const styles = useThemeStyles(); @@ -28,13 +23,6 @@ function SearchPage({route}: SearchPageProps) { const query = rawQuery as SearchQuery; const isValidQuery = Object.values(CONST.SEARCH.TAB).includes(query); - 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 handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH.getRoute(CONST.SEARCH.TAB.ALL)); // On small screens this page is not displayed, the configuration is in the file: src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx @@ -55,11 +43,6 @@ function SearchPage({route}: SearchPageProps) { onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > -