diff --git a/assets/images/arrow-down-long.svg b/assets/images/arrow-down-long.svg new file mode 100644 index 000000000000..cbf6e7e5ad2f --- /dev/null +++ b/assets/images/arrow-down-long.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/arrow-up-long.svg b/assets/images/arrow-up-long.svg new file mode 100644 index 000000000000..13d7a0c2d67e --- /dev/null +++ b/assets/images/arrow-up-long.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST.ts b/src/CONST.ts index 4f622cc0b3bf..4665ec94625a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -932,6 +932,7 @@ const CONST = { RECEIPT: 'receipt', DATE: 'date', MERCHANT: 'merchant', + DESCRIPTION: 'description', FROM: 'from', TO: 'to', CATEGORY: 'category', @@ -4777,6 +4778,11 @@ const CONST = { REFERRER: { NOTIFICATION: 'notification', }, + + SORT_ORDER: { + ASC: 'asc', + DESC: 'desc', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 0302faf7e92b..ed6e1cd3ce38 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2,6 +2,8 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; import type {IOUAction, IOUType} from './CONST'; import type {IOURequestType} from './libs/actions/IOU'; +import type {CentralPaneNavigatorParamList} from './libs/Navigation/types'; +import type {SearchQuery} from './types/onyx/SearchResults'; import type AssertTypesNotEqual from './types/utils/AssertTypesNotEqual'; // This is a file containing constants for all the routes we want to be able to go to @@ -35,7 +37,15 @@ const ROUTES = { SEARCH: { route: '/search/:query', - getRoute: (query: string) => `search/${query}` as const, + getRoute: (searchQuery: SearchQuery, queryParams?: CentralPaneNavigatorParamList['Search_Central_Pane']) => { + const {sortBy, sortOrder} = queryParams ?? {}; + + if (!sortBy && !sortOrder) { + return `search/${searchQuery}` as const; + } + + return `search/${searchQuery}?sortBy=${sortBy}&sortOrder=${sortOrder}` as const; + }, }, SEARCH_REPORT: { diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index c127fbda1bd5..bcc40947a83a 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -2,8 +2,10 @@ import AddReaction from '@assets/images/add-reaction.svg'; import All from '@assets/images/all.svg'; import Android from '@assets/images/android.svg'; import Apple from '@assets/images/apple.svg'; +import ArrowDownLong from '@assets/images/arrow-down-long.svg'; import ArrowRightLong from '@assets/images/arrow-right-long.svg'; import ArrowRight from '@assets/images/arrow-right.svg'; +import ArrowUpLong from '@assets/images/arrow-up-long.svg'; import UpArrow from '@assets/images/arrow-up.svg'; import ArrowsUpDown from '@assets/images/arrows-updown.svg'; import AdminRoomAvatar from '@assets/images/avatars/admin-room.svg'; @@ -182,6 +184,8 @@ export { ArrowRight, ArrowRightLong, ArrowsUpDown, + ArrowUpLong, + ArrowDownLong, Wrench, BackArrow, Bank, diff --git a/src/components/Search.tsx b/src/components/Search.tsx index 699ae0907f31..fcb915379181 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -1,4 +1,7 @@ -import React, {useEffect} from 'react'; +import {useNavigation} from '@react-navigation/native'; +import type {StackNavigationProp} from '@react-navigation/stack'; +import React, {useEffect, useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -6,11 +9,15 @@ import * as SearchActions from '@libs/actions/Search'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import * as SearchUtils from '@libs/SearchUtils'; +import type {SearchColumnType, SortOrder} from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; +import type {CentralPaneNavigatorParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; 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 SearchResults from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import SelectionList from './SelectionList'; @@ -19,8 +26,10 @@ import type {ReportListItemType, TransactionListItemType} from './SelectionList/ import TableListItemSkeleton from './Skeletons/TableListItemSkeleton'; type SearchProps = { - query: string; + query: SearchQuery; policyIDs?: string; + sortBy?: SearchColumnType; + sortOrder?: SortOrder; }; function isReportListItemType(item: TransactionListItemType | ReportListItemType): item is ReportListItemType { @@ -28,27 +37,36 @@ function isReportListItemType(item: TransactionListItemType | ReportListItemType return reportListItem.transactions !== undefined; } -function Search({query, policyIDs}: SearchProps) { +function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); + const navigation = useNavigation>(); + const lastSearchResultsRef = useRef>(); - const hash = SearchUtils.getQueryHash(query, policyIDs); - const [searchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); + const hash = SearchUtils.getQueryHash(query, policyIDs, sortBy, sortOrder); + const [currentSearchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); + + // save last non-empty search results to avoid ugly flash of loading screen when hash changes and onyx returns empty data + if (currentSearchResults?.data && currentSearchResults !== lastSearchResultsRef.current) { + lastSearchResultsRef.current = currentSearchResults; + } + + const searchResults = currentSearchResults?.data ? currentSearchResults : lastSearchResultsRef.current; useEffect(() => { if (isOffline) { return; } - SearchActions.search(hash, query, 0, policyIDs); + SearchActions.search({hash, query, policyIDs, offset: 0, sortBy, sortOrder}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hash, isOffline]); - const isLoadingInitialItems = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || searchResults?.data === undefined; - const isLoadingMoreItems = !isLoadingInitialItems && searchResults?.search?.isLoading; - const shouldShowEmptyState = !isLoadingInitialItems && isEmptyObject(searchResults?.data); + const isLoadingItems = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || searchResults?.data === undefined; + const isLoadingMoreItems = !isLoadingItems && searchResults?.search?.isLoading; + const shouldShowEmptyState = !isLoadingItems && isEmptyObject(searchResults?.data); - if (isLoadingInitialItems) { + if (isLoadingItems) { return ; } @@ -65,11 +83,11 @@ function Search({query, policyIDs}: SearchProps) { }; const fetchMoreResults = () => { - if (!searchResults?.search?.hasMoreResults || isLoadingInitialItems || isLoadingMoreItems) { + if (!searchResults?.search?.hasMoreResults || isLoadingItems || isLoadingMoreItems) { return; } const currentOffset = searchResults?.search?.offset ?? 0; - SearchActions.search(hash, query, currentOffset + CONST.SEARCH_RESULTS_PAGE_SIZE); + SearchActions.search({hash, query, offset: currentOffset + CONST.SEARCH_RESULTS_PAGE_SIZE, sortBy, sortOrder}); }; const type = SearchUtils.getSearchType(searchResults?.search); @@ -83,9 +101,25 @@ function Search({query, policyIDs}: SearchProps) { const data = SearchUtils.getSections(searchResults?.data ?? {}, type); + const onSortPress = (column: SearchColumnType, order: SortOrder) => { + navigation.setParams({ + sortBy: column, + sortOrder: order, + }); + }; + + const sortedData = SearchUtils.getSortedSections(type, data, sortBy, sortOrder); + return ( - customListHeader={} + customListHeader={ + + } // 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. @@ -98,7 +132,7 @@ function Search({query, policyIDs}: SearchProps) { windowSize={111} updateCellsBatchingPeriod={200} ListItem={ListItem} - sections={[{data, isDisabled: false}]} + sections={[{data: sortedData, isDisabled: false}]} onSelectRow={(item) => { const reportID = isReportListItemType(item) ? item.reportID : item.transactionThreadReportID; diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx index 9b2188c659e5..cea9c174dda5 100644 --- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx +++ b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx @@ -13,11 +13,13 @@ import UserInfoCell from './UserInfoCell'; type ExpenseItemHeaderNarrowProps = { participantFrom: SearchAccountDetails; participantTo: SearchAccountDetails; + participantFromDisplayName: string; + participantToDisplayName: string; buttonText: string; onButtonPress: () => void; }; -function ExpenseItemHeaderNarrow({participantFrom, participantTo, buttonText, onButtonPress}: ExpenseItemHeaderNarrowProps) { +function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, participantTo, participantToDisplayName, buttonText, onButtonPress}: ExpenseItemHeaderNarrowProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -26,7 +28,10 @@ function ExpenseItemHeaderNarrow({participantFrom, participantTo, buttonText, on - + - + diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index ecab5a3053df..c1a38cf6e7cd 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -74,6 +74,11 @@ function ReportListItem({ const participantFrom = reportItem.transactions[0].from; const participantTo = reportItem.transactions[0].to; + // These values should come as part of the item via SearchUtils.getSections() but ReportListItem is not yet 100% handled + // This will be simplified in future once sorting of ReportListItem is done + const participantFromDisplayName = participantFrom?.name ?? participantFrom?.displayName ?? participantFrom?.login ?? ''; + const participantToDisplayName = participantTo?.name ?? participantTo?.displayName ?? participantTo?.login ?? ''; + if (reportItem.transactions.length === 1) { const transactionItem = reportItem.transactions[0]; @@ -118,7 +123,9 @@ function ReportListItem({ {!isLargeScreenWidth && ( diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index 71912c2d3c7a..cb1ef3fdc6e1 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -1,25 +1,11 @@ import React, {memo} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import Avatar from '@components/Avatar'; import Button from '@components/Button'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import ReceiptImage from '@components/ReceiptImage'; -import type { - ActionCellProps, - CellProps, - CurrencyCellProps, - DateCellProps, - MerchantCellProps, - ReceiptCellProps, - TransactionCellProps, - TransactionListItemType, - TypeCellProps, - UserCellProps, -} from '@components/SelectionList/types'; -import Text from '@components/Text'; +import type {TransactionListItemType} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -31,10 +17,31 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {Transaction} from '@src/types/onyx'; import type {SearchTransactionType} from '@src/types/onyx/SearchResults'; import ExpenseItemHeaderNarrow from './ExpenseItemHeaderNarrow'; import TextWithIconCell from './TextWithIconCell'; +import UserInfoCell from './UserInfoCell'; + +type CellProps = { + // eslint-disable-next-line react/no-unused-prop-types + showTooltip: boolean; + // eslint-disable-next-line react/no-unused-prop-types + keyForList: string; + // eslint-disable-next-line react/no-unused-prop-types + isLargeScreenWidth: boolean; +}; + +type TransactionCellProps = { + transactionItem: TransactionListItemType; +} & CellProps; + +type ReceiptCellProps = { + isHovered?: boolean; +} & TransactionCellProps; + +type ActionCellProps = { + onButtonPress: () => void; +} & CellProps; type TransactionListItemRowProps = { item: TransactionListItemType; @@ -89,71 +96,49 @@ const ReceiptCell = memo(({transactionItem, isHovered = false}: ReceiptCellProps ); }, areReceiptPropsEqual); -const DateCell = memo(({showTooltip, date, isLargeScreenWidth}: DateCellProps) => { +const DateCell = memo(({transactionItem, showTooltip, isLargeScreenWidth}: TransactionCellProps) => { const styles = useThemeStyles(); + const date = TransactionUtils.getCreated(transactionItem, CONST.DATE.MONTH_DAY_ABBR_FORMAT); return ( ); }, arePropsEqual); -const MerchantCell = memo(({showTooltip, transactionItem, merchant, description}: MerchantCellProps) => { +const MerchantCell = memo(({transactionItem, showTooltip}: TransactionCellProps) => { const styles = useThemeStyles(); + const description = TransactionUtils.getDescription(transactionItem); + return ( ); }, arePropsEqual); -const UserCell = memo(({participant}: UserCellProps) => { - const styles = useThemeStyles(); - - const displayName = participant?.name ?? participant?.displayName ?? participant?.login; - const avatarURL = participant?.avatarURL ?? participant?.avatar; - const isWorkspace = participant?.avatarURL !== undefined; - const iconType = isWorkspace ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR; - - return ( - - - - {displayName} - - - ); -}, arePropsEqual); - -const TotalCell = memo(({showTooltip, amount, currency, isLargeScreenWidth}: CurrencyCellProps) => { +const TotalCell = memo(({showTooltip, isLargeScreenWidth, transactionItem}: TransactionCellProps) => { const styles = useThemeStyles(); + const currency = TransactionUtils.getCurrency(transactionItem); return ( ); }, arePropsEqual); -const TypeCell = memo(({typeIcon, isLargeScreenWidth}: TypeCellProps) => { +const TypeCell = memo(({transactionItem, isLargeScreenWidth}: TransactionCellProps) => { const theme = useTheme(); + const typeIcon = getTypeIcon(transactionItem.type); + return ( { ); }, arePropsEqual); -const ActionCell = memo(({item, onSelectRow}: ActionCellProps) => { +const ActionCell = memo(({onButtonPress}: ActionCellProps) => { const {translate} = useLocalize(); const styles = useThemeStyles(); return (