Skip to content

Commit

Permalink
Merge pull request #44385 from software-mansion-labs/search/bulk-actions
Browse files Browse the repository at this point in the history
[Search v1] Add bulk actions
  • Loading branch information
luacmartins authored Jul 2, 2024
2 parents cb1b452 + 4c82eac commit fe880cb
Show file tree
Hide file tree
Showing 25 changed files with 478 additions and 49 deletions.
8 changes: 8 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions src/components/ButtonWithDropdownMenu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ type DropdownOption<TValueType> = {
iconDescription?: string;
onSelected?: () => void;
disabled?: boolean;
iconFill?: string;
interactive?: boolean;
numberOfLinesTitle?: number;
titleStyle?: ViewStyle;
};

type ButtonWithDropdownMenuProps<TValueType> = {
Expand Down
3 changes: 3 additions & 0 deletions src/components/PopoverMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -247,6 +248,8 @@ function PopoverMenu({
shouldForceRenderingTooltipLeft={item.shouldForceRenderingTooltipLeft}
tooltipWrapperStyle={item.tooltipWrapperStyle}
renderTooltipContent={item.renderTooltipContent}
numberOfLinesTitle={item.numberOfLinesTitle}
interactive={item.interactive}
/>
))}
</View>
Expand Down
124 changes: 124 additions & 0 deletions src/components/Search/SearchListWithHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef, useEffect, useMemo, useState} from 'react';
import SelectionList from '@components/SelectionList';
import type {BaseSelectionListProps, ReportListItemType, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types';
import * as SearchUtils from '@libs/SearchUtils';
import CONST from '@src/CONST';
import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults';
import SearchPageHeader from './SearchPageHeader';
import type {SelectedTransactionInfo, SelectedTransactions} from './types';

type SearchListWithHeaderProps = Omit<BaseSelectionListProps<ReportListItemType | TransactionListItemType>, 'onSelectAll' | 'onCheckboxPress' | 'sections'> & {
query: SearchQuery;
hash: number;
data: TransactionListItemType[] | ReportListItemType[];
searchType: SearchDataTypes;
};

function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] {
return [item.keyForList, {isSelected: true, canDelete: item.canDelete, action: item.action}];
}

function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, selectedItems: SelectedTransactions) {
return {...item, isSelected: !!selectedItems[item.keyForList]?.isSelected};
}

function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType, selectedItems: SelectedTransactions) {
return SearchUtils.isTransactionListItemType(item)
? mapToTransactionItemWithSelectionInfo(item, selectedItems)
: {
...item,
transactions: item.transactions?.map((tranaction) => mapToTransactionItemWithSelectionInfo(tranaction, selectedItems)),
isSelected: item.transactions.every((transaction) => !!selectedItems[transaction.keyForList]?.isSelected),
};
}

function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchType, ...props}: SearchListWithHeaderProps, ref: ForwardedRef<SelectionListHandle>) {
const [selectedItems, setSelectedItems] = useState<SelectedTransactions>({});

const clearSelectedItems = () => setSelectedItems({});

useEffect(() => {
clearSelectedItems();
}, [hash]);

const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => {
if (SearchUtils.isTransactionListItemType(item)) {
if (!item.keyForList) {
return;
}

setSelectedItems((prev) => {
if (prev[item.keyForList]?.isSelected) {
const {[item.keyForList]: omittedTransaction, ...transactions} = prev;
return transactions;
}
return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}};
});

return;
}

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

item.transactions.forEach((transaction) => {
delete reducedSelectedItems[transaction.keyForList];
});

setSelectedItems(reducedSelectedItems);
return;
}

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

const toggleAllTransactions = () => {
const areItemsOfReportType = searchType === CONST.SEARCH.DATA_TYPES.REPORT;
const flattenedItems = areItemsOfReportType ? (data as ReportListItemType[]).flatMap((item) => item.transactions) : data;
const isAllSelected = flattenedItems.length === Object.keys(selectedItems).length;

if (isAllSelected) {
clearSelectedItems();
return;
}

if (areItemsOfReportType) {
setSelectedItems(Object.fromEntries((data as ReportListItemType[]).flatMap((item) => item.transactions.map(mapTransactionItemToSelectedEntry))));

return;
}

setSelectedItems(Object.fromEntries((data as TransactionListItemType[]).map(mapTransactionItemToSelectedEntry)));
};

