From da896c4d2824be26b263faa20ecd9c0fc44d76d3 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 20 Jun 2024 10:51:07 +0200 Subject: [PATCH 01/28] Add checkbox to search list items --- src/components/Search.tsx | 20 ++++++++++++++-- .../SelectionList/Search/ReportListItem.tsx | 17 ++++++++++++-- .../Search/TransactionListItem.tsx | 2 ++ .../Search/TransactionListItemRow.tsx | 23 +++++++++++++++++-- .../SelectionList/SearchTableHeader.tsx | 2 +- 5 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/components/Search.tsx b/src/components/Search.tsx index e4faa15bfb946..bcfde986f2c81 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -1,6 +1,6 @@ import {useNavigation} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import useNetwork from '@hooks/useNetwork'; @@ -19,7 +19,7 @@ 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 {SearchQuery, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; import type SearchResults from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -47,6 +47,7 @@ function isTransactionListItemType(item: TransactionListItemType | ReportListIte } function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { + const [selectedItems, setSelectedItems] = useState>([]); const {isOffline} = useNetwork(); const styles = useThemeStyles(); const {isLargeScreenWidth} = useWindowDimensions(); @@ -151,6 +152,18 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const shouldShowYear = SearchUtils.shouldShowYear(searchResults?.data); + const toggleAllItems = () => { + if (selectedItems.length === data.length) { + setSelectedItems([]); + } else { + setSelectedItems([...data]); + } + }; + + const toggleListItem = (listItem: TransactionListItemType | ReportListItemType) => { + console.log(listItem); + }; + return ( customListHeader={ @@ -163,6 +176,9 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { shouldShowYear={shouldShowYear} /> } + canSelectMultiple={isLargeScreenWidth} + onSelectAll={toggleAllItems} + onCheckboxPress={toggleListItem} 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. diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 2273b80e529d7..426ad7e7493c2 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; +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'; @@ -154,10 +155,20 @@ function ReportListItem({ onButtonPress={handleOnButtonPress} /> )} - + - + {canSelectMultiple && ( + {}} + 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')}`} @@ -195,6 +206,8 @@ function ReportListItem({ showItemHeaderOnNarrowLayout={false} containerStyle={styles.mt3} isChildListItem + isDisabled={!!isDisabled} + canSelectMultiple={!!canSelectMultiple} /> ))} diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index 23ab549dd4956..f20c238d7744a 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -54,6 +54,8 @@ function TransactionListItem({ onButtonPress={() => { onSelectRow(item); }} + isDisabled={!!isDisabled} + canSelectMultiple={!!canSelectMultiple} /> ); diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index c0fff452d1e54..c0a53d2cda943 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Button from '@components/Button'; +import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import ReceiptImage from '@components/ReceiptImage'; @@ -49,6 +50,8 @@ type TransactionListItemRowProps = { showItemHeaderOnNarrowLayout?: boolean; containerStyle?: StyleProp; isChildListItem?: boolean; + isDisabled: boolean; + canSelectMultiple: boolean; }; const getTypeIcon = (type?: SearchTransactionType) => { @@ -213,7 +216,16 @@ function TaxCell({transactionItem, showTooltip}: TransactionCellProps) { ); } -function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeaderOnNarrowLayout = true, containerStyle, isChildListItem = false}: TransactionListItemRowProps) { +function TransactionListItemRow({ + item, + showTooltip, + isDisabled, + canSelectMultiple, + onButtonPress, + showItemHeaderOnNarrowLayout = true, + containerStyle, + isChildListItem = false, +}: TransactionListItemRowProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isLargeScreenWidth} = useWindowDimensions(); @@ -285,7 +297,14 @@ function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeade return ( - + {canSelectMultiple && ( + {}} + accessibilityLabel={item.text ?? ''} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} + /> + )} + + {SearchColumns.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { if (!shouldShow(data)) { From bf09dbadbc6be24cb6c9ba23566d696b4ecca2ec Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 20 Jun 2024 16:24:22 +0200 Subject: [PATCH 02/28] Handle disabling checkboxes --- src/components/Search.tsx | 50 ++++++++++++++----- .../SelectionList/Search/ReportListItem.tsx | 2 + .../Search/TransactionListItem.tsx | 2 + .../Search/TransactionListItemRow.tsx | 6 ++- src/types/onyx/SearchResults.ts | 3 ++ 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/components/Search.tsx b/src/components/Search.tsx index bcfde986f2c81..bf942e6dc2817 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -10,8 +10,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 {CentralPaneNavigatorParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; @@ -19,8 +19,8 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchQuery, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; import type SearchResults from '@src/types/onyx/SearchResults'; +import type {SearchQuery} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import SelectionList from './SelectionList'; @@ -47,7 +47,7 @@ function isTransactionListItemType(item: TransactionListItemType | ReportListIte } function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { - const [selectedItems, setSelectedItems] = useState>([]); + const [selectedItems, setSelectedItems] = useState>({}); const {isOffline} = useNetwork(); const styles = useThemeStyles(); const {isLargeScreenWidth} = useWindowDimensions(); @@ -152,18 +152,42 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const shouldShowYear = SearchUtils.shouldShowYear(searchResults?.data); - const toggleAllItems = () => { - if (selectedItems.length === data.length) { - setSelectedItems([]); - } else { - setSelectedItems([...data]); + const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { + console.log('item', item); + + if (isTransactionListItemType(item)) { + // console.log('item', item); + // if (!item.canDelete || !item.keyForList) { + // return; + // } + + setSelectedItems((prev) => { + if (prev[item.keyForList]) { + const {[item.keyForList]: omittedCategory, ...newCategories} = prev; + return newCategories; + } + return {...prev, [item.keyForList]: true}; + }); + + return; } + + item.transactions.forEach((transaction) => toggleTransaction(transaction)); }; - const toggleListItem = (listItem: TransactionListItemType | ReportListItemType) => { - console.log(listItem); + const toggleAllTransactions = () => { + const availableCategories = sortedData.filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const isAllSelected = availableCategories.length === Object.keys(selectedItems).length; + setSelectedItems(isAllSelected ? {} : Object.fromEntries(availableCategories.map((item) => [item.keyForList, true]))); }; + const sortedSelectedData = sortedData.map((item) => ({ + ...item, + isSelected: !!selectedItems[item.keyForList], + })); + + console.log('selectedItems', selectedItems); + return ( customListHeader={ @@ -177,8 +201,8 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { /> } canSelectMultiple={isLargeScreenWidth} - onSelectAll={toggleAllItems} - onCheckboxPress={toggleListItem} + onSelectAll={toggleAllTransactions} + onCheckboxPress={toggleTransaction} 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. @@ -192,7 +216,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { windowSize={111} updateCellsBatchingPeriod={200} ListItem={ListItem} - sections={[{data: sortedData, isDisabled: false}]} + sections={[{data: sortedSelectedData, isDisabled: false}]} onSelectRow={(item) => openReport(item)} getItemHeight={getItemHeight} shouldDebounceRowSelect diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 426ad7e7493c2..71940499f6c7e 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -67,6 +67,7 @@ function ReportListItem({ showTooltip, isDisabled, canSelectMultiple, + onCheckboxPress, onSelectRow, onDismissError, onFocus, @@ -203,6 +204,7 @@ function ReportListItem({ onButtonPress={() => { openReportInRHP(transaction); }} + onCheckboxPress={onCheckboxPress} showItemHeaderOnNarrowLayout={false} containerStyle={styles.mt3} isChildListItem diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index f20c238d7744a..66a9a97d9ecd4 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,7 @@ function TransactionListItem({ onButtonPress={() => { onSelectRow(item); }} + onCheckboxPress={onCheckboxPress} isDisabled={!!isDisabled} canSelectMultiple={!!canSelectMultiple} /> diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index c0a53d2cda943..a4c7629a6f327 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -47,6 +47,7 @@ type TransactionListItemRowProps = { item: TransactionListItemType; showTooltip: boolean; onButtonPress: () => void; + onCheckboxPress: (item: TransactionListItemType) => void; showItemHeaderOnNarrowLayout?: boolean; containerStyle?: StyleProp; isChildListItem?: boolean; @@ -222,6 +223,7 @@ function TransactionListItemRow({ isDisabled, canSelectMultiple, onButtonPress, + onCheckboxPress, showItemHeaderOnNarrowLayout = true, containerStyle, isChildListItem = false, @@ -299,7 +301,9 @@ function TransactionListItemRow({ {canSelectMultiple && ( {}} + isChecked={item.isSelected} + onPress={() => onCheckboxPress(item)} + disabled={!item.canDelete || !!item.isDisabled || isDisabled} accessibilityLabel={item.text ?? ''} style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} /> diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 52afaa44f8d22..192de5d02ef5b 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -113,6 +113,9 @@ type SearchTransaction = { /** The transaction amount */ amount: number; + /** If the transaction can be deleted */ + canDelete: boolean + /** The edited transaction amount */ modifiedAmount: number; From 1ed881d90231c3225ee4e1440feb7f060e7993ed Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Fri, 21 Jun 2024 16:09:44 +0200 Subject: [PATCH 03/28] Handle selecting checkboxes in Search --- src/components/Search/SearchHeader.tsx | 5 ++ .../{Search.tsx => Search/index.tsx} | 74 +++++++++++++------ .../SelectionList/BaseSelectionList.tsx | 2 +- .../SelectionList/Search/ReportListItem.tsx | 3 +- .../Search/TransactionListItemRow.tsx | 2 +- src/components/SelectionList/types.ts | 2 + src/types/onyx/SearchResults.ts | 2 +- 7 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 src/components/Search/SearchHeader.tsx rename src/components/{Search.tsx => Search/index.tsx} (79%) diff --git a/src/components/Search/SearchHeader.tsx b/src/components/Search/SearchHeader.tsx new file mode 100644 index 0000000000000..e5d08a1a98b62 --- /dev/null +++ b/src/components/Search/SearchHeader.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +interface SearchHeaderProps {} + +function SearchHeader() {} diff --git a/src/components/Search.tsx b/src/components/Search/index.tsx similarity index 79% rename from src/components/Search.tsx rename to src/components/Search/index.tsx index bf942e6dc2817..06690a0bb808f 100644 --- a/src/components/Search.tsx +++ b/src/components/Search/index.tsx @@ -3,6 +3,10 @@ import type {StackNavigationProp} from '@react-navigation/stack'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import SelectionList from '@components/SelectionList'; +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'; @@ -23,10 +27,6 @@ import type SearchResults from '@src/types/onyx/SearchResults'; import type {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'; type SearchProps = { query: SearchQuery; @@ -47,13 +47,17 @@ function isTransactionListItemType(item: TransactionListItemType | ReportListIte } function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { - const [selectedItems, setSelectedItems] = useState>({}); + const [selectedItems, setSelectedItems] = useState>({}); const {isOffline} = useNetwork(); const styles = useThemeStyles(); const {isLargeScreenWidth} = useWindowDimensions(); const navigation = useNavigation>(); const lastSearchResultsRef = useRef>(); + useEffect(() => { + setSelectedItems({}); + }, [query, policyIDs]); + const getItemHeight = useCallback( (item: TransactionListItemType | ReportListItemType) => { if (isTransactionListItemType(item)) { @@ -153,20 +157,17 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const shouldShowYear = SearchUtils.shouldShowYear(searchResults?.data); const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { - console.log('item', item); - if (isTransactionListItemType(item)) { - // console.log('item', item); - // if (!item.canDelete || !item.keyForList) { - // return; - // } + if (!item.keyForList) { + return; + } setSelectedItems((prev) => { - if (prev[item.keyForList]) { - const {[item.keyForList]: omittedCategory, ...newCategories} = prev; - return newCategories; + if (prev[item.keyForList]?.isSelected) { + const {[item.keyForList]: omittedTransaction, ...transactions} = prev; + return transactions; } - return {...prev, [item.keyForList]: true}; + return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}}; }); return; @@ -176,17 +177,44 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { }; const toggleAllTransactions = () => { - const availableCategories = sortedData.filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const isAllSelected = availableCategories.length === Object.keys(selectedItems).length; - setSelectedItems(isAllSelected ? {} : Object.fromEntries(availableCategories.map((item) => [item.keyForList, true]))); + const areReportItems = searchResults.search.type === 'report'; + const flattenedItems = areReportItems ? (sortedData as ReportListItemType[]).flatMap((item) => item.transactions) : sortedData; + const isAllSelected = flattenedItems.length === Object.keys(selectedItems).length; + + if (isAllSelected) { + setSelectedItems({}); + return; + } + + if (areReportItems) { + setSelectedItems( + Object.fromEntries( + (sortedData as ReportListItemType[]).flatMap((item) => + item.transactions.map((transaction: TransactionListItemType) => [ + transaction.keyForList, + {isSelected: true, canDelete: transaction.canDelete, action: transaction.action}, + ]), + ), + ), + ); + + return; + } + + setSelectedItems(Object.fromEntries((sortedData as TransactionListItemType[]).map((item) => [item.keyForList, {isSelected: true, canDelete: item.canDelete, action: item.action}]))); }; - const sortedSelectedData = sortedData.map((item) => ({ - ...item, - isSelected: !!selectedItems[item.keyForList], - })); + const mapToSelectedTransactionItem = (item: TransactionListItemType) => ({...item, isSelected: !!selectedItems[item.keyForList]?.isSelected}); - console.log('selectedItems', selectedItems); + const sortedSelectedData = sortedData.map((item) => + isTransactionListItemType(item) + ? mapToSelectedTransactionItem(item) + : { + ...item, + transactions: item.transactions?.map(mapToSelectedTransactionItem), + isSelected: item.transactions.every((transaction) => !!selectedItems[transaction.keyForList]?.isSelected), + }, + ); return ( diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index b92b4cef862f8..14cbe62d5ed4b 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -439,7 +439,7 @@ function BaseSelectionList( showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={() => selectRow(item)} - onCheckboxPress={onCheckboxPress ? () => onCheckboxPress?.(item) : undefined} + onCheckboxPress={onCheckboxPress} 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/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 71940499f6c7e..21d8816c99376 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -118,6 +118,7 @@ function ReportListItem({ showTooltip={showTooltip} isDisabled={isDisabled} canSelectMultiple={canSelectMultiple} + onCheckboxPress={onCheckboxPress} onSelectRow={() => openReportInRHP(transactionItem)} onDismissError={onDismissError} onFocus={onFocus} @@ -161,7 +162,7 @@ function ReportListItem({ {canSelectMultiple && ( {}} + onPress={() => onCheckboxPress?.(item)} isChecked={item.isSelected} containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!item.isSelected, !!item.isDisabled)]} disabled={!!isDisabled || item.isDisabledCheckbox} diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index a4c7629a6f327..ee8b7b707520e 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -303,7 +303,7 @@ function TransactionListItemRow({ onCheckboxPress(item)} - disabled={!item.canDelete || !!item.isDisabled || isDisabled} + disabled={!!item.isDisabled || isDisabled} accessibilityLabel={item.text ?? ''} style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} /> diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index f65a2624ad100..2104aceddf15c 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -172,6 +172,8 @@ type TransactionListItemType = ListItem & * This is true if at least one transaction in the dataset was created in past years */ shouldShowYear: boolean; + + keyForList: string; }; type ReportListItemType = ListItem & diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 192de5d02ef5b..339810f00a062 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -114,7 +114,7 @@ type SearchTransaction = { amount: number; /** If the transaction can be deleted */ - canDelete: boolean + canDelete: boolean; /** The edited transaction amount */ modifiedAmount: number; From 750768bba77ecd94191bcf10c5398d25d90d71df Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Mon, 24 Jun 2024 13:33:29 +0200 Subject: [PATCH 04/28] Add template of SearchHeader --- src/CONST.ts | 9 +++ src/components/Search/SearchHeader.tsx | 100 +++++++++++++++++++++++- src/components/Search/index.tsx | 101 +++++++++++++------------ src/pages/Search/SearchPage.tsx | 17 ----- src/types/onyx/SearchResults.ts | 11 +++ 5 files changed, 172 insertions(+), 66 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 1d6c3a92faa94..101b17f7587a5 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -4832,6 +4832,15 @@ const CONST = { REPORT: 'report', }, + SEARCH_BULK_ACTION_TYPES: { + DELETE: 'delete', + HOLD: 'hold', + UNHOLD: 'unhold', + SUBMIT: 'submit', + APPROVE: 'approve', + PAY: 'pay', + }, + REFERRER: { NOTIFICATION: 'notification', }, diff --git a/src/components/Search/SearchHeader.tsx b/src/components/Search/SearchHeader.tsx index e5d08a1a98b62..1ebe96e2c3f51 100644 --- a/src/components/Search/SearchHeader.tsx +++ b/src/components/Search/SearchHeader.tsx @@ -1,5 +1,101 @@ import React 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 useWindowDimensions from '@hooks/useWindowDimensions'; +import CONST from '@src/CONST'; +import type {SearchQuery, SelectedTransactions} from '@src/types/onyx/SearchResults'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import type IconAsset from '@src/types/utils/IconAsset'; -interface SearchHeaderProps {} +type SearchHeaderProps = { + query: SearchQuery; + selectedItems: SelectedTransactions; +}; -function SearchHeader() {} +function SearchHeader({query, selectedItems}: SearchHeaderProps) { + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); + 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 = () => { + 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: 'Delete', + value: CONST.SEARCH_BULK_ACTION_TYPES.DELETE, + onSelected: () => {}, + }); + } + + const itemsToHold = selectedItemsKeys.filter((id) => selectedItems[id].action === 'hold'); + + if (itemsToHold.length > 0) { + options.push({ + icon: Expensicons.Stopwatch, + text: 'Hold', + value: CONST.SEARCH_BULK_ACTION_TYPES.HOLD, + onSelected: () => {}, + }); + } + + const itemsToUnhold = selectedItemsKeys.filter((id) => selectedItems[id].action === 'unhold'); + + if (itemsToUnhold.length > 0) { + options.push({ + icon: Expensicons.Stopwatch, + text: 'Unhold', + value: CONST.SEARCH_BULK_ACTION_TYPES.UNHOLD, + onSelected: () => {}, + }); + } + + return ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} + options={options} + isSplitButton={false} + isDisabled + /> + ); + }; + + if (isSmallScreenWidth) { + return null; + } + + return ( + + {getHeaderButtons()} + + ); +} + +SearchHeader.displayName = 'SearchHeader'; + +export default SearchHeader; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 06690a0bb808f..d5ff1f0a6454c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -24,9 +24,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SearchResults from '@src/types/onyx/SearchResults'; -import type {SearchQuery} from '@src/types/onyx/SearchResults'; +import type {SearchQuery, SelectedTransactions} from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import SearchHeader from './SearchHeader'; type SearchProps = { query: SearchQuery; @@ -47,7 +48,7 @@ function isTransactionListItemType(item: TransactionListItemType | ReportListIte } function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { - const [selectedItems, setSelectedItems] = useState>({}); + const [selectedItems, setSelectedItems] = useState({}); const {isOffline} = useNetwork(); const styles = useThemeStyles(); const {isLargeScreenWidth} = useWindowDimensions(); @@ -217,52 +218,58 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { ); return ( - - customListHeader={ - - } - canSelectMultiple={isLargeScreenWidth} - onSelectAll={toggleAllTransactions} - onCheckboxPress={toggleTransaction} - 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. - // The windowSize determines the number of items rendered before and after the currently visible items. - // A larger windowSize helps pre-render more items, reducing the likelihood of blank spaces appearing. - // The updateCellsBatchingPeriod sets the delay (in milliseconds) between rendering batches of cells. - // A longer delay allows the UI to handle rendering in smaller increments, which can improve performance and smoothness. - // For more information, refer to the React Native documentation: - // https://reactnative.dev/docs/0.73/optimizing-flatlist-configuration#windowsize - // https://reactnative.dev/docs/0.73/optimizing-flatlist-configuration#updatecellsbatchingperiod - windowSize={111} - updateCellsBatchingPeriod={200} - ListItem={ListItem} - sections={[{data: sortedSelectedData, isDisabled: false}]} - onSelectRow={(item) => openReport(item)} - getItemHeight={getItemHeight} - shouldDebounceRowSelect - shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} - listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} - containerStyle={[styles.pv0]} - showScrollIndicator={false} - onEndReachedThreshold={0.75} - onEndReached={fetchMoreResults} - listFooterContent={ - isLoadingMoreItems ? ( - + + + customListHeader={ + - ) : undefined - } - /> + } + canSelectMultiple={isLargeScreenWidth} + onSelectAll={toggleAllTransactions} + onCheckboxPress={toggleTransaction} + 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. + // The windowSize determines the number of items rendered before and after the currently visible items. + // A larger windowSize helps pre-render more items, reducing the likelihood of blank spaces appearing. + // The updateCellsBatchingPeriod sets the delay (in milliseconds) between rendering batches of cells. + // A longer delay allows the UI to handle rendering in smaller increments, which can improve performance and smoothness. + // For more information, refer to the React Native documentation: + // https://reactnative.dev/docs/0.73/optimizing-flatlist-configuration#windowsize + // https://reactnative.dev/docs/0.73/optimizing-flatlist-configuration#updatecellsbatchingperiod + windowSize={111} + updateCellsBatchingPeriod={200} + ListItem={ListItem} + sections={[{data: sortedSelectedData, isDisabled: false}]} + onSelectRow={(item) => openReport(item)} + getItemHeight={getItemHeight} + shouldDebounceRowSelect + shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} + listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} + containerStyle={[styles.pv0]} + showScrollIndicator={false} + onEndReachedThreshold={0.75} + onEndReached={fetchMoreResults} + listFooterContent={ + isLoadingMoreItems ? ( + + ) : undefined + } + /> + ); } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 1f7db3eeb2c52..8354749b95410 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.TAB_SEARCH).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.TAB_SEARCH.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} > - > & Record & Record; }; +/** Model of the selected transaction */ +type SelectedTransactionInfo = { + isSelected: boolean; + canDelete: boolean; + action: string; +}; + +/** Model of selected results */ +type SelectedTransactions = Record; + export default SearchResults; export type { @@ -229,4 +239,5 @@ export type { SearchTypeToItemMap, SearchReport, SectionsType, + SelectedTransactions, }; From ebe5046f36a51ce593713bedfa46f1615a3a8bdd Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Tue, 25 Jun 2024 12:07:23 +0200 Subject: [PATCH 05/28] Add bulk actions to SearchActions --- src/components/Search/SearchHeader.tsx | 48 +++++++++++++------ src/components/Search/index.tsx | 1 + .../DeleteMoneyRequestOnSearchParams.ts | 5 ++ .../HoldMoneyRequestOnSearchParams.ts | 6 +++ .../UnholdMoneyRequestOnSearchParams.ts | 5 ++ src/libs/API/parameters/index.ts | 3 ++ src/libs/API/types.ts | 7 +++ src/libs/actions/Search.ts | 27 +++++++++-- 8 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 src/libs/API/parameters/DeleteMoneyRequestOnSearchParams.ts create mode 100644 src/libs/API/parameters/HoldMoneyRequestOnSearchParams.ts create mode 100644 src/libs/API/parameters/UnholdMoneyRequestOnSearchParams.ts diff --git a/src/components/Search/SearchHeader.tsx b/src/components/Search/SearchHeader.tsx index 1ebe96e2c3f51..f5655a5b2ad81 100644 --- a/src/components/Search/SearchHeader.tsx +++ b/src/components/Search/SearchHeader.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -6,6 +7,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import useLocalize from '@hooks/useLocalize'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as SearchActions from '@libs/actions/Search'; import CONST from '@src/CONST'; import type {SearchQuery, SelectedTransactions} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -14,9 +16,10 @@ import type IconAsset from '@src/types/utils/IconAsset'; type SearchHeaderProps = { query: SearchQuery; selectedItems: SelectedTransactions; + hash: number; }; -function SearchHeader({query, selectedItems}: SearchHeaderProps) { +function SearchHeader({query, selectedItems, hash}: SearchHeaderProps) { const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); const headerContent: {[key in SearchQuery]: {icon: IconAsset; title: string}} = { @@ -34,48 +37,63 @@ function SearchHeader({query, selectedItems}: SearchHeaderProps) { return null; } - const itemsToDelete = selectedItemsKeys.filter((id) => !selectedItems[id].canDelete); + const itemsToDelete = selectedItemsKeys.filter((id) => selectedItems[id].canDelete); if (itemsToDelete.length > 0) { options.push({ icon: Expensicons.Trashcan, text: 'Delete', value: CONST.SEARCH_BULK_ACTION_TYPES.DELETE, - onSelected: () => {}, + onSelected: () => { + SearchActions.deleteMoneyRequestOnSearch(hash, itemsToDelete); + }, }); } - const itemsToHold = selectedItemsKeys.filter((id) => selectedItems[id].action === 'hold'); + const itemsToHold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH_BULK_ACTION_TYPES.HOLD); if (itemsToHold.length > 0) { options.push({ icon: Expensicons.Stopwatch, text: 'Hold', value: CONST.SEARCH_BULK_ACTION_TYPES.HOLD, - onSelected: () => {}, + onSelected: () => { + SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, ''); + }, }); } - const itemsToUnhold = selectedItemsKeys.filter((id) => selectedItems[id].action === 'unhold'); + const itemsToUnhold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH_BULK_ACTION_TYPES.UNHOLD); if (itemsToUnhold.length > 0) { options.push({ icon: Expensicons.Stopwatch, text: 'Unhold', value: CONST.SEARCH_BULK_ACTION_TYPES.UNHOLD, - onSelected: () => {}, + onSelected: () => { + SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold); + }, }); } + if (options.length > 0) { + return ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} + options={options} + isSplitButton={false} + /> + ); + } + return ( - null} - shouldAlwaysShowDropdownMenu - pressOnEnter - buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} - options={options} - isSplitButton={false} +