Skip to content

Commit

Permalink
Merge pull request #44820 from software-mansion-labs/search/selection…
Browse files Browse the repository at this point in the history
…-mode

[Search] Selection Mode for small screens
  • Loading branch information
rlinoz authored Jul 11, 2024
2 parents 19922f5 + 29fc205 commit 978b833
Show file tree
Hide file tree
Showing 15 changed files with 335 additions and 69 deletions.
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}
/>
);
}
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
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 @@ -35,18 +35,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 @@ -176,24 +177,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 @@ -216,6 +221,8 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) {
showScrollIndicator={false}
onEndReachedThreshold={0.75}
onEndReached={fetchMoreResults}
setIsMobileSelectionModeActive={setIsMobileSelectionModeActive}
isMobileSelectionModeActive={isMobileSelectionModeActive}
listFooterContent={
isLoadingMoreItems ? (
<SearchRowSkeleton
Expand Down
Loading

0 comments on commit 978b833

Please sign in to comment.