diff --git a/assets/images/bookmark.svg b/assets/images/bookmark.svg
new file mode 100644
index 000000000000..d7c1a8397b37
--- /dev/null
+++ b/assets/images/bookmark.svg
@@ -0,0 +1 @@
+
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index ed4f6a2dc563..38affd97c637 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -215,6 +215,9 @@ const ONYXKEYS = {
/** The NVP containing all information related to educational tooltip in workspace chat */
NVP_WORKSPACE_TOOLTIP: 'workspaceTooltip',
+ /** Whether to hide save search rename tooltip */
+ NVP_SHOULD_HIDE_SAVED_SEARCH_RENAME_TOOLTIP: 'nvp_should_hide_saved_search_rename_tooltip',
+
/** Whether to hide gbr tooltip */
NVP_SHOULD_HIDE_GBR_TOOLTIP: 'nvp_should_hide_gbr_tooltip',
@@ -424,6 +427,9 @@ const ONYXKEYS = {
/** Stores the route to open after changing app permission from settings */
LAST_ROUTE: 'lastRoute',
+ /** Stores the information about the saved searches */
+ SAVED_SEARCHES: 'nvp_savedSearches',
+
/** Stores recently used currencies */
RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies',
@@ -668,6 +674,8 @@ const ONYXKEYS = {
SAGE_INTACCT_DIMENSION_TYPE_FORM_DRAFT: 'sageIntacctDimensionTypeFormDraft',
SEARCH_ADVANCED_FILTERS_FORM: 'searchAdvancedFiltersForm',
SEARCH_ADVANCED_FILTERS_FORM_DRAFT: 'searchAdvancedFiltersFormDraft',
+ SEARCH_SAVED_SEARCH_RENAME_FORM: 'searchSavedSearchRenameForm',
+ SEARCH_SAVED_SEARCH_RENAME_FORM_DRAFT: 'searchSavedSearchRenameFormDraft',
TEXT_PICKER_MODAL_FORM: 'textPickerModalForm',
TEXT_PICKER_MODAL_FORM_DRAFT: 'textPickerModalFormDraft',
RULES_CUSTOM_NAME_MODAL_FORM: 'rulesCustomNameModalForm',
@@ -777,6 +785,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.RULES_REQUIRED_RECEIPT_AMOUNT_FORM]: FormTypes.RulesRequiredReceiptAmountForm;
[ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AMOUNT_FORM]: FormTypes.RulesMaxExpenseAmountForm;
[ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm;
+ [ONYXKEYS.FORMS.SEARCH_SAVED_SEARCH_RENAME_FORM]: FormTypes.SearchSavedSearchRenameForm;
};
type OnyxFormDraftValuesMapping = {
@@ -842,6 +851,7 @@ type OnyxValuesMapping = {
// ONYXKEYS.NVP_TRYNEWDOT is HybridApp onboarding data
[ONYXKEYS.NVP_TRYNEWDOT]: OnyxTypes.TryNewDot;
+ [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch[];
[ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[];
[ONYXKEYS.ACTIVE_CLIENTS]: string[];
[ONYXKEYS.DEVICE_ID]: string;
@@ -976,6 +986,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx;
[ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet;
[ONYXKEYS.LAST_ROUTE]: string;
+ [ONYXKEYS.NVP_SHOULD_HIDE_SAVED_SEARCH_RENAME_TOOLTIP]: boolean;
};
type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 8333fb3c14ce..27504998c49c 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -37,6 +37,10 @@ const ROUTES = {
route: 'search',
getRoute: ({query}: {query: SearchQueryString}) => `search?q=${encodeURIComponent(query)}` as const,
},
+ SEARCH_SAVED_SEARCH_RENAME: {
+ route: 'search/saved-search/rename',
+ getRoute: ({name, jsonQuery}: {name: string; jsonQuery: SearchQueryString}) => `search/saved-search/rename?name=${name}&q=${jsonQuery}` as const,
+ },
SEARCH_ADVANCED_FILTERS: 'search/filters',
SEARCH_ADVANCED_FILTERS_DATE: 'search/filters/date',
SEARCH_ADVANCED_FILTERS_CURRENCY: 'search/filters/currency',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index b87c7703b376..8168afba89ab 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -46,6 +46,7 @@ const SCREENS = {
ADVANCED_FILTERS_TAG_RHP: 'Search_Advanced_Filters_Tag_RHP',
ADVANCED_FILTERS_FROM_RHP: 'Search_Advanced_Filters_From_RHP',
ADVANCED_FILTERS_TO_RHP: 'Search_Advanced_Filters_To_RHP',
+ SAVED_SEARCH_RENAME_RHP: 'Search_Saved_Search_Rename_RHP',
ADVANCED_FILTERS_IN_RHP: 'Search_Advanced_Filters_In_RHP',
TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP',
BOTTOM_TAB: 'Search_Bottom_Tab',
@@ -172,6 +173,7 @@ const SCREENS = {
TRAVEL: 'Travel',
SEARCH_REPORT: 'SearchReport',
SEARCH_ADVANCED_FILTERS: 'SearchAdvancedFilters',
+ SEARCH_SAVED_SEARCH: 'SearchSavedSearch',
SETTINGS_CATEGORIES: 'SettingsCategories',
RESTRICTED_ACTION: 'RestrictedAction',
REPORT_EXPORT: 'Report_Export',
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 698dc33b4a03..6034a6026601 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -24,6 +24,7 @@ import Bell from '@assets/images/bell.svg';
import BellSlash from '@assets/images/bellSlash.svg';
import Bill from '@assets/images/bill.svg';
import Bolt from '@assets/images/bolt.svg';
+import Bookmark from '@assets/images/bookmark.svg';
import Box from '@assets/images/box.svg';
import Briefcase from '@assets/images/briefcase.svg';
import Bug from '@assets/images/bug.svg';
@@ -397,5 +398,6 @@ export {
Feed,
Table,
SpreadsheetComputer,
+ Bookmark,
Star,
};
diff --git a/src/components/MenuItemList.tsx b/src/components/MenuItemList.tsx
index 623198498dd1..2e732c691140 100644
--- a/src/components/MenuItemList.tsx
+++ b/src/components/MenuItemList.tsx
@@ -1,9 +1,10 @@
import React, {useRef} from 'react';
-import type {GestureResponderEvent, View} from 'react-native';
+import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native';
import useSingleExecution from '@hooks/useSingleExecution';
import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu';
import CONST from '@src/CONST';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+import type IconAsset from '@src/types/utils/IconAsset';
import type {MenuItemProps} from './MenuItem';
import MenuItem from './MenuItem';
import OfflineWithFeedback from './OfflineWithFeedback';
@@ -36,9 +37,32 @@ type MenuItemListProps = {
/** Whether or not to use the single execution hook */
shouldUseSingleExecution?: boolean;
+
+ /** Any additional styles to apply for each item */
+ wrapperStyle?: StyleProp;
+
+ /** Icon to display on the left side of each item */
+ icon?: IconAsset;
+
+ /** Icon Width */
+ iconWidth?: number;
+
+ /** Icon Height */
+ iconHeight?: number;
+
+ /** Is this in the Pane */
+ isPaneMenu?: boolean;
};
-function MenuItemList({menuItems = [], shouldUseSingleExecution = false}: MenuItemListProps) {
+function MenuItemList({
+ menuItems = [],
+ shouldUseSingleExecution = false,
+ wrapperStyle = {},
+ icon = undefined,
+ iconWidth = undefined,
+ iconHeight = undefined,
+ isPaneMenu = false,
+}: MenuItemListProps) {
const popoverAnchor = useRef(null);
const {isExecuting, singleExecution} = useSingleExecution();
@@ -67,9 +91,14 @@ function MenuItemList({menuItems = [], shouldUseSingleExecution = false}: MenuIt
>
{displaySignIn && }
+ {isCustomSearchQuery && (
+ {
+ Navigation.goBack(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchUtils.buildCannedSearchQuery()}));
+ }}
+ >
+ {translate('common.cancel')}
+
+ )}
{displaySearch && (
> =
SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP,
SCREENS.SEARCH.ADVANCED_FILTERS_IN_RHP,
SCREENS.SEARCH.ADVANCED_FILTERS_CARD_RHP,
+ SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP,
],
[SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: [
SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index c20fa0fb9369..2ca2db10a1a7 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -1145,6 +1145,11 @@ const config: LinkingOptions['config'] = {
[SCREENS.SEARCH.ADVANCED_FILTERS_IN_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_IN,
},
},
+ [SCREENS.RIGHT_MODAL.SEARCH_SAVED_SEARCH]: {
+ screens: {
+ [SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP]: ROUTES.SEARCH_SAVED_SEARCH_RENAME.route,
+ },
+ },
[SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: {
screens: {
[SCREENS.RESTRICTED_ACTION_ROOT]: ROUTES.RESTRICTED_ACTION.route,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 98c4f357480b..c6a4ce90c214 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -13,6 +13,7 @@ import type {
import type {TupleToUnion, ValueOf} from 'type-fest';
import type {SearchQueryString} from '@components/Search/types';
import type {IOURequestType} from '@libs/actions/IOU';
+import type {SaveSearchParams} from '@libs/API/parameters';
import type CONST from '@src/CONST';
import type {Country, IOUAction, IOUType} from '@src/CONST';
import type NAVIGATORS from '@src/NAVIGATORS';
@@ -1181,6 +1182,7 @@ type RightModalNavigatorParamList = {
[SCREENS.RIGHT_MODAL.SEARCH_REPORT]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.SEARCH_ADVANCED_FILTERS]: NavigatorScreenParams;
+ [SCREENS.RIGHT_MODAL.SEARCH_SAVED_SEARCH]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.MISSING_PERSONAL_DETAILS]: NavigatorScreenParams;
};
@@ -1405,6 +1407,10 @@ type SearchAdvancedFiltersParamList = {
[SCREENS.SEARCH.ADVANCED_FILTERS_RHP]: Record;
};
+type SearchSavedSearchParamList = {
+ [SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP]: SaveSearchParams;
+};
+
type RestrictedActionParamList = {
[SCREENS.RESTRICTED_ACTION_ROOT]: {
policyID: string;
@@ -1487,6 +1493,7 @@ export type {
TransactionDuplicateNavigatorParamList,
SearchReportParamList,
SearchAdvancedFiltersParamList,
+ SearchSavedSearchParamList,
RestrictedActionParamList,
MissingPersonalDetailsParamList,
};
diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts
index feb7bd4d5857..24872b5d90eb 100644
--- a/src/libs/SearchUtils.ts
+++ b/src/libs/SearchUtils.ts
@@ -5,9 +5,11 @@ import ChatListItem from '@components/SelectionList/ChatListItem';
import ReportListItem from '@components/SelectionList/Search/ReportListItem';
import TransactionListItem from '@components/SelectionList/Search/TransactionListItem';
import type {ListItem, ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
+import * as Expensicons from '@src/components/Icon/Expensicons';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import type {SearchAdvancedFiltersForm} from '@src/types/form';
import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm';
@@ -17,6 +19,7 @@ import type {ListItemDataType, ListItemType, SearchDataTypes, SearchPersonalDeta
import * as CurrencyUtils from './CurrencyUtils';
import DateUtils from './DateUtils';
import {translateLocal} from './Localize';
+import Navigation from './Navigation/Navigation';
import navigationRef from './Navigation/navigationRef';
import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types';
import * as PersonalDetailsUtils from './PersonalDetailsUtils';
@@ -761,6 +764,33 @@ function buildCannedSearchQuery(type: SearchDataTypes = CONST.SEARCH.DATA_TYPES.
return normalizeQuery(`type:${type} status:${status}`);
}
+function getOverflowMenu(itemName: string, hash: number, inputQuery: string, showDeleteModal: (hash: number) => void, isMobileMenu?: boolean, closeMenu?: () => void) {
+ return [
+ {
+ text: translateLocal('common.rename'),
+ onSelected: () => {
+ if (isMobileMenu && closeMenu) {
+ closeMenu();
+ }
+ Navigation.navigate(ROUTES.SEARCH_SAVED_SEARCH_RENAME.getRoute({name: itemName, jsonQuery: inputQuery}));
+ },
+ icon: Expensicons.Pencil,
+ shouldShowRightIcon: false,
+ shouldShowRightComponent: false,
+ shouldCallAfterModalHide: true,
+ },
+ {
+ text: translateLocal('common.delete'),
+ onSelected: () => showDeleteModal(hash),
+ icon: Expensicons.Trashcan,
+ shouldShowRightIcon: false,
+ shouldShowRightComponent: false,
+ shouldCallAfterModalHide: true,
+ shouldCloseAllModals: true,
+ },
+ ];
+}
+
/**
* Returns whether a given search query is a Canned query.
*
@@ -792,4 +822,5 @@ export {
buildCannedSearchQuery,
isCannedSearchQuery,
getExpenseTypeTranslationKey,
+ getOverflowMenu,
};
diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts
index e522aea788e4..a4f0e59ef976 100644
--- a/src/libs/actions/Search.ts
+++ b/src/libs/actions/Search.ts
@@ -51,6 +51,17 @@ function getOnyxLoadingData(hash: number): {optimisticData: OnyxUpdate[]; finall
return {optimisticData, finallyData};
}
+function saveSearch({queryJSON, name}: {queryJSON: SearchQueryJSON; name?: string}) {
+ const saveSearchName = name ?? queryJSON?.inputQuery ?? '';
+ const jsonQuery = JSON.stringify(queryJSON);
+
+ API.write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, name: saveSearchName});
+}
+
+function deleteSavedSearch(hash: number) {
+ API.write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {hash});
+}
+
function search({queryJSON, offset}: {queryJSON: SearchQueryJSON; offset?: number}) {
const {optimisticData, finallyData} = getOnyxLoadingData(queryJSON.hash);
const {flatFilters, ...queryJSONWithoutFlatFilters} = queryJSON;
@@ -145,7 +156,12 @@ function clearAdvancedFilters() {
Onyx.merge(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, values);
}
+function dismissSavedSearchRenameTooltip() {
+ Onyx.merge(ONYXKEYS.NVP_SHOULD_HIDE_SAVED_SEARCH_RENAME_TOOLTIP, true);
+}
+
export {
+ saveSearch,
search,
createTransactionThread,
deleteMoneyRequestOnSearch,
@@ -155,4 +171,6 @@ export {
updateAdvancedFilters,
clearAllFilters,
clearAdvancedFilters,
+ deleteSavedSearch,
+ dismissSavedSearchRenameTooltip,
};
diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx
index ac31c4e7f3b9..fa59495d2c8f 100644
--- a/src/pages/Search/AdvancedSearchFilters.tsx
+++ b/src/pages/Search/AdvancedSearchFilters.tsx
@@ -1,14 +1,15 @@
-import React from 'react';
+import React, {useMemo} from 'react';
import {View} from 'react-native';
import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils';
import type {OnyxCollection} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import {usePersonalDetails} from '@components/OnyxProvider';
import ScrollView from '@components/ScrollView';
-import type {AdvancedFiltersKeys} from '@components/Search/types';
+import type {AdvancedFiltersKeys, SearchQueryJSON} from '@components/Search/types';
import useLocalize from '@hooks/useLocalize';
import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -220,24 +221,33 @@ function AdvancedSearchFilters() {
const {singleExecution} = useSingleExecution();
const waitForNavigate = useWaitForNavigation();
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
-
const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
const taxRates = getAllTaxRates();
const personalDetails = usePersonalDetails();
const currentType = searchAdvancedFilters?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE;
- const onFormSubmit = () => {
- const query = SearchUtils.buildQueryStringFromFilterValues(searchAdvancedFilters);
+ const queryString = useMemo(() => SearchUtils.buildQueryStringFromFilterValues(searchAdvancedFilters) || '', [searchAdvancedFilters]);
+ const queryJSON = useMemo(() => SearchUtils.buildSearchQueryJSON(queryString || SearchUtils.buildCannedSearchQuery()) ?? ({} as SearchQueryJSON), [queryString]);
+
+ const applyFiltersAndNavigate = () => {
SearchActions.clearAllFilters();
Navigation.dismissModal();
Navigation.navigate(
ROUTES.SEARCH_CENTRAL_PANE.getRoute({
- query,
+ query: queryString,
}),
);
};
+ const onSaveSearch = () => {
+ SearchActions.saveSearch({
+ queryJSON,
+ });
+
+ applyFiltersAndNavigate();
+ };
+
const filters = typeFiltersKeys[currentType].map((key) => {
const onPress = singleExecution(waitForNavigate(() => Navigation.navigate(baseFilterConfig[key].route)));
let filterTitle;
@@ -296,10 +306,19 @@ function AdvancedSearchFilters() {
})}
+
+ {!SearchUtils.isCannedSearchQuery(queryJSON) && (
+
+ )}
>
diff --git a/src/pages/Search/SavedSearchRenamePage.tsx b/src/pages/Search/SavedSearchRenamePage.tsx
new file mode 100644
index 000000000000..0460ecae5317
--- /dev/null
+++ b/src/pages/Search/SavedSearchRenamePage.tsx
@@ -0,0 +1,73 @@
+import React, {useState} from 'react';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import type {SearchQueryJSON} from '@components/Search/types';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as SearchActions from '@libs/actions/Search';
+import Navigation from '@libs/Navigation/Navigation';
+import * as SearchUtils from '@libs/SearchUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import INPUT_IDS from '@src/types/form/SearchSavedSearchRenameForm';
+
+function SavedSearchRenamePage({route}: {route: {params: {q: string; name: string}}}) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {q, name} = route.params;
+ const [newName, setNewName] = useState(name);
+
+ const applyFiltersAndNavigate = () => {
+ SearchActions.clearAdvancedFilters();
+ Navigation.navigate(
+ ROUTES.SEARCH_CENTRAL_PANE.getRoute({
+ query: q,
+ }),
+ );
+ };
+
+ const onSaveSearch = () => {
+ const queryJSON = SearchUtils.buildSearchQueryJSON(q || SearchUtils.buildCannedSearchQuery()) ?? ({} as SearchQueryJSON);
+
+ SearchActions.saveSearch({
+ queryJSON,
+ name: newName,
+ });
+
+ applyFiltersAndNavigate();
+ };
+
+ return (
+
+
+
+ setNewName(renamedName)}
+ />
+
+
+ );
+}
+
+SavedSearchRenamePage.displayName = 'SavedSearchRenamePage';
+
+export default SavedSearchRenamePage;
diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx
index 9c46588bb68e..9226093154ae 100644
--- a/src/pages/Search/SearchPageBottomTab.tsx
+++ b/src/pages/Search/SearchPageBottomTab.tsx
@@ -1,4 +1,4 @@
-import React, {useMemo} from 'react';
+import React from 'react';
import {useOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -26,20 +26,12 @@ function SearchPageBottomTab() {
const styles = useThemeStyles();
const {clearSelectedTransactions} = useSearchContext();
const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE);
-
- const {queryJSON, policyID} = useMemo(() => {
- if (activeCentralPaneRoute?.name !== SCREENS.SEARCH.CENTRAL_PANE) {
- return {queryJSON: undefined, policyID: undefined};
- }
-
- const searchParams = activeCentralPaneRoute?.params as AuthScreensParamList[typeof SCREENS.SEARCH.CENTRAL_PANE];
- const parsedQuery = SearchUtils.buildSearchQueryJSON(searchParams?.q);
-
- return {
- queryJSON: parsedQuery,
- policyID: parsedQuery && SearchUtils.getPolicyIDFromSearchQuery(parsedQuery),
- };
- }, [activeCentralPaneRoute]);
+ const searchParams = activeCentralPaneRoute?.params as AuthScreensParamList[typeof SCREENS.SEARCH.CENTRAL_PANE];
+ const parsedQuery = SearchUtils.buildSearchQueryJSON(searchParams?.q);
+ const policyIDFromSearchQuery = parsedQuery && SearchUtils.getPolicyIDFromSearchQuery(parsedQuery);
+ const isActiveCentralPaneRoute = activeCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE;
+ const queryJSON = isActiveCentralPaneRoute ? parsedQuery : undefined;
+ const policyID = isActiveCentralPaneRoute ? policyIDFromSearchQuery : undefined;
const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchUtils.buildCannedSearchQuery()}));
@@ -60,6 +52,7 @@ function SearchPageBottomTab() {
activeWorkspaceID={policyID}
breadcrumbLabel={translate('common.search')}
shouldDisplaySearch={false}
+ isCustomSearchQuery={shouldUseNarrowLayout && !SearchUtils.isCannedSearchQuery(queryJSON)}
/>
>
diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx
index 281218415f9a..ce6068fdeb53 100644
--- a/src/pages/Search/SearchTypeMenu.tsx
+++ b/src/pages/Search/SearchTypeMenu.tsx
@@ -1,9 +1,17 @@
-import React from 'react';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
+import type {TextStyle, ViewStyle} from 'react-native';
import {useOnyx} from 'react-native-onyx';
+import type {MenuItemBaseProps} from '@components/MenuItem';
import MenuItem from '@components/MenuItem';
+import MenuItemList from '@components/MenuItemList';
+import type {MenuItemWithLink} from '@components/MenuItemList';
import {usePersonalDetails} from '@components/OnyxProvider';
+import ScrollView from '@components/ScrollView';
import type {SearchQueryJSON} from '@components/Search/types';
+import Text from '@components/Text';
+import ThreeDotsMenu from '@components/ThreeDotsMenu';
+import useDeleteSavedSearch from '@hooks/useDeleteSavedSearch';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSingleExecution from '@hooks/useSingleExecution';
@@ -18,10 +26,18 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
+import type {SaveSearchItem} from '@src/types/onyx/SaveSearch';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
import type IconAsset from '@src/types/utils/IconAsset';
import SearchTypeMenuNarrow from './SearchTypeMenuNarrow';
+type SavedSearchMenuItem = MenuItemBaseProps & {
+ key: string;
+ hash: string;
+ query: string;
+ styles: Array;
+};
+
type SearchTypeMenuProps = {
queryJSON: SearchQueryJSON;
};
@@ -34,11 +50,15 @@ type SearchTypeMenuItem = {
};
function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) {
- const {type} = queryJSON;
+ const {type, hash} = queryJSON;
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {singleExecution} = useSingleExecution();
const {translate} = useLocalize();
+ const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES);
+ const [shouldHideSavedSearchRenameTooltip] = useOnyx(ONYXKEYS.NVP_SHOULD_HIDE_SAVED_SEARCH_RENAME_TOOLTIP, {initialValue: true});
+ const {showDeleteModal, DeleteConfirmModal} = useDeleteSavedSearch();
+
const personalDetails = usePersonalDetails();
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const taxRates = getAllTaxRates();
@@ -71,6 +91,94 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) {
},
];
+ const getOverflowMenu = useCallback(
+ (itemName: string, itemHash: number, itemQuery: string) => SearchUtils.getOverflowMenu(itemName, itemHash, itemQuery, showDeleteModal),
+ [showDeleteModal],
+ );
+
+ const createSavedSearchMenuItem = useCallback(
+ (item: SaveSearchItem, key: string, isNarrow: boolean) => {
+ const baseMenuItem: SavedSearchMenuItem = {
+ key,
+ title: item.name,
+ hash: key,
+ query: item.query,
+ shouldShowRightComponent: true,
+ focused: Number(key) === hash,
+ onPress: () => {
+ SearchActions.clearAllFilters();
+ Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: item?.query ?? ''}));
+ },
+ rightComponent: (
+
+ ),
+ styles: [styles.alignItemsCenter],
+ };
+
+ if (!isNarrow) {
+ return {
+ ...baseMenuItem,
+ shouldRenderTooltip: !shouldHideSavedSearchRenameTooltip,
+ tooltipAnchorAlignment: {
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
+ },
+ tooltipShiftHorizontal: -32,
+ tooltipShiftVertical: 15,
+ tooltipWrapperStyle: [styles.bgPaleGreen, styles.mh4, styles.pv2],
+ renderTooltipContent: () => {
+ SearchActions.dismissSavedSearchRenameTooltip();
+ return (
+
+
+ {translate('search.saveSearchTooltipText')}
+
+ );
+ },
+ };
+ }
+
+ return baseMenuItem;
+ },
+ [hash, styles, getOverflowMenu, translate, shouldHideSavedSearchRenameTooltip],
+ );
+
+ const savedSearchesMenuItems = () => {
+ if (!savedSearches) {
+ return [];
+ }
+ return Object.entries(savedSearches).map(([key, item]) => createSavedSearchMenuItem(item as SaveSearchItem, key, shouldUseNarrowLayout));
+ };
+
+ const renderSavedSearchesSection = useCallback(
+ (menuItems: MenuItemWithLink[]) => (
+
+ {translate('search.savedSearchesMenuItemTitle')}
+
+
+ ),
+ [styles, translate],
+ );
+
const isCannedQuery = SearchUtils.isCannedSearchQuery(queryJSON);
const activeItemIndex = isCannedQuery ? typeMenuItems.findIndex((item) => item.type === type) : -1;
@@ -83,36 +191,45 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) {
activeItemIndex={activeItemIndex}
queryJSON={queryJSON}
title={title}
+ savedSearchesMenuItems={savedSearchesMenuItems()}
/>
);
}
return (
-
- {typeMenuItems.map((item, index) => {
- const onPress = singleExecution(() => {
- SearchActions.clearAllFilters();
- Navigation.navigate(item.route);
- });
-
- return (
-
- );
- })}
-
+ <>
+
+ {typeMenuItems.map((item, index) => {
+ const onPress = singleExecution(() => {
+ SearchActions.clearAllFilters();
+ Navigation.navigate(item.route);
+ });
+
+ return (
+
+ );
+ })}
+
+ {savedSearches && Object.keys(savedSearches).length > 0 && (
+ <>
+ {renderSavedSearchesSection(savedSearchesMenuItems())}
+
+ >
+ )}
+ >
);
}
diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx
index b8a9e6c11e5f..0158a15bfc41 100644
--- a/src/pages/Search/SearchTypeMenuNarrow.tsx
+++ b/src/pages/Search/SearchTypeMenuNarrow.tsx
@@ -1,11 +1,17 @@
-import React, {useMemo, useRef, useState} from 'react';
+import React, {useCallback, useMemo, useRef, useState} from 'react';
import {Animated, View} from 'react-native';
+import type {TextStyle, ViewStyle} from 'react-native';
import Button from '@components/Button';
import Icon from '@components/Icon';
+import type {MenuItemBaseProps} from '@components/MenuItem';
import PopoverMenu from '@components/PopoverMenu';
+import type {PopoverMenuItem} from '@components/PopoverMenu';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import type {SearchQueryJSON} from '@components/Search/types';
import Text from '@components/Text';
+import ThreeDotsMenu from '@components/ThreeDotsMenu';
+import useDeleteSavedSearch from '@hooks/useDeleteSavedSearch';
+import useLocalize from '@hooks/useLocalize';
import useSingleExecution from '@hooks/useSingleExecution';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
@@ -13,73 +19,121 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as SearchActions from '@libs/actions/Search';
import Navigation from '@libs/Navigation/Navigation';
+import * as SearchUtils from '@libs/SearchUtils';
import variables from '@styles/variables';
import * as Expensicons from '@src/components/Icon/Expensicons';
-import * as SearchUtils from '@src/libs/SearchUtils';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type {SearchTypeMenuItem} from './SearchTypeMenu';
+type SavedSearchMenuItem = MenuItemBaseProps & {
+ key: string;
+ hash: string;
+ query: string;
+ styles: Array;
+};
+
type SearchTypeMenuNarrowProps = {
typeMenuItems: SearchTypeMenuItem[];
activeItemIndex: number;
queryJSON: SearchQueryJSON;
title?: string;
+ savedSearchesMenuItems: SavedSearchMenuItem[];
};
-function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title}: SearchTypeMenuNarrowProps) {
+function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title, savedSearchesMenuItems}: SearchTypeMenuNarrowProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {singleExecution} = useSingleExecution();
const {windowHeight} = useWindowDimensions();
+ const {translate} = useLocalize();
+ const {hash} = queryJSON;
+ const {showDeleteModal, DeleteConfirmModal} = useDeleteSavedSearch();
const [isPopoverVisible, setIsPopoverVisible] = useState(false);
const buttonRef = useRef(null);
- const openMenu = () => setIsPopoverVisible(true);
- const closeMenu = () => setIsPopoverVisible(false);
+ const openMenu = useCallback(() => setIsPopoverVisible(true), []);
+ const closeMenu = useCallback(() => setIsPopoverVisible(false), []);
const onPress = () => {
const values = SearchUtils.getFiltersFormValues(queryJSON);
SearchActions.updateAdvancedFilters(values);
Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS);
};
- const popoverMenuItems = typeMenuItems.map((item, index) => {
- const isSelected = title ? false : index === activeItemIndex;
-
- return {
- text: item.title,
- onSelected: singleExecution(() => {
- SearchActions.clearAllFilters();
- Navigation.navigate(item.route);
- }),
- isSelected,
- 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,
- isSelected: true,
- icon: Expensicons.Filters,
- iconFill: theme.iconSuccessFill,
- success: true,
- containerStyle: [{backgroundColor: theme.border}],
- iconRight: Expensicons.Checkmark,
- shouldShowRightIcon: false,
+ const currentSavedSearch = savedSearchesMenuItems.find((item) => Number(item.hash) === hash);
+
+ const popoverMenuItems = useMemo(() => {
+ const items = typeMenuItems.map((item, index) => {
+ const isSelected = title ? false : index === activeItemIndex;
+
+ return {
+ text: item.title,
+ onSelected: singleExecution(() => {
+ SearchActions.clearAllFilters();
+ Navigation.navigate(item.route);
+ }),
+ isSelected,
+ icon: item.icon,
+ iconFill: isSelected ? theme.iconSuccessFill : theme.icon,
+ iconRight: Expensicons.Checkmark,
+ shouldShowRightIcon: isSelected,
+ success: isSelected,
+ containerStyle: isSelected ? [{backgroundColor: theme.border}] : undefined,
+ };
});
- }
+
+ if (title) {
+ items.push({
+ text: title,
+ onSelected: closeMenu,
+ isSelected: !currentSavedSearch,
+ icon: Expensicons.Filters,
+ iconFill: theme.iconSuccessFill,
+ success: true,
+ containerStyle: undefined,
+ iconRight: Expensicons.Checkmark,
+ shouldShowRightIcon: false,
+ });
+ }
+
+ return items;
+ }, [typeMenuItems, activeItemIndex, title, theme, singleExecution, closeMenu, currentSavedSearch]);
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} : {};
+ const titleViewStyles = useMemo(() => (title ? {...styles.flex1, ...styles.justifyContentCenter} : {}), [title, styles]);
+
+ const savedSearchItems = savedSearchesMenuItems.map((item) => ({
+ text: item.title ?? '',
+ styles: [styles.textSupporting],
+ onSelected: item.onPress,
+ shouldShowRightComponent: true,
+ rightComponent: (
+
+ ),
+ isSelected: currentSavedSearch?.hash === item.hash,
+ }));
+
+ const allMenuItems = [];
+ allMenuItems.push(...popoverMenuItems);
+
+ if (savedSearchesMenuItems.length > 0) {
+ allMenuItems.push({
+ text: translate('search.savedSearchesMenuItemTitle'),
+ styles: [styles.textSupporting],
+ disabled: true,
+ });
+ allMenuItems.push(...savedSearchItems);
+ }
return (
@@ -118,13 +172,14 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title}
onPress={onPress}
/>
+
);
}
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 67455ceedf91..9dfd18b641e8 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5191,6 +5191,14 @@ const styles = (theme: ThemeColors) =>
backgroundColor: theme.border,
},
+ colorGreenSuccess: {
+ color: colors.green400,
+ },
+
+ bgPaleGreen: {
+ backgroundColor: colors.green100,
+ },
+
importColumnCard: {
backgroundColor: theme.cardBG,
borderRadius: variables.componentBorderRadiusNormal,
diff --git a/src/types/form/SearchSavedSearchRenameForm.ts b/src/types/form/SearchSavedSearchRenameForm.ts
new file mode 100644
index 000000000000..27f28c05ad10
--- /dev/null
+++ b/src/types/form/SearchSavedSearchRenameForm.ts
@@ -0,0 +1,18 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ NAME: 'savedSearchNewName',
+} as const;
+
+type InputID = ValueOf;
+
+type SearchSavedSearchRenameForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.NAME]: string;
+ }
+>;
+
+export type {SearchSavedSearchRenameForm};
+export default INPUT_IDS;
diff --git a/src/types/form/index.ts b/src/types/form/index.ts
index e24a240f0f1c..82dd63a36fe9 100644
--- a/src/types/form/index.ts
+++ b/src/types/form/index.ts
@@ -82,5 +82,6 @@ export type {RulesMaxExpenseAgeForm} from './RulesMaxExpenseAgeForm';
export type {WorkspaceCategoryDescriptionHintForm} from './WorkspaceCategoryDescriptionHintForm';
export type {WorkspaceCategoryFlagAmountsOverForm} from './WorkspaceCategoryFlagAmountsOverForm';
export type {WorkspaceCompanyCardFeedName} from './WorkspaceCompanyCardFeedName';
+export type {SearchSavedSearchRenameForm} from './SearchSavedSearchRenameForm';
export type {WorkspaceCompanyCardEditName} from './WorkspaceCompanyCardEditName';
export type {PersonalDetailsForm} from './PersonalDetailsForm';
diff --git a/src/types/onyx/SaveSearch.ts b/src/types/onyx/SaveSearch.ts
new file mode 100644
index 000000000000..d8f8bf32f2a1
--- /dev/null
+++ b/src/types/onyx/SaveSearch.ts
@@ -0,0 +1,17 @@
+/**
+ * Model of a single saved search
+ */
+type SaveSearchItem = {
+ /** Name of the saved search */
+ name: string;
+
+ /** Query string for the saved search */
+ query: string;
+};
+
+/**
+ * Model of saved searches
+ */
+type SaveSearch = Record;
+
+export type {SaveSearch, SaveSearchItem};
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index a44aacfb679f..c7c264a3da15 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -81,6 +81,7 @@ import type ReportViolations from './ReportViolation';
import type Request from './Request';
import type Response from './Response';
import type ReviewDuplicates from './ReviewDuplicates';
+import type {SaveSearch} from './SaveSearch';
import type ScreenShareRequest from './ScreenShareRequest';
import type SearchResults from './SearchResults';
import type SecurityGroup from './SecurityGroup';
@@ -230,6 +231,7 @@ export type {
MobileSelectionMode,
WorkspaceTooltip,
CardFeeds,
+ SaveSearch,
ImportedSpreadsheet,
ValidateMagicCodeAction,
};