const sortedSelectedData = useMemo(() => data.map((item) => mapToItemWithSelectionInfo(item, selectedItems)), [data, selectedItems]);

return (
<>
<SearchPageHeader
selectedItems={selectedItems}
clearSelectedItems={clearSelectedItems}
query={query}
hash={hash}
/>
<SelectionList<ReportListItemType | TransactionListItemType>
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
sections={[{data: sortedSelectedData, isDisabled: false}]}
ListItem={ListItem}
onSelectRow={onSelectRow}
ref={ref}
onCheckboxPress={toggleTransaction}
onSelectAll={toggleAllTransactions}
/>
</>
);
}

SearchListWithHeader.displayName = 'SearchListWithHeader';

export default forwardRef(SearchListWithHeader);
141 changes: 141 additions & 0 deletions src/components/Search/SearchPageHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React, {useCallback} from 'react';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as SearchActions from '@libs/actions/Search';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {SearchQuery} from '@src/types/onyx/SearchResults';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
import type IconAsset from '@src/types/utils/IconAsset';
import type {SelectedTransactions} from './types';

type SearchHeaderProps = {
query: SearchQuery;
selectedItems?: SelectedTransactions;
clearSelectedItems?: () => void;
hash: number;
};

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

function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: SearchHeaderProps) {
const {translate} = useLocalize();
const theme = useTheme();
const styles = useThemeStyles();
const {isOffline} = useNetwork();
const {isSmallScreenWidth} = useResponsiveLayout();
const headerContent: {[key in SearchQuery]: {icon: IconAsset; title: string}} = {
all: {icon: Illustrations.MoneyReceipts, title: translate('common.expenses')},
shared: {icon: Illustrations.SendMoney, title: translate('common.shared')},
drafts: {icon: Illustrations.Pencil, title: translate('common.drafts')},
finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')},
};

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

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

const itemsToDelete = selectedItemsKeys.filter((id) => selectedItems[id].canDelete);

if (itemsToDelete.length > 0) {
options.push({
icon: Expensicons.Trashcan,
text: translate('search.bulkActions.delete'),
value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE,
onSelected: () => {
clearSelectedItems?.();
SearchActions.deleteMoneyRequestOnSearch(hash, itemsToDelete);
},
});
}

const itemsToHold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH.BULK_ACTION_TYPES.HOLD);

if (itemsToHold.length > 0) {
options.push({
icon: Expensicons.Stopwatch,
text: translate('search.bulkActions.hold'),
value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD,
onSelected: () => {
clearSelectedItems?.();
SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, '');
},
});
}

const itemsToUnhold = selectedItemsKeys.filter((id) => selectedItems[id].action === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD);

if (itemsToUnhold.length > 0) {
options.push({
icon: Expensicons.Stopwatch,
text: translate('search.bulkActions.unhold'),
value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD,
onSelected: () => {
clearSelectedItems?.();
SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold);
},
});
}

if (options.length === 0) {
const emptyOptionStyle = {
interactive: false,
iconFill: theme.icon,
iconHeight: variables.iconSizeLarge,
iconWidth: variables.iconSizeLarge,
numberOfLinesTitle: 2,
titleStyle: {...styles.colorMuted, ...styles.fontWeightNormal},
};

options.push({
icon: Expensicons.Exclamation,
text: translate('search.bulkActions.noOptionsAvailable'),
value: undefined,
...emptyOptionStyle,
});
}

return (
<ButtonWithDropdownMenu
onPress={() => null}
shouldAlwaysShowDropdownMenu
pressOnEnter
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})}
options={options}
isSplitButton={false}
isDisabled={isOffline}
/>
);
}, [clearSelectedItems, hash, isOffline, selectedItems, styles.colorMuted, styles.fontWeightNormal, theme.icon, translate]);

if (isSmallScreenWidth) {
return null;
}

return (
<HeaderWithBackButton
title={headerContent[query]?.title}
icon={headerContent[query]?.icon}
shouldShowBackButton={false}
>
{getHeaderButtons()}
</HeaderWithBackButton>
);
}

SearchPageHeader.displayName = 'SearchPageHeader';

export default SearchPageHeader;
Loading

0 comments on commit fe880cb

Please sign in to comment.