diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx
index 78d33726bb2f..a7fb1c7671b4 100644
--- a/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx
+++ b/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx
@@ -2,11 +2,14 @@ import TopBar from '@components/Navigation/TopBar';
import type {SearchQueryJSON} from '@components/Search/types';
import useLocalize from '@hooks/useLocalize';
+import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections';
import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow';
import React from 'react';
+import getSearchPageHeaderTitle from './getSearchPageHeaderTitle';
+
type SearchPageHeaderNarrowProps = {
queryJSON: SearchQueryJSON;
shouldShowLoadingBar: boolean;
@@ -15,6 +18,10 @@ type SearchPageHeaderNarrowProps = {
function SearchPageHeaderNarrow({queryJSON, shouldShowLoadingBar = false, isMobileSelectionModeEnabled}: SearchPageHeaderNarrowProps) {
const {translate} = useLocalize();
+ const {typeMenuSections, activeItemIndex, activeSavedSearch} = useSearchTypeMenuSections(queryJSON);
+ const selectedItem = activeItemIndex >= 0 ? typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex) : undefined;
+
+ const title = getSearchPageHeaderTitle({translate, type: queryJSON.type, activeSavedSearch, selectedItem});
if (isMobileSelectionModeEnabled) {
return ;
@@ -23,7 +30,7 @@ function SearchPageHeaderNarrow({queryJSON, shouldShowLoadingBar = false, isMobi
return (
);
diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx
index 4b9915ed7150..1acb6d1d7581 100644
--- a/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx
+++ b/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx
@@ -4,34 +4,20 @@ import type {SearchQueryJSON} from '@components/Search/types';
import useLocalize from '@hooks/useLocalize';
import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections';
-import CONST from '@src/CONST';
-
import React from 'react';
+import getSearchPageHeaderTitle from './getSearchPageHeaderTitle';
+
type SearchPageHeaderWideProps = {
queryJSON: SearchQueryJSON;
};
function SearchPageHeaderWide({queryJSON}: SearchPageHeaderWideProps) {
const {translate} = useLocalize();
- const {typeMenuSections, activeItemIndex} = useSearchTypeMenuSections(queryJSON);
- const selectedItem = typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex);
-
- let title = translate('common.spend');
- if (activeItemIndex >= 0 && selectedItem) {
- title = translate(selectedItem.translationPath);
- } else {
- const {type} = queryJSON;
- if (type === CONST.SEARCH.DATA_TYPES.TASK) {
- title = translate(`common.tasks`);
- } else if (type === CONST.SEARCH.DATA_TYPES.TRIP) {
- title = translate(`travel.trips`);
- } else if (type === CONST.SEARCH.DATA_TYPES.INVOICE) {
- title = translate(`workspace.common.invoices`);
- } else if (type === CONST.SEARCH.DATA_TYPES.CHAT) {
- title = translate(`common.chats`);
- }
- }
+ const {typeMenuSections, activeItemIndex, activeSavedSearch} = useSearchTypeMenuSections(queryJSON);
+ const selectedItem = activeItemIndex >= 0 ? typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex) : undefined;
+
+ const title = getSearchPageHeaderTitle({translate, type: queryJSON.type, activeSavedSearch, selectedItem});
return (
= 0`) */
+ selectedItem: SearchTypeMenuItem | undefined;
+};
+
+/**
+ * Resolves the Search page header title using a single priority chain shared by the wide and narrow headers so the two
+ * can't diverge:
+ * a. the active saved search's display name,
+ * b. the matched suggested-search label, then the data-type fallbacks (task / trip / invoice / chat),
+ * c. the generic "Spend" fallback.
+ */
+function getSearchPageHeaderTitle({translate, type, activeSavedSearch, selectedItem}: SearchPageHeaderTitleDeps): string {
+ // a. Active saved search display name. `name` is a display string, not a translation key, so it's used directly
+ // (matching the LHN, which renders `item.name` for the `name !== query` case).
+ if (activeSavedSearch?.name) {
+ return activeSavedSearch.name;
+ }
+
+ // b. Matched suggested search.
+ if (selectedItem) {
+ return translate(selectedItem.translationPath);
+ }
+
+ // b (cont.). Data-type fallbacks.
+ if (type === CONST.SEARCH.DATA_TYPES.TASK) {
+ return translate('common.tasks');
+ }
+ if (type === CONST.SEARCH.DATA_TYPES.TRIP) {
+ return translate('travel.trips');
+ }
+ if (type === CONST.SEARCH.DATA_TYPES.INVOICE) {
+ return translate('workspace.common.invoices');
+ }
+ if (type === CONST.SEARCH.DATA_TYPES.CHAT) {
+ return translate('common.chats');
+ }
+
+ // c. Generic fallback.
+ return translate('common.spend');
+}
+
+export default getSearchPageHeaderTitle;
diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts
index 3db55ddb9e9b..ef0a6a41fc57 100644
--- a/src/hooks/useSearchTypeMenuSections.ts
+++ b/src/hooks/useSearchTypeMenuSections.ts
@@ -143,21 +143,22 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams
],
);
- const activeItemIndex = useMemo(() => {
- const isSavedSearchActive =
- hash !== undefined &&
- !!savedSearches &&
- Object.entries(savedSearches).some(([key, item]) => {
- if (Number(key) !== hash) {
- return false;
- }
- if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) {
- return false;
- }
- return true;
- });
+ // The saved search the current query maps to (keyed by `hash`), derived from the existing `savedSearches`
+ // subscription. Undefined when there is no match or when the match is pending deletion (unless offline).
+ const activeSavedSearch = (() => {
+ if (hash === undefined || !savedSearches) {
+ return undefined;
+ }
+ const item = savedSearches[hash];
+ if (!item || (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline)) {
+ return undefined;
+ }
+ return item;
+ })();
- if (isSavedSearchActive) {
+ const activeItemIndex = (() => {
+ // A saved search is not part of `typeMenuSections`, so keep suggested-search focus off it.
+ if (activeSavedSearch) {
return -1;
}
@@ -193,7 +194,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams
}
return -1;
- }, [typeMenuSections, savedSearches, hash, similarSearchHash, sortBy, sortOrder, type, isOffline]);
+ })();
const activeKey = activeItemIndex < 0 ? undefined : typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex)?.key;
@@ -201,6 +202,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams
typeMenuSections,
activeItemIndex,
activeKey,
+ activeSavedSearch,
};
};
diff --git a/src/pages/Search/SavedSearchRenamePage.tsx b/src/pages/Search/SavedSearchRenamePage.tsx
index 768c7015e144..0d06f2abbb6e 100644
--- a/src/pages/Search/SavedSearchRenamePage.tsx
+++ b/src/pages/Search/SavedSearchRenamePage.tsx
@@ -1,5 +1,6 @@
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import type {SearchQueryJSON} from '@components/Search/types';
@@ -12,44 +13,48 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {saveSearch} from '@libs/actions/Search';
import Navigation from '@libs/Navigation/Navigation';
import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils';
+import {getFieldRequiredErrors} from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/SearchSavedSearchRenameForm';
-import React, {useState} from 'react';
+import React from 'react';
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 {inputCallbackRef} = useAutoFocusInput();
- const applyFiltersAndNavigate = () => {
+ const applyFiltersAndNavigate = (newName: string) => {
Navigation.dismissModal();
Navigation.isNavigationReady().then(() => {
Navigation.navigate(
ROUTES.SEARCH_ROOT.getRoute({
query: q,
- name: newName?.trim(),
+ name: newName,
}),
);
});
};
- const onSaveSearch = () => {
+ const onSaveSearch = (values: FormOnyxValues) => {
+ const newName = values[INPUT_IDS.NAME].trim();
const queryJSON = buildSearchQueryJSON(q || buildCannedSearchQuery()) ?? ({} as SearchQueryJSON);
saveSearch({
queryJSON,
- newName: newName?.trim() || q,
+ newName,
});
- applyFiltersAndNavigate();
+ applyFiltersAndNavigate(newName);
};
+ const validate = (values: FormOnyxValues): FormInputErrors =>
+ getFieldRequiredErrors(values, [INPUT_IDS.NAME], translate);
+
return (
setNewName(renamedName)}
ref={inputCallbackRef}
defaultValue={name}
/>
diff --git a/src/pages/Search/SearchSavePage.tsx b/src/pages/Search/SearchSavePage.tsx
index e115523237f9..f72da9de16f0 100644
--- a/src/pages/Search/SearchSavePage.tsx
+++ b/src/pages/Search/SearchSavePage.tsx
@@ -1,5 +1,6 @@
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import type {LocalizedTranslate} from '@components/LocaleContextProvider';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -25,6 +26,7 @@ import {saveSearch} from '@libs/actions/Search';
import Navigation from '@libs/Navigation/Navigation';
import {getCustomColumnDefault, getSearchColumnTranslationKey, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils';
import type {SearchFilter} from '@libs/SearchUIUtils';
+import {getFieldRequiredErrors} from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -32,7 +34,7 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form';
import INPUT_IDS from '@src/types/form/SearchSaveForm';
import {getEmptyObject} from '@src/types/utils/EmptyObject';
-import React, {useState} from 'react';
+import React from 'react';
import {View} from 'react-native';
type FilterValueProps = {
@@ -154,21 +156,22 @@ function SearchSavePage() {
const {translate, localeCompare} = useLocalize();
const {convertToDisplayStringWithoutCurrency} = useCurrencyListActions();
const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
- const [name, setName] = useState('');
const {currentSearchQueryJSON} = useSearchQueryContext();
- const onSaveSearch = () => {
+ const onSaveSearch = (values: FormOnyxValues) => {
if (!currentSearchQueryJSON) {
Navigation.goBack();
return;
}
- const newName = name.trim() || currentSearchQueryJSON?.inputQuery;
- saveSearch({queryJSON: currentSearchQueryJSON, newName});
+ saveSearch({queryJSON: currentSearchQueryJSON, newName: values[INPUT_IDS.NAME].trim()});
Navigation.goBack();
};
+ const validate = (values: FormOnyxValues): FormInputErrors =>
+ getFieldRequiredErrors(values, [INPUT_IDS.NAME], translate);
+
const appliedFilters = mapFiltersFormToLabelValueList(searchAdvancedFiltersForm, undefined, translate, localeCompare, convertToDisplayStringWithoutCurrency);
const appliedDisplays = getAppliedDisplays(searchAdvancedFiltersForm, currentSearchQueryJSON, translate);
@@ -184,6 +187,7 @@ function SearchSavePage() {
formID={ONYXKEYS.FORMS.SEARCH_SAVE_FORM}
submitButtonText={translate('search.saveView')}
onSubmit={onSaveSearch}
+ validate={validate}
style={[styles.mh5, styles.flex1]}
enabledWhenOffline
shouldHideFixErrorsAlert
@@ -193,8 +197,6 @@ function SearchSavePage() {
InputComponent={TextInput}
inputID={INPUT_IDS.NAME}
ref={inputCallbackRef}
- value={name}
- onChangeText={setName}
placeholder={translate('common.name')}
accessibilityLabel={translate('common.name')}
role={CONST.ROLE.PRESENTATION}