diff --git a/src/CONST.ts b/src/CONST.ts index d929a01e030a..f591373a45c2 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5251,6 +5251,9 @@ const CONST = { DRAFTS: 'drafts', FINISHED: 'finished', }, + TYPE: { + EXPENSE: 'expense', + }, TAB: { EXPENSE: { ALL: 'type:expense status:all', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index af29a7fdbbb4..2dfbef1cc718 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -47,6 +47,8 @@ const ROUTES = { SEARCH_ADVANCED_FILTERS_TYPE: 'search/filters/type', + SEARCH_ADVANCED_FILTERS_STATUS: 'search/filters/status', + SEARCH_REPORT: { route: 'search/view/:reportID', getRoute: (reportID: string) => `search/view/${reportID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 74d4a628e696..32825418d7fa 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -33,6 +33,7 @@ const SCREENS = { ADVANCED_FILTERS_RHP: 'Search_Advanced_Filters_RHP', ADVANCED_FILTERS_DATE_RHP: 'Search_Advanced_Filters_Date_RHP', ADVANCED_FILTERS_TYPE_RHP: 'Search_Advanced_Filters_Type_RHP', + ADVANCED_FILTERS_STATUS_RHP: 'Search_Advanced_Filters_Status_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', BOTTOM_TAB: 'Search_Bottom_Tab', }, diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b8d7c02fc7d8..3f2ed651eadd 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -41,6 +41,7 @@ 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({queryJSON, policyIDs, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index cf8a2eb04e14..360a8456bda7 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -44,10 +44,10 @@ type QueryFilter = { value: string | number; }; -type AllFieldKeys = ValueOf | ValueOf; +type AdvancedFiltersKeys = ValueOf | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS; type QueryFilters = { - [K in AllFieldKeys]: QueryFilter | QueryFilter[]; + [K in AdvancedFiltersKeys]?: QueryFilter | QueryFilter[]; }; type SearchQueryString = string; @@ -61,7 +61,7 @@ type SearchQueryAST = { }; type SearchQueryJSON = { - input: string; + inputQuery: SearchQueryString; hash: number; } & SearchQueryAST; @@ -78,5 +78,5 @@ export type { ASTNode, QueryFilter, QueryFilters, - AllFieldKeys, + AdvancedFiltersKeys, }; diff --git a/src/languages/en.ts b/src/languages/en.ts index cb5e0b76edef..5fed12a55f9b 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3570,6 +3570,7 @@ export default { search: { selectMultiple: 'Select multiple', resultsAreLimited: 'Search results are limited.', + viewResults: 'View results', searchResults: { emptyResults: { title: 'Nothing to show', @@ -3587,9 +3588,10 @@ export default { filtersHeader: 'Filters', filters: { date: { - before: 'Before', - after: 'After', + before: (date?: string) => `Before ${date ?? ''}`, + after: (date?: string) => `After ${date ?? ''}`, }, + status: 'Status', }, }, genericErrorPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 6f4ba907fc9c..8631687a469b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3627,6 +3627,7 @@ export default { search: { selectMultiple: 'Seleccionar varios', resultsAreLimited: 'Los resultados de búsqueda están limitados.', + viewResults: 'Ver resultados', searchResults: { emptyResults: { title: 'No hay nada que ver aquí', @@ -3644,9 +3645,10 @@ export default { filtersHeader: 'Filtros', filters: { date: { - before: 'Antes de', - after: 'Después de', + before: (date?: string) => `Antes de ${date ?? ''}`, + after: (date?: string) => `Después de ${date ?? ''}`, }, + status: 'Estado', }, }, genericErrorPage: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 1b0daf941e70..f3fc8accb83f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -513,6 +513,7 @@ const SearchAdvancedFiltersModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchAdvancedFiltersPage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_DATE_RHP]: () => require('../../../../pages/Search/SearchFiltersDatePage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_TYPE_RHP]: () => require('../../../../pages/Search/SearchFiltersTypePage').default, + [SCREENS.SEARCH.ADVANCED_FILTERS_STATUS_RHP]: () => require('../../../../pages/Search/SearchFiltersStatusPage').default, }); const RestrictedActionModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 586b4e9d4506..e4c35643f950 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1009,6 +1009,7 @@ const config: LinkingOptions['config'] = { [SCREENS.SEARCH.ADVANCED_FILTERS_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS, [SCREENS.SEARCH.ADVANCED_FILTERS_DATE_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_DATE, [SCREENS.SEARCH.ADVANCED_FILTERS_TYPE_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_TYPE, + [SCREENS.SEARCH.ADVANCED_FILTERS_STATUS_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_STATUS, }, }, [SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: { diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 18888903053e..4c3229760d71 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -1,10 +1,12 @@ import type {ValueOf} from 'type-fest'; -import type {AllFieldKeys, ASTNode, QueryFilter, QueryFilters, SearchColumnType, SearchQueryJSON, SearchQueryString, SortOrder} from '@components/Search/types'; +import type {AdvancedFiltersKeys, ASTNode, QueryFilter, QueryFilters, SearchColumnType, SearchQueryJSON, SearchQueryString, SortOrder} from '@components/Search/types'; import ReportListItem from '@components/SelectionList/Search/ReportListItem'; import TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; import type {ListItem, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import CONST from '@src/CONST'; 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'; @@ -316,7 +318,7 @@ function buildSearchQueryJSON(query: SearchQueryString, policyID?: string) { try { // Add the full input and hash to the results const result = searchParser.parse(query) as SearchQueryJSON; - result.input = query; + result.inputQuery = query; // Temporary solution until we move policyID filter into the AST - then remove this line and keep only query const policyIDPart = policyID ?? ''; @@ -351,7 +353,54 @@ function normalizeQuery(query: string) { return buildSearchQueryString(normalizedQueryJSON); } -function getFilters(query: SearchQueryString, fields: Array>) { +/** + * @private + * returns Date filter query string part, which needs special logic + */ +function buildDateFilterQuery(filterValues: Partial) { + const dateBefore = filterValues[INPUT_IDS.DATE_BEFORE]; + const dateAfter = filterValues[INPUT_IDS.DATE_AFTER]; + + let dateFilter = ''; + if (dateBefore) { + dateFilter += `${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}<${dateBefore}`; + } + if (dateBefore && dateAfter) { + dateFilter += ' '; + } + if (dateAfter) { + dateFilter += `${CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE}>${dateAfter}`; + } + + return dateFilter; +} + +/** + * Given object with chosen search filters builds correct query string from them + */ +function buildQueryStringFromFilters(filterValues: Partial) { + // TODO add handling of multiple values picked + const filtersString = Object.entries(filterValues) + .map(([filterKey, filterValue]) => { + if (filterKey === INPUT_IDS.TYPE && filterValue) { + return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${filterValue as string}`; + } + + if (filterKey === INPUT_IDS.STATUS && filterValue) { + return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${filterValue as string}`; + } + + return undefined; + }) + .filter(Boolean) + .join(' '); + + const dateFilter = buildDateFilterQuery(filterValues); + + return dateFilter ? `${filtersString} ${dateFilter}` : filtersString; +} + +function getFilters(query: SearchQueryString, fields: Array>) { let queryAST; try { @@ -427,4 +476,5 @@ export { isSearchResultsEmpty, getFilters, normalizeQuery, + buildQueryStringFromFilters, }; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 5aff8682abb9..4b782e8b103c 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -129,10 +129,26 @@ function exportSearchItemsToCSV({query, reportIDList, transactionIDList, policyI } /** - * Updates the form values for the advanced search form. + * Updates the form values for the advanced filters search form. */ -function updateAdvancedFilters(values: FormOnyxValues) { +function updateAdvancedFilters(values: Partial>) { Onyx.merge(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, values); } -export {search, createTransactionThread, deleteMoneyRequestOnSearch, holdMoneyRequestOnSearch, unholdMoneyRequestOnSearch, exportSearchItemsToCSV, updateAdvancedFilters}; +/** + * Clears all values for the advanced filters search form. + */ +function clearAdvancedFilters() { + Onyx.set(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, null); +} + +export { + search, + createTransactionThread, + deleteMoneyRequestOnSearch, + holdMoneyRequestOnSearch, + unholdMoneyRequestOnSearch, + exportSearchItemsToCSV, + updateAdvancedFilters, + clearAdvancedFilters, +}; diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 171e2c45dbd7..eff58f140aa1 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -1,54 +1,111 @@ +import {Str} from 'expensify-common'; import React, {useMemo} from 'react'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import type {AdvancedFiltersKeys} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; +import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import Navigation from '@libs/Navigation/Navigation'; +import * as SearchUtils from '@libs/SearchUtils'; +import * as SearchActions from '@userActions/Search'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; -function getFilterDisplayTitle(filters: Record, fieldName: string) { - // This is temporary because the full parsing of search query is not yet done - // TODO once we have values from query, this value should be `filters[fieldName].value` - return fieldName; +function getFilterDisplayTitle(filters: Partial, fieldName: AdvancedFiltersKeys, translate: LocaleContextProps['translate']) { + if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) { + // the value of date filter is a combination of dateBefore + dateAfter values + const {dateAfter, dateBefore} = filters; + let dateValue = ''; + if (dateBefore) { + dateValue = translate('search.filters.date.before', dateBefore); + } + if (dateBefore && dateAfter) { + dateValue += ', '; + } + if (dateAfter) { + dateValue += translate('search.filters.date.after', dateAfter); + } + + return dateValue; + } + + // Todo Once all Advanced filters are implemented this line can be cleaned up. See: https://github.com/Expensify/App/issues/45026 + // @ts-expect-error this property access is temporarily an error, because not every SYNTAX_FILTER_KEYS is handled by form. + // When all filters are updated here: src/types/form/SearchAdvancedFiltersForm.ts this line comment + type cast can be removed. + const filterValue = filters[fieldName] as string; + return filterValue ? Str.recapitalize(filterValue) : undefined; } function AdvancedSearchFilters() { const {translate} = useLocalize(); + const styles = useThemeStyles(); const {singleExecution} = useSingleExecution(); const waitForNavigate = useWaitForNavigation(); + const [searchAdvancedFilters = {}] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const advancedFilters = useMemo( () => [ { - title: getFilterDisplayTitle({}, 'title'), + title: getFilterDisplayTitle(searchAdvancedFilters, CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, translate), description: 'common.type' as const, route: ROUTES.SEARCH_ADVANCED_FILTERS_TYPE, }, { - title: getFilterDisplayTitle({}, 'date'), + title: getFilterDisplayTitle(searchAdvancedFilters, CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, translate), + description: 'search.filters.status' as const, + route: ROUTES.SEARCH_ADVANCED_FILTERS_STATUS, + }, + { + title: getFilterDisplayTitle(searchAdvancedFilters, CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, translate), description: 'common.date' as const, route: ROUTES.SEARCH_ADVANCED_FILTERS_DATE, }, ], - [], + [searchAdvancedFilters, translate], ); + const onFormSubmit = () => { + const query = SearchUtils.buildQueryStringFromFilters(searchAdvancedFilters); + SearchActions.clearAdvancedFilters(); + Navigation.navigate( + ROUTES.SEARCH_CENTRAL_PANE.getRoute({ + query, + isCustomQuery: true, + }), + ); + }; + return ( - - {advancedFilters.map((item) => { - const onPress = singleExecution(waitForNavigate(() => Navigation.navigate(item.route))); - - return ( - - ); - })} + + + {advancedFilters.map((item) => { + const onPress = singleExecution(waitForNavigate(() => Navigation.navigate(item.route))); + + return ( + + ); + })} + + ); } diff --git a/src/pages/Search/SearchFiltersStatusPage.tsx b/src/pages/Search/SearchFiltersStatusPage.tsx new file mode 100644 index 000000000000..55274b770adc --- /dev/null +++ b/src/pages/Search/SearchFiltersStatusPage.tsx @@ -0,0 +1,89 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@navigation/Navigation'; +import * as SearchActions from '@userActions/Search'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +function SearchFiltersStatusPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + + const activeItem = searchAdvancedFiltersForm?.status; + + const filterStatusItems = useMemo( + () => [ + { + text: translate('common.all'), + value: CONST.SEARCH.STATUS.ALL, + keyForList: CONST.SEARCH.STATUS.ALL, + isSelected: activeItem === CONST.SEARCH.STATUS.ALL, + }, + { + text: translate('common.shared'), + value: CONST.SEARCH.STATUS.SHARED, + keyForList: CONST.SEARCH.STATUS.SHARED, + isSelected: activeItem === CONST.SEARCH.STATUS.SHARED, + }, + { + text: translate('common.drafts'), + value: CONST.SEARCH.STATUS.DRAFTS, + keyForList: CONST.SEARCH.STATUS.DRAFTS, + isSelected: activeItem === CONST.SEARCH.STATUS.DRAFTS, + }, + { + text: translate('common.finished'), + value: CONST.SEARCH.STATUS.FINISHED, + keyForList: CONST.SEARCH.STATUS.FINISHED, + isSelected: activeItem === CONST.SEARCH.STATUS.FINISHED, + }, + ], + [translate, activeItem], + ); + + const updateStatus = (values: Partial>) => { + SearchActions.updateAdvancedFilters(values); + Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); + }; + + return ( + + + + + { + updateStatus({ + status: item.value, + }); + }} + initiallyFocusedOptionKey={activeItem} + shouldStopPropagation + ListItem={RadioListItem} + /> + + + + ); +} + +SearchFiltersStatusPage.displayName = 'SearchFiltersStatusPage'; + +export default SearchFiltersStatusPage; diff --git a/src/pages/Search/SearchFiltersTypePage.tsx b/src/pages/Search/SearchFiltersTypePage.tsx index e18b865f20ef..df5d55739884 100644 --- a/src/pages/Search/SearchFiltersTypePage.tsx +++ b/src/pages/Search/SearchFiltersTypePage.tsx @@ -1,16 +1,45 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import Text from '@src/components/Text'; +import Navigation from '@navigation/Navigation'; +import * as SearchActions from '@userActions/Search'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; function SearchFiltersTypePage() { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + + const activeItem = searchAdvancedFiltersForm?.type; + + const filterTypeItems = useMemo( + () => [ + { + text: translate('common.expenses'), + value: CONST.SEARCH.TYPE.EXPENSE, + keyForList: CONST.SEARCH.TYPE.EXPENSE, + isSelected: activeItem === CONST.SEARCH.TYPE.EXPENSE, + }, + ], + [translate, activeItem], + ); + + const updateType = (values: Partial>) => { + SearchActions.updateAdvancedFilters(values); + Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); + }; + return ( - - {/* temporary placeholder, will be implemented in https://github.com/Expensify/App/issues/45026 */} - Advanced filters Type form + + { + updateType({ + type: item.value, + }); + }} + initiallyFocusedOptionKey={activeItem} + shouldStopPropagation + ListItem={RadioListItem} + /> diff --git a/src/types/form/SearchAdvancedFiltersForm.ts b/src/types/form/SearchAdvancedFiltersForm.ts index 46808954f661..3c9a64a06976 100644 --- a/src/types/form/SearchAdvancedFiltersForm.ts +++ b/src/types/form/SearchAdvancedFiltersForm.ts @@ -3,6 +3,7 @@ import type Form from './Form'; const INPUT_IDS = { TYPE: 'type', + STATUS: 'status', DATE_AFTER: 'dateAfter', DATE_BEFORE: 'dateBefore', } as const; @@ -15,6 +16,7 @@ type SearchAdvancedFiltersForm = Form< [INPUT_IDS.TYPE]: string; [INPUT_IDS.DATE_AFTER]: string; [INPUT_IDS.DATE_BEFORE]: string; + [INPUT_IDS.STATUS]: string; } >;