diff --git a/assets/images/filters.svg b/assets/images/filters.svg new file mode 100644 index 000000000000..ed0f1169d168 --- /dev/null +++ b/assets/images/filters.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__filters.svg b/assets/images/simple-illustrations/simple-illustration__filters.svg new file mode 100644 index 000000000000..c1d574f154f4 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__filters.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 0b9306d1c977..3aacf2ae88c8 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -82,6 +82,7 @@ import ExpensifyLogoNew from '@assets/images/expensify-logo-new.svg'; import ExpensifyWordmark from '@assets/images/expensify-wordmark.svg'; import EyeDisabled from '@assets/images/eye-disabled.svg'; import Eye from '@assets/images/eye.svg'; +import Filters from '@assets/images/filters.svg'; import Flag from '@assets/images/flag.svg'; import FlagLevelOne from '@assets/images/flag_level_01.svg'; import FlagLevelTwo from '@assets/images/flag_level_02.svg'; @@ -373,5 +374,6 @@ export { CheckCircle, CheckmarkCircle, NetSuiteSquare, + Filters, CalendarSolid, }; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index ccbf4b3a5da9..1ef0ca14bf51 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -56,6 +56,7 @@ import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustrat import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; import EmptyState from '@assets/images/simple-illustrations/simple-illustration__empty-state.svg'; +import Filters from '@assets/images/simple-illustrations/simple-illustration__filters.svg'; import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg'; import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg'; @@ -210,4 +211,5 @@ export { FolderWithPapers, VirtualCard, Tire, + Filters, }; diff --git a/src/components/Search/SearchListWithHeader.tsx b/src/components/Search/SearchListWithHeader.tsx index abb1bc45aebe..aafa8141e99e 100644 --- a/src/components/Search/SearchListWithHeader.tsx +++ b/src/components/Search/SearchListWithHeader.tsx @@ -11,13 +11,14 @@ import * as SearchUtils from '@libs/SearchUtils'; import CONST from '@src/CONST'; import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults'; import SearchPageHeader from './SearchPageHeader'; -import type {SearchStatus, SelectedTransactionInfo, SelectedTransactions} from './types'; +import type {SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions} from './types'; type SearchListWithHeaderProps = Omit, 'onSelectAll' | 'onCheckboxPress' | 'sections'> & { - status: SearchStatus; + queryJSON: SearchQueryJSON; hash: number; data: TransactionListItemType[] | ReportListItemType[]; searchType: SearchDataTypes; + isCustomQuery: boolean; }; function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] { @@ -38,7 +39,7 @@ function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListIt }; } -function SearchListWithHeader({ListItem, onSelectRow, status, hash, data, searchType, ...props}: SearchListWithHeaderProps, ref: ForwardedRef) { +function SearchListWithHeader({ListItem, onSelectRow, queryJSON, hash, data, searchType, isCustomQuery, ...props}: SearchListWithHeaderProps, ref: ForwardedRef) { const {isSmallScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); const [selectedTransactions, setSelectedTransactions] = useState({}); @@ -148,10 +149,11 @@ function SearchListWithHeader({ListItem, onSelectRow, status, hash, data, search setOfflineModalVisible(true)} setDownloadErrorModalOpen={() => setDownloadErrorModalVisible(true)} /> diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 0dd83853456d..97ab5383ff9c 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -1,10 +1,15 @@ import React, {useMemo} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; +import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Header from '@components/Header'; +import type HeaderWithBackButtonProps from '@components/HeaderWithBackButton/types'; +import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; +import Text from '@components/Text'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -14,32 +19,97 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import * as SearchActions from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import * as SearchUtils from '@libs/SearchUtils'; import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {SearchReport} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; import {useSearchContext} from './SearchContext'; -import type {SearchStatus, SelectedTransactions} from './types'; +import type {SearchQueryJSON, SearchStatus, SelectedTransactions} from './types'; + +type HeaderWrapperProps = Pick & { + subtitleStyles?: StyleProp; +}; + +function HeaderWrapper({icon, title, subtitle, children, subtitleStyles = {}}: HeaderWrapperProps) { + const styles = useThemeStyles(); + + // If the icon is present, the header bar should be taller and use different font. + const isCentralPaneSettings = !!icon; + + const middleContent = useMemo(() => { + return ( +
+ {title} + + } + subtitle={ + + {subtitle} + + } + /> + ); + }, [styles.mutedTextLabel, styles.pre, styles.textLarge, subtitle, subtitleStyles, title]); + + return ( + + + {icon && ( + + )} + + {middleContent} + {children} + + + ); +} type SearchPageHeaderProps = { - status: SearchStatus; + queryJSON: SearchQueryJSON; selectedTransactions?: SelectedTransactions; selectedReports?: Array; clearSelectedItems?: () => void; hash: number; onSelectDeleteOption?: (itemsToDelete: string[]) => void; + isCustomQuery: boolean; setOfflineModalOpen?: () => void; setDownloadErrorModalOpen?: () => void; }; type SearchHeaderOptionValue = DeepValueOf | undefined; +const headerContent: {[key in SearchStatus]: {icon: IconAsset; titleTx: TranslationPaths}} = { + all: {icon: Illustrations.MoneyReceipts, titleTx: 'common.expenses'}, + shared: {icon: Illustrations.SendMoney, titleTx: 'common.shared'}, + drafts: {icon: Illustrations.Pencil, titleTx: 'common.drafts'}, + finished: {icon: Illustrations.CheckmarkCircle, titleTx: 'common.finished'}, +}; + function SearchPageHeader({ - status, + queryJSON, selectedTransactions = {}, hash, clearSelectedItems, @@ -47,6 +117,7 @@ function SearchPageHeader({ setOfflineModalOpen, setDownloadErrorModalOpen, selectedReports, + isCustomQuery, }: SearchPageHeaderProps) { const {translate} = useLocalize(); const theme = useTheme(); @@ -55,17 +126,16 @@ function SearchPageHeader({ const {activeWorkspaceID} = useActiveWorkspace(); const {isSmallScreenWidth} = useResponsiveLayout(); const {setSelectedTransactionIDs} = useSearchContext(); + const {status} = queryJSON; + const headerSubtitle = isCustomQuery ? SearchUtils.getSearchHeaderTitle(queryJSON) : translate(headerContent[status]?.titleTx); + const headerTitle = isCustomQuery ? translate('search.filtersHeader') : ''; + const headerIcon = isCustomQuery ? Illustrations.Filters : headerContent[status]?.icon; const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); - const headerContent: {[key in SearchStatus]: {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 selectedTransactionsKeys = Object.keys(selectedTransactions ?? []); + const subtitleStyles = isCustomQuery ? {} : styles.textHeadlineH2; + const headerButtonsOptions = useMemo(() => { if (selectedTransactionsKeys.length === 0) { return []; @@ -210,10 +280,11 @@ function SearchPageHeader({ } return ( - {headerButtonsOptions.length > 0 && ( )} - + ); } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index de08af11cefc..28fe2ebef7c6 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -32,6 +32,7 @@ import type {SearchColumnType, SearchQueryJSON, SearchStatus, SortOrder} from '. type SearchProps = { queryJSON: SearchQueryJSON; policyIDs?: string; + isCustomQuery: boolean; }; const sortableSearchTabs: SearchStatus[] = [CONST.SEARCH.STATUS.ALL]; @@ -40,7 +41,7 @@ const reportItemTransactionHeight = 52; const listItemPadding = 12; // this is equivalent to 'mb3' on every transaction/report list item const searchHeaderHeight = 54; -function Search({queryJSON, policyIDs}: SearchProps) { +function Search({queryJSON, policyIDs, isCustomQuery}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); const {isLargeScreenWidth, isSmallScreenWidth} = useWindowDimensions(); @@ -109,7 +110,8 @@ function Search({queryJSON, policyIDs}: SearchProps) { return ( <> @@ -123,7 +125,8 @@ function Search({queryJSON, policyIDs}: SearchProps) { return ( <> @@ -182,10 +185,11 @@ function Search({queryJSON, policyIDs}: SearchProps) { return ( > { if (screenName === SCREENS.SEARCH.CENTRAL_PANE) { // Generate default query string with buildSearchQueryString without argument. - return {q: buildSearchQueryString()}; + return {q: buildSearchQueryString(), isCustomQuery: false}; } if (screenName === SCREENS.REPORT) { diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index d0aeb644f27d..f2d1bfb36d7e 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -55,7 +55,12 @@ const config: LinkingOptions['config'] = { exact: true, }, [SCREENS.SETTINGS.WORKSPACES]: ROUTES.SETTINGS_WORKSPACES, - [SCREENS.SEARCH.CENTRAL_PANE]: ROUTES.SEARCH_CENTRAL_PANE.route, + [SCREENS.SEARCH.CENTRAL_PANE]: { + path: ROUTES.SEARCH_CENTRAL_PANE.route, + parse: { + isCustomQuery: (isCustomQuery) => isCustomQuery.toLowerCase() === 'true', + }, + }, [SCREENS.SETTINGS.SAVE_THE_WORLD]: ROUTES.SETTINGS_SAVE_THE_WORLD, [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: ROUTES.SETTINGS_SUBSCRIPTION, diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 4c3229760d71..367f414eb53d 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -8,8 +8,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import INPUT_IDS from '@src/types/form/SearchAdvancedFiltersForm'; import type * as OnyxTypes from '@src/types/onyx'; -import type {SearchAccountDetails, SearchDataTypes, SearchPersonalDetails, SearchTransaction, SearchTypeToItemMap, SectionsType} from '@src/types/onyx/SearchResults'; import type SearchResults from '@src/types/onyx/SearchResults'; +import type {SearchAccountDetails, SearchDataTypes, SearchPersonalDetails, SearchTransaction, SearchTypeToItemMap, SectionsType} from '@src/types/onyx/SearchResults'; import DateUtils from './DateUtils'; import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute'; import navigationRef from './Navigation/navigationRef'; @@ -33,6 +33,18 @@ const columnNamesToSortingProperty = { [CONST.SEARCH.TABLE_COLUMNS.RECEIPT]: null, }; +// This map contains signs with spaces that match each operator +const operatorToSignMap = { + [CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]: ':' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN]: '<' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO]: '<=' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN]: '>' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO]: '>=' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO]: '!=' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.AND]: ',' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.OR]: ' ' as const, +}; + /** * @private */ @@ -429,11 +441,11 @@ function getFilters(query: SearchQueryString, fields: Array { + // If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value + if ((queryFilter.operator === 'eq' && queryFilters[index - 1]?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters[index - 1]?.operator === 'neq')) { + filterValueString += `,${queryFilter.value}`; + } else { + filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${queryFilter.value}`; + } + }); + + return filterValueString; +} + +function getSearchHeaderTitle(queryJSON: SearchQueryJSON) { + const {inputQuery, type, status} = queryJSON; + const filters = getFilters(inputQuery, Object.values(CONST.SEARCH.SYNTAX_FILTER_KEYS)) ?? {}; + + let title = `type:${type} status:${status}`; + + Object.keys(filters).forEach((key) => { + const queryFilter = filters[key as ValueOf] as QueryFilter[]; + title += buildFilterValueString(key, queryFilter); + }); + + return title; +} + export { + buildQueryStringFromFilters, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchParams, + getFilters, getListItem, getQueryHash, + getSearchHeaderTitle, + getSearchType, getSections, - getSortedSections, getShouldShowMerchant, - getSearchType, - shouldShowYear, + getSortedSections, isReportListItemType, - isTransactionListItemType, isSearchResultsEmpty, - getFilters, + isTransactionListItemType, normalizeQuery, - buildQueryStringFromFilters, + shouldShowYear, }; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index c95290dbc006..e2495c02d44f 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -17,10 +17,9 @@ type SearchPageProps = StackScreenProps buildSearchQueryJSON(route.params.q, policyIDs), [route.params.q, policyIDs]); + const queryJSON = useMemo(() => buildSearchQueryJSON(q, policyIDs), [q, policyIDs]); const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: CONST.SEARCH.TAB.EXPENSE.ALL})); @@ -44,6 +43,7 @@ function SearchPage({route}: SearchPageProps) { > {queryJSON && ( diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx index 4f3138aef110..c8254af295d7 100644 --- a/src/pages/Search/SearchPageBottomTab.tsx +++ b/src/pages/Search/SearchPageBottomTab.tsx @@ -26,7 +26,7 @@ function SearchPageBottomTab() { const styles = useThemeStyles(); const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); - const {queryJSON, policyIDs} = useMemo(() => { + const {queryJSON, policyIDs, isCustomQuery} = useMemo(() => { if (!activeCentralPaneRoute || activeCentralPaneRoute.name !== SCREENS.SEARCH.CENTRAL_PANE) { return {queryJSON: undefined, policyIDs: undefined}; } @@ -37,6 +37,7 @@ function SearchPageBottomTab() { return { queryJSON: buildSearchQueryJSON(searchParams.q, searchParams.policyIDs), policyIDs: searchParams.policyIDs, + isCustomQuery: searchParams.isCustomQuery, }; }, [activeCentralPaneRoute]); @@ -60,7 +61,10 @@ function SearchPageBottomTab() { breadcrumbLabel={translate('common.search')} shouldDisplaySearch={false} /> - + ) : ( )} diff --git a/src/pages/Search/SearchStatusMenu.tsx b/src/pages/Search/SearchStatusMenu.tsx index e1470bdc4e75..9b8e890bbefd 100644 --- a/src/pages/Search/SearchStatusMenu.tsx +++ b/src/pages/Search/SearchStatusMenu.tsx @@ -1,13 +1,14 @@ import React from 'react'; import {View} from 'react-native'; import MenuItem from '@components/MenuItem'; -import type {SearchStatus} from '@components/Search/types'; +import type {SearchQueryJSON, SearchStatus} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import {normalizeQuery} from '@libs/SearchUtils'; +import * as SearchUtils from '@libs/SearchUtils'; import variables from '@styles/variables'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; @@ -17,17 +18,19 @@ import type IconAsset from '@src/types/utils/IconAsset'; import SearchStatusMenuNarrow from './SearchStatusMenuNarrow'; type SearchStatusMenuProps = { - status: SearchStatus; + queryJSON: SearchQueryJSON; + isCustomQuery: boolean; }; type SearchStatusMenuItem = { title: string; status: SearchStatus; icon: IconAsset; - route: Route; + route?: Route; }; -function SearchStatusMenu({status}: SearchStatusMenuProps) { +function SearchStatusMenu({queryJSON, isCustomQuery}: SearchStatusMenuProps) { + const {status} = queryJSON; const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {singleExecution} = useSingleExecution(); @@ -62,10 +65,13 @@ function SearchStatusMenu({status}: SearchStatusMenuProps) { const activeItemIndex = statusMenuItems.findIndex((item) => item.status === status); if (shouldUseNarrowLayout) { + const title = isCustomQuery ? SearchUtils.getSearchHeaderTitle(queryJSON) : undefined; + return ( ); } diff --git a/src/pages/Search/SearchStatusMenuNarrow.tsx b/src/pages/Search/SearchStatusMenuNarrow.tsx index e80db44ce3dc..1d10dc1dd431 100644 --- a/src/pages/Search/SearchStatusMenuNarrow.tsx +++ b/src/pages/Search/SearchStatusMenuNarrow.tsx @@ -1,4 +1,4 @@ -import React, {useRef, useState} from 'react'; +import React, {useMemo, useRef, useState} from 'react'; import {Animated, View} from 'react-native'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; @@ -15,9 +15,10 @@ import type {SearchStatusMenuItem} from './SearchStatusMenu'; type SearchStatusMenuNarrowProps = { statusMenuItems: SearchStatusMenuItem[]; activeItemIndex: number; + title?: string; }; -function SearchStatusMenuNarrow({statusMenuItems, activeItemIndex}: SearchStatusMenuNarrowProps) { +function SearchStatusMenuNarrow({statusMenuItems, activeItemIndex, title}: SearchStatusMenuNarrowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {singleExecution} = useSingleExecution(); @@ -29,34 +30,61 @@ function SearchStatusMenuNarrow({statusMenuItems, activeItemIndex}: SearchStatus const openMenu = () => setIsPopoverVisible(true); const closeMenu = () => setIsPopoverVisible(false); - const popoverMenuItems = statusMenuItems.map((item, index) => ({ - text: item.title, - onSelected: singleExecution(() => Navigation.navigate(item.route)), - icon: item.icon, - iconFill: index === activeItemIndex ? theme.iconSuccessFill : theme.icon, - iconRight: Expensicons.Checkmark, - shouldShowRightIcon: index === activeItemIndex, - success: index === activeItemIndex, - containerStyle: index === activeItemIndex ? [{backgroundColor: theme.border}] : undefined, - })); + const popoverMenuItems = statusMenuItems.map((item, index) => { + const isSelected = title ? false : index === activeItemIndex; + + return { + text: item.title, + onSelected: singleExecution(() => Navigation.navigate(item.route)), + icon: item.icon, + iconFill: isSelected ? theme.iconSuccessFill : theme.icon, + iconRight: Expensicons.Checkmark, + shouldShowRightIcon: isSelected, + success: isSelected, + containerStyle: isSelected ? [{backgroundColor: theme.border}] : undefined, + }; + }); + + if (title) { + popoverMenuItems.push({ + text: title, + onSelected: closeMenu, + icon: Expensicons.Filters, + iconFill: theme.iconSuccessFill, + success: true, + containerStyle: [{backgroundColor: theme.border}], + iconRight: Expensicons.Checkmark, + shouldShowRightIcon: false, + }); + } + + const menuIcon = useMemo(() => (title ? Expensicons.Filters : popoverMenuItems[activeItemIndex]?.icon ?? Expensicons.Receipt), [activeItemIndex, popoverMenuItems, title]); + const menuTitle = useMemo(() => title ?? popoverMenuItems[activeItemIndex]?.text, [activeItemIndex, popoverMenuItems, title]); + const titleViewStyles = title ? {...styles.flex1, ...styles.justifyContentCenter} : {}; return ( - + {({hovered}) => ( - + - {popoverMenuItems[activeItemIndex]?.text} + + {menuTitle} +