Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Search] Selection Mode for small screens #44820

Merged
merged 14 commits into from
Jul 11, 2024
6 changes: 5 additions & 1 deletion src/components/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ type ButtonProps = Partial<ChildrenProps> & {

/** Whether the button should use split style or not */
isSplitButton?: boolean;

/** Whether button's content should be centered */
isContentCentered?: boolean;
};

type KeyboardShortcutComponentProps = Pick<ButtonProps, 'isDisabled' | 'isLoading' | 'onPress' | 'pressOnEnter' | 'allowBubble' | 'enterKeyEventListenerPriority'>;
Expand Down Expand Up @@ -202,6 +205,7 @@ function Button(
id = '',
accessibilityLabel = '',
isSplitButton = false,
isContentCentered = false,
...rest
}: ButtonProps,
ref: ForwardedRef<View>,
Expand Down Expand Up @@ -239,7 +243,7 @@ function Button(
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (icon || shouldShowRightIcon) {
return (
<View style={[styles.justifyContentBetween, styles.flexRow]}>
<View style={[isContentCentered ? styles.justifyContentCenter : styles.justifyContentBetween, styles.flexRow]}>
<View style={[styles.alignItemsCenter, styles.flexRow, styles.flexShrink1]}>
{icon && (
<View style={[large ? styles.mr2 : styles.mr1, !text && styles.mr0, iconStyles]}>
Expand Down
107 changes: 85 additions & 22 deletions src/components/Search/SearchListWithHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef, useEffect, useMemo, useState} from 'react';
import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'react';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
import Modal from '@components/Modal';
import SelectionList from '@components/SelectionList';
import type {BaseSelectionListProps, ReportListItemType, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types';
import useLocalize from '@hooks/useLocalize';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as SearchUtils from '@libs/SearchUtils';
import CONST from '@src/CONST';
import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults';
Expand All @@ -13,6 +18,8 @@ type SearchListWithHeaderProps = Omit<BaseSelectionListProps<ReportListItemType
hash: number;
data: TransactionListItemType[] | ReportListItemType[];
searchType: SearchDataTypes;
isMobileSelectionModeActive?: boolean;
setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void;
};

function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] {
Expand All @@ -33,7 +40,14 @@ function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListIt
};
}

function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchType, ...props}: SearchListWithHeaderProps, ref: ForwardedRef<SelectionListHandle>) {
function SearchListWithHeader(
{ListItem, onSelectRow, query, hash, data, searchType, isMobileSelectionModeActive, setIsMobileSelectionModeActive, ...props}: SearchListWithHeaderProps,
ref: ForwardedRef<SelectionListHandle>,
) {
const {isSmallScreenWidth} = useWindowDimensions();
const {translate} = useLocalize();
const [isModalVisible, setIsModalVisible] = useState(false);
const [longPressedItem, setLongPressedItem] = useState<TransactionListItemType | ReportListItemType | null>(null);
const [selectedItems, setSelectedItems] = useState<SelectedTransactions>({});

const clearSelectedItems = () => setSelectedItems({});
Expand All @@ -42,39 +56,72 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT
clearSelectedItems();
}, [hash]);

const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => {
if (SearchUtils.isTransactionListItemType(item)) {
if (!item.keyForList) {
const toggleTransaction = useCallback(
(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;
}

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}};
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)),
});
},
[selectedItems],
);

const openBottomModal = (item: TransactionListItemType | ReportListItemType | null) => {
if (!isSmallScreenWidth) {
return;
}

if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) {
const reducedSelectedItems: SelectedTransactions = {...selectedItems};
setLongPressedItem(item);
setIsModalVisible(true);
};

item.transactions.forEach((transaction) => {
delete reducedSelectedItems[transaction.keyForList];
});
const turnOnSelectionMode = useCallback(() => {
setIsMobileSelectionModeActive?.(true);
setIsModalVisible(false);

if (longPressedItem) {
toggleTransaction(longPressedItem);
}
}, [longPressedItem, setIsMobileSelectionModeActive, toggleTransaction]);

