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}