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 v1] Add bulk actions #44385

Merged
merged 30 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
da896c4
Add checkbox to search list items
WojtekBoman Jun 20, 2024
bf09dba
Handle disabling checkboxes
WojtekBoman Jun 20, 2024
1ed881d
Handle selecting checkboxes in Search
WojtekBoman Jun 21, 2024
750768b
Add template of SearchHeader
WojtekBoman Jun 24, 2024
ebe5046
Add bulk actions to SearchActions
WojtekBoman Jun 25, 2024
a3de9b7
Fix TS in search list items components
WojtekBoman Jun 25, 2024
0614be1
Refactor passing onCheckboxPress in BaseSelectionList
WojtekBoman Jun 25, 2024
3cd114a
Fix selecting report list items
WojtekBoman Jun 25, 2024
3edbdb1
Add translations
WojtekBoman Jun 25, 2024
b288a7c
Merge branch 'main' into search/bulk-actions
WojtekBoman Jul 1, 2024
f832e43
Add translations, missing req params and clearing state
WojtekBoman Jul 1, 2024
1c71750
Display no options available message
WojtekBoman Jul 1, 2024
3638a40
Add isSelected prop to ActionCell
WojtekBoman Jul 1, 2024
4f0a8e1
Fix selecting checkboxes in ReportListItems
WojtekBoman Jul 1, 2024
9023d74
Add isButtonSelected prop
WojtekBoman Jul 1, 2024
93f2e50
Add SearchSelectionListWithHeader
WojtekBoman Jul 1, 2024
c4256e2
Add missing docs to SelectedTransactionInfo
WojtekBoman Jul 1, 2024
bcf6cbb
Fix types in DropdownOption
WojtekBoman Jul 1, 2024
381b750
Rename SearchPage to SearchPageHeader
WojtekBoman Jul 2, 2024
8f13ca5
Uncomment hold and unhold actions
WojtekBoman Jul 2, 2024
5f3b4a0
Wrap getHeaderButtons in useCallback in SearchPageHeader
WojtekBoman Jul 2, 2024
437fc2a
Clear state after hold unhold actions
WojtekBoman Jul 2, 2024
ca1bd1d
Use const to check areReportItems
WojtekBoman Jul 2, 2024
4804ccc
Refactor useWindowDimensions to useResponsiveLayout in SearchPageHeader
WojtekBoman Jul 2, 2024
311e18d
Add missing docs to TransactionListItemType
WojtekBoman Jul 2, 2024
b39c0c2
Add emptyOptionStyle
WojtekBoman Jul 2, 2024
3a2cef8
Cleanup search components and types
WojtekBoman Jul 2, 2024
aa22412
Add helper functions in SearchListWithHeader
WojtekBoman Jul 2, 2024
e9d58ea
Disable dropdown button in SearchPageHeader when user is offline
WojtekBoman Jul 2, 2024
4c82eac
Merge branch 'main' into search/bulk-actions
WojtekBoman Jul 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(() => {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const getHeaderButtons = useCallback(() => {
const getSelection(Options|Buttons) = useCallback(() => {

Copy link
Contributor Author

@WojtekBoman WojtekBoman Jul 2, 2024

Choose a reason for hiding this comment

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

This is the convention used in list components. Check for example: WorkspaceCategoriesPage and WorkspaceTagsPage

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, '');
Copy link
Contributor

Choose a reason for hiding this comment

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

We'll still need to redirect the user to the reason page. Maybe that's something that @Kicu can address in his PR?

},
});
}

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
Loading