setSelectedItems(reducedSelectedItems);
const closeBottomModal = useCallback(() => {
setIsModalVisible(false);
}, []);

useEffect(() => {
if (isMobileSelectionModeActive) {
return;
}

setSelectedItems({
...selectedItems,
...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)),
});
};
setSelectedItems({});
}, [setSelectedItems, isMobileSelectionModeActive]);

const toggleAllTransactions = () => {
const areItemsOfReportType = searchType === CONST.SEARCH.DATA_TYPES.REPORT;
Expand Down Expand Up @@ -104,17 +151,33 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT
clearSelectedItems={clearSelectedItems}
query={query}
hash={hash}
isMobileSelectionModeActive={isMobileSelectionModeActive}
setIsMobileSelectionModeActive={setIsMobileSelectionModeActive}
/>
<SelectionList<ReportListItemType | TransactionListItemType>
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
sections={[{data: sortedSelectedData, isDisabled: false}]}
ListItem={ListItem}
onSelectRow={onSelectRow}
onLongPressRow={openBottomModal}
ref={ref}
onCheckboxPress={toggleTransaction}
onSelectAll={toggleAllTransactions}
isMobileSelectionModeActive={isMobileSelectionModeActive}
/>

<Modal
isVisible={isModalVisible}
type={CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED}
onClose={closeBottomModal}
>
<MenuItem
title={translate('common.select')}
icon={Expensicons.Checkmark}
onPress={turnOnSelectionMode}
/>
</Modal>
</>
);
}
Expand Down
60 changes: 41 additions & 19 deletions src/components/Search/SearchPageHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useCallback} from 'react';
import React, {useMemo} from 'react';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
Expand All @@ -10,6 +10,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as SearchActions from '@libs/actions/Search';
import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {SearchQuery} from '@src/types/onyx/SearchResults';
Expand All @@ -22,11 +23,13 @@ type SearchHeaderProps = {
selectedItems?: SelectedTransactions;
clearSelectedItems?: () => void;
hash: number;
isMobileSelectionModeActive?: boolean;
setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void;
};

type SearchHeaderOptionValue = DeepValueOf<typeof CONST.SEARCH.BULK_ACTION_TYPES> | undefined;

function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: SearchHeaderProps) {
function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchHeaderProps) {
const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
Expand All @@ -39,12 +42,13 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')},
};

const getHeaderButtons = useCallback(() => {
const selectedItemsKeys = Object.keys(selectedItems ?? []);

const headerButtonsOptions = useMemo(() => {
const options: Array<DropdownOption<SearchHeaderOptionValue>> = [];
const selectedItemsKeys = Object.keys(selectedItems ?? []);

if (selectedItemsKeys.length === 0) {
return null;
return options;
}

const itemsToDelete = selectedItemsKeys.filter((id) => selectedItems[id].canDelete);
Expand All @@ -56,6 +60,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE,
onSelected: () => {
clearSelectedItems?.();
if (isMobileSelectionModeActive) {
setIsMobileSelectionModeActive?.(false);
}
SearchActions.deleteMoneyRequestOnSearch(hash, itemsToDelete);
},
});
Expand All @@ -70,6 +77,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD,
onSelected: () => {
clearSelectedItems?.();
if (isMobileSelectionModeActive) {
setIsMobileSelectionModeActive?.(false);
}
SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, '');
},
});
Expand All @@ -84,6 +94,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD,
onSelected: () => {
clearSelectedItems?.();
if (isMobileSelectionModeActive) {
setIsMobileSelectionModeActive?.(false);
}
SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold);
},
});
Expand All @@ -107,21 +120,18 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
});
}

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]);
return options;
}, [clearSelectedItems, hash, selectedItems, selectedItemsKeys, styles, theme, translate, isMobileSelectionModeActive, setIsMobileSelectionModeActive]);

if (isSmallScreenWidth) {
if (isMobileSelectionModeActive) {
return (
<SearchSelectedNarrow
options={headerButtonsOptions}
itemsLength={selectedItemsKeys.length}
rlinoz marked this conversation as resolved.
Show resolved Hide resolved
/>
);
}
return null;
}

Expand All @@ -131,11 +141,23 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}:
icon={headerContent[query]?.icon}
shouldShowBackButton={false}
>
{getHeaderButtons()}
{headerButtonsOptions.length > 0 && (
<ButtonWithDropdownMenu
onPress={() => null}
shouldAlwaysShowDropdownMenu
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is that correct? that is just the action on pressing the main button that would display the list, and each option has their own onPress?
Or are we not yet ready to call the actual action?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onPress function fires only when we pass isSplitButton prop - in this case we don't need it

pressOnEnter
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})}
options={headerButtonsOptions}
isSplitButton={false}
isDisabled={isOffline}
/>
)}
</HeaderWithBackButton>
);
}

SearchPageHeader.displayName = 'SearchPageHeader';

export type {SearchHeaderOptionValue};
export default SearchPageHeader;
33 changes: 20 additions & 13 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,19 @@ type SearchProps = {
policyIDs?: string;
sortBy?: SearchColumnType;
sortOrder?: SortOrder;
isMobileSelectionModeActive?: boolean;
setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void;
};

const sortableSearchTabs: SearchQuery[] = [CONST.SEARCH.TAB.ALL];
const transactionItemMobileHeight = 100;
const reportItemTransactionHeight = 52;
const listItemPadding = 12; // this is equivalent to 'mb3' on every transaction/report list item
const searchHeaderHeight = 54;

function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
function Search({query, policyIDs, sortBy, sortOrder, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchProps) {
const {isOffline} = useNetwork();
const styles = useThemeStyles();
const {isLargeScreenWidth} = useWindowDimensions();
const {isLargeScreenWidth, isSmallScreenWidth} = useWindowDimensions();
const navigation = useNavigation<StackNavigationProp<AuthScreensParamList>>();
const lastSearchResultsRef = useRef<OnyxEntry<SearchResults>>();
const {setCurrentSearchHash} = useSearchContext();
Expand Down Expand Up @@ -165,24 +166,28 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {

const shouldShowYear = SearchUtils.shouldShowYear(searchResults?.data);

const canSelectMultiple = isSmallScreenWidth ? isMobileSelectionModeActive : true;

return (
<SearchListWithHeader
query={query}
hash={hash}
data={sortedData}
searchType={searchResults?.search?.type as SearchDataTypes}
customListHeader={
<SearchTableHeader
data={searchResults?.data}
metadata={searchResults?.search}
onSortPress={onSortPress}
sortOrder={sortOrder}
isSortingAllowed={isSortingAllowed}
sortBy={sortBy}
shouldShowYear={shouldShowYear}
/>
!isLargeScreenWidth ? null : (
<SearchTableHeader
data={searchResults?.data}
metadata={searchResults?.search}
onSortPress={onSortPress}
sortOrder={sortOrder}
isSortingAllowed={isSortingAllowed}
sortBy={sortBy}
shouldShowYear={shouldShowYear}
/>
)
}
canSelectMultiple={isLargeScreenWidth}
canSelectMultiple={canSelectMultiple}
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.
Expand All @@ -205,6 +210,8 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
showScrollIndicator={false}
onEndReachedThreshold={0.75}
onEndReached={fetchMoreResults}
setIsMobileSelectionModeActive={setIsMobileSelectionModeActive}
isMobileSelectionModeActive={isMobileSelectionModeActive}
listFooterContent={
isLoadingMoreItems ? (
<TableListItemSkeleton
Expand Down
Loading
Loading