diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b9964d59b4b1..fbc7465bc023 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -423,6 +423,9 @@ const ONYXKEYS = { /** Stores the information about the saved searches */ SAVED_SEARCHES: 'nvp_savedSearches', + /** Stores the information about the recent searches */ + RECENT_SEARCHES: 'nvp_recentSearches', + /** Stores recently used currencies */ RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies', @@ -850,6 +853,7 @@ type OnyxValuesMapping = { // ONYXKEYS.NVP_TRYNEWDOT is HybridApp onboarding data [ONYXKEYS.NVP_TRYNEWDOT]: OnyxTypes.TryNewDot; + [ONYXKEYS.RECENT_SEARCHES]: Record; [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 5fff92213969..f50540346e6d 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -69,12 +69,15 @@ function HeaderWrapper({icon, children, text, isCannedQuery}: HeaderWrapperProps ) : ( {}} + updateSearch={() => {}} disabled isFullWidth wrapperStyle={[styles.searchRouterInputResults, styles.br2]} wrapperFocusedStyle={styles.searchRouterInputResultsFocused} - defaultValue={text} rightComponent={children} + routerListRef={undefined} /> )} diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index dfe2cbbe16c6..b3f147b7ac28 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,71 +1,167 @@ +import {useNavigationState} from '@react-navigation/native'; import debounce from 'lodash/debounce'; -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; +import {useOptionsList} from '@components/OptionListContextProvider'; import type {SearchQueryJSON} from '@components/Search/types'; +import type {SelectionListHandle} from '@components/SelectionList/types'; +import useDebouncedState from '@hooks/useDebouncedState'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import Log from '@libs/Log'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import type {OptionData} from '@libs/ReportUtils'; import * as SearchUtils from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; +import variables from '@styles/variables'; +import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {useSearchRouterContext} from './SearchRouterContext'; import SearchRouterInput from './SearchRouterInput'; +import SearchRouterList from './SearchRouterList'; const SEARCH_DEBOUNCE_DELAY = 150; function SearchRouter() { const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); const {isSmallScreenWidth} = useResponsiveLayout(); const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext(); + const listRef = useRef(null); + const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500); const [userSearchQuery, setUserSearchQuery] = useState(undefined); - - const clearUserQuery = () => { - setUserSearchQuery(undefined); - }; - - const onSearchChange = debounce((userQuery: string) => { - if (!userQuery) { - clearUserQuery(); - return; + const contextualReportID = useNavigationState, string | undefined>((state) => { + return state?.routes.at(-1)?.params?.reportID; + }); + const sortedRecentSearches = useMemo(() => { + return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + }, [recentSearches]); + + const {options, areOptionsInitialized} = useOptionsList(); + const searchOptions = useMemo(() => { + if (!areOptionsInitialized) { + return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null, categoryOptions: [], tagOptions: [], taxRatesOptions: []}; + } + return OptionsListUtils.getSearchOptions(options, '', betas ?? []); + }, [areOptionsInitialized, betas, options]); + + const filteredOptions = useMemo(() => { + if (debouncedInputValue.trim() === '') { + return { + recentReports: [], + personalDetails: [], + userToInvite: null, + }; } - const queryJSON = SearchUtils.buildSearchQueryJSON(userQuery); + const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); - if (queryJSON) { - // eslint-disable-next-line - console.log('parsedQuery', queryJSON); + return { + recentReports: newOptions.recentReports, + personalDetails: newOptions.personalDetails, + userToInvite: newOptions.userToInvite, + }; + }, [debouncedInputValue, searchOptions]); - setUserSearchQuery(queryJSON); - } else { - // Handle query parsing error + const recentReports: OptionData[] = useMemo(() => { + const currentSearchOptions = debouncedInputValue === '' ? searchOptions : filteredOptions; + const reports: OptionData[] = [...currentSearchOptions.recentReports, ...currentSearchOptions.personalDetails]; + if (currentSearchOptions.userToInvite) { + reports.push(currentSearchOptions.userToInvite); } - }, SEARCH_DEBOUNCE_DELAY); + return reports.slice(0, 10); + }, [debouncedInputValue, filteredOptions, searchOptions]); - const onSearchSubmit = useCallback(() => { - if (!userSearchQuery) { + useEffect(() => { + Report.searchInServer(debouncedInputValue.trim()); + }, [debouncedInputValue]); + + useEffect(() => { + if (!textInputValue && isSearchRouterDisplayed) { return; } + listRef.current?.updateAndScrollToFocusedIndex(0); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSearchRouterDisplayed]); - closeSearchRouter(); + const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; - const query = SearchUtils.buildSearchQueryString(userSearchQuery); - Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); + const clearUserQuery = () => { + setTextInputValue(''); + setUserSearchQuery(undefined); + }; + const onSearchChange = useMemo( + // eslint-disable-next-line react-compiler/react-compiler + () => + debounce((userQuery: string) => { + if (!userQuery) { + clearUserQuery(); + listRef.current?.updateAndScrollToFocusedIndex(-1); + return; + } + listRef.current?.updateAndScrollToFocusedIndex(0); + const queryJSON = SearchUtils.buildSearchQueryJSON(userQuery); + + if (queryJSON) { + setUserSearchQuery(queryJSON); + } else { + Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, userQuery, false); + } + }, SEARCH_DEBOUNCE_DELAY), + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const updateUserSearchQuery = (newSearchQuery: string) => { + setTextInputValue(newSearchQuery); + onSearchChange(newSearchQuery); + }; + + const closeAndClearRouter = useCallback(() => { + closeSearchRouter(); clearUserQuery(); - }, [closeSearchRouter, userSearchQuery]); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [closeSearchRouter]); + + const onSearchSubmit = useCallback( + (query: SearchQueryJSON | undefined) => { + if (!query) { + return; + } + closeSearchRouter(); + const queryString = SearchUtils.buildSearchQueryString(query); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString})); + clearUserQuery(); + }, + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + [closeSearchRouter], + ); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => { closeSearchRouter(); clearUserQuery(); }); - const modalType = isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.CENTERED : CONST.MODAL.MODAL_TYPE.POPOVER; - const isFullWidth = isSmallScreenWidth; + const modalType = isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.POPOVER; + const modalWidth = isSmallScreenWidth ? styles.w100 : {width: variables.popoverWidth}; return ( - + + {isSmallScreenWidth && ( + closeSearchRouter()} + /> + )} + diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx index 046386416259..460ff37c88b2 100644 --- a/src/components/Search/SearchRouter/SearchRouterInput.tsx +++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx @@ -1,25 +1,30 @@ import React, {useState} from 'react'; -import type {ReactNode} from 'react'; +import type {ReactNode, RefObject} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; +import type {SelectionListHandle} from '@components/SelectionList/types'; import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; type SearchRouterInputProps = { - /** Callback triggered when the input text changes */ - onChange?: (searchTerm: string) => void; + /** Value of TextInput */ + value: string; - /** Callback invoked when the user submits the input */ - onSubmit?: () => void; + /** Setter to TextInput value */ + setValue: (searchTerm: string) => void; + + /** Callback to update search in SearchRouter */ + updateSearch: (searchTerm: string) => void; + + /** SearchRouterList ref for managing TextInput and SearchRouterList focus */ + routerListRef?: RefObject; /** Whether the input is full width */ isFullWidth: boolean; - /** Default value for text input */ - defaultValue?: string; - /** Whether the input is disabled */ disabled?: boolean; @@ -31,37 +36,58 @@ type SearchRouterInputProps = { /** Component to be displayed on the right */ rightComponent?: ReactNode; + + /** Whether the search reports API call is running */ + isSearchingForReports?: boolean; }; -function SearchRouterInput({isFullWidth, onChange, onSubmit, defaultValue = '', disabled = false, wrapperStyle, wrapperFocusedStyle, rightComponent}: SearchRouterInputProps) { +function SearchRouterInput({ + value, + setValue, + updateSearch, + routerListRef, + isFullWidth, + disabled = false, + wrapperStyle, + wrapperFocusedStyle, + rightComponent, + isSearchingForReports, +}: SearchRouterInputProps) { const styles = useThemeStyles(); - - const [value, setValue] = useState(defaultValue); + const {translate} = useLocalize(); const [isFocused, setIsFocused] = useState(false); const onChangeText = (text: string) => { setValue(text); - onChange?.(text); + updateSearch(text); }; const inputWidth = isFullWidth ? styles.w100 : {width: variables.popoverWidth}; return ( - + setIsFocused(true)} - onBlur={() => setIsFocused(false)} + onFocus={() => { + setIsFocused(true); + routerListRef?.current?.updateExternalTextInputFocus(true); + }} + onBlur={() => { + setIsFocused(false); + routerListRef?.current?.updateExternalTextInputFocus(false); + }} + isLoading={!!isSearchingForReports} /> {rightComponent && {rightComponent}} diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx new file mode 100644 index 000000000000..96c11b2fa353 --- /dev/null +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -0,0 +1,184 @@ +import React, {forwardRef, useCallback} from 'react'; +import type {ForwardedRef} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import * as Expensicons from '@components/Icon/Expensicons'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import type {SearchQueryJSON} from '@components/Search/types'; +import SelectionList from '@components/SelectionList'; +import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem'; +import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem'; +import type {SectionListDataType, SelectionListHandle, UserListItemProps} from '@components/SelectionList/types'; +import UserListItem from '@components/SelectionList/UserListItem'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import {getAllTaxRates} from '@libs/PolicyUtils'; +import type {OptionData} from '@libs/ReportUtils'; +import * as SearchUtils from '@libs/SearchUtils'; +import * as Report from '@userActions/Report'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +type ItemWithQuery = { + query: string; +}; + +type SearchRouterListProps = { + /** currentQuery value computed coming from parsed TextInput value */ + currentQuery: SearchQueryJSON | undefined; + + /** Recent searches */ + recentSearches: ItemWithQuery[] | undefined; + + /** Recent reports */ + recentReports: OptionData[]; + + /** Callback to submit query when selecting a list item */ + onSearchSubmit: (query: SearchQueryJSON | undefined) => void; + + /** Context present when opening SearchRouter from a report, invoice or workspace page */ + reportForContextualSearch?: OptionData; + + /** Callback to update search query when selecting contextual suggestion */ + updateUserSearchQuery: (newSearchQuery: string) => void; + + /** Callback to close and clear SearchRouter */ + closeAndClearRouter: () => void; +}; + +function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem { + if ('singleIcon' in item && item.singleIcon && 'query' in item && item.query) { + return true; + } + return false; +} + +function isSearchQueryListItem(listItem: UserListItemProps | SearchQueryListItemProps): listItem is SearchQueryListItemProps { + return isSearchQueryItem(listItem.item); +} + +function SearchRouterItem(props: UserListItemProps | SearchQueryListItemProps) { + const styles = useThemeStyles(); + + if (isSearchQueryListItem(props)) { + return ( + + ); + } + return ( + + ); +} + +function SearchRouterList( + {currentQuery, reportForContextualSearch, recentSearches, recentReports, onSearchSubmit, updateUserSearchQuery, closeAndClearRouter}: SearchRouterListProps, + ref: ForwardedRef, +) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useResponsiveLayout(); + + const personalDetails = usePersonalDetails(); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const taxRates = getAllTaxRates(); + const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const contextualQuery = `in:${reportForContextualSearch?.reportID}`; + const sections: Array> = []; + + if (currentQuery?.inputQuery) { + sections.push({ + data: [ + { + text: currentQuery?.inputQuery, + singleIcon: Expensicons.MagnifyingGlass, + query: currentQuery?.inputQuery, + itemStyle: styles.activeComponentBG, + keyForList: 'findItem', + }, + ], + }); + } + + if (reportForContextualSearch && !currentQuery?.inputQuery?.includes(contextualQuery)) { + sections.push({ + data: [ + { + text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`, + singleIcon: Expensicons.MagnifyingGlass, + query: SearchUtils.getContextualSuggestionQuery(reportForContextualSearch.reportID), + itemStyle: styles.activeComponentBG, + keyForList: 'contextualSearch', + isContextualSearchItem: true, + }, + ], + }); + } + + const recentSearchesData = recentSearches?.map(({query}) => { + const searchQueryJSON = SearchUtils.buildSearchQueryJSON(query); + return { + text: searchQueryJSON ? SearchUtils.getSearchHeaderTitle(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query, + singleIcon: Expensicons.History, + query, + keyForList: query, + }; + }); + + if (!currentQuery?.inputQuery && recentSearchesData && recentSearchesData.length > 0) { + sections.push({title: translate('search.recentSearches'), data: recentSearchesData}); + } + + const styledRecentReports = recentReports.map((item) => ({...item, pressableStyle: styles.br2})); + sections.push({title: translate('search.recentChats'), data: styledRecentReports}); + + const onSelectRow = useCallback( + (item: OptionData | SearchQueryItem) => { + if (isSearchQueryItem(item)) { + if (item.isContextualSearchItem) { + // Handle selection of "Contextual search suggestion" + updateUserSearchQuery(`${item?.query} ${currentQuery?.inputQuery ?? ''}`); + return; + } + + // Handle selection of "Recent search" + if (!item?.query) { + return; + } + onSearchSubmit(SearchUtils.buildSearchQueryJSON(item?.query)); + } + + // Handle selection of "Recent chat" + closeAndClearRouter(); + if ('reportID' in item && item?.reportID) { + Navigation.closeAndNavigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID)); + } else if ('login' in item) { + Report.navigateToAndOpenReport(item?.login ? [item.login] : []); + } + }, + [closeAndClearRouter, onSearchSubmit, currentQuery, updateUserSearchQuery], + ); + + return ( + + sections={sections} + onSelectRow={onSelectRow} + ListItem={SearchRouterItem} + containerStyle={[styles.mh100]} + sectionListStyle={[isSmallScreenWidth ? styles.ph5 : styles.ph2, styles.pb2]} + ref={ref} + /> + ); +} + +export default forwardRef(SearchRouterList); +export {SearchRouterItem}; +export type {ItemWithQuery}; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 36b2cf873416..ae5168b7b94e 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -619,7 +619,22 @@ function BaseSelectionList( [flattenedSections.allOptions, setFocusedIndex, updateAndScrollToFocusedIndex], ); - useImperativeHandle(ref, () => ({scrollAndHighlightItem, clearInputAfterSelect, scrollToIndex}), [scrollAndHighlightItem, clearInputAfterSelect, scrollToIndex]); + /** + * Handles isTextInputFocusedRef value when using external TextInput, so external TextInput is not defocused when typing in it. + * + * @param isTextInputFocused - Is external TextInput focused. + */ + const updateExternalTextInputFocus = useCallback((isTextInputFocused: boolean) => { + isTextInputFocusedRef.current = isTextInputFocused; + }, []); + + useImperativeHandle(ref, () => ({scrollAndHighlightItem, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, scrollToIndex}), [ + scrollAndHighlightItem, + clearInputAfterSelect, + updateAndScrollToFocusedIndex, + updateExternalTextInputFocus, + scrollToIndex, + ]); /** Selects row when pressing Enter */ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx new file mode 100644 index 000000000000..369f527cdeba --- /dev/null +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import BaseListItem from '@components/SelectionList/BaseListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type SearchQueryItem = ListItem & { + singleIcon?: IconAsset; + query?: string; + isContextualSearchItem?: boolean; +}; + +type SearchQueryListItemProps = { + item: SearchQueryItem; + isFocused?: boolean; + showTooltip: boolean; + onSelectRow: (item: SearchQueryItem) => void; + onFocus?: () => void; + shouldSyncFocus?: boolean; +}; + +function SearchQueryListItem({item, isFocused, showTooltip, onSelectRow, onFocus, shouldSyncFocus}: SearchQueryListItemProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + + return ( + + <> + {item.singleIcon && ( + + )} + + + {item.alternateText && ( + + )} + + + + ); +} + +SearchQueryListItem.displayName = 'SearchQueryListItem'; + +export default SearchQueryListItem; +export type {SearchQueryItem, SearchQueryListItemProps}; diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index e2bad86c8d97..df4ea6fd0cd2 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -38,6 +38,7 @@ function UserListItem({ rightHandSideComponent, onFocus, shouldSyncFocus, + wrapperStyle, pressableStyle, }: UserListItemProps) { const styles = useThemeStyles(); @@ -60,7 +61,7 @@ function UserListItem({ return ( ({ rightHandSideComponent={rightHandSideComponent} errors={item.errors} pendingAction={item.pendingAction} - pressableStyle={pressableStyle} + pressableStyle={[isFocused && styles.sidebarLinkActive, pressableStyle]} FooterComponent={ item.invitedSecondaryLogin ? ( diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 3ac48519c9c6..6e1362bcb632 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -12,6 +12,7 @@ import type { ViewStyle, } from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; +import type {SearchRouterItem} from '@components/Search/SearchRouter/SearchRouterList'; import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; // eslint-disable-next-line no-restricted-imports import type CursorStyles from '@styles/utils/cursor/types'; @@ -25,6 +26,7 @@ import type ChatListItem from './ChatListItem'; import type InviteMemberListItem from './InviteMemberListItem'; import type RadioListItem from './RadioListItem'; import type ReportListItem from './Search/ReportListItem'; +import type SearchQueryListItem from './Search/SearchQueryListItem'; import type TransactionListItem from './Search/TransactionListItem'; import type TableListItem from './TableListItem'; import type UserListItem from './UserListItem'; @@ -182,6 +184,9 @@ type ListItem = { /** Determines whether the newly added item should animate in / highlight */ shouldAnimateInHighlight?: boolean; + + /** The style to override the default appearance */ + itemStyle?: StyleProp; }; type TransactionListItemType = ListItem & @@ -329,7 +334,9 @@ type ValidListItem = | typeof InviteMemberListItem | typeof TransactionListItem | typeof ReportListItem - | typeof ChatListItem; + | typeof ChatListItem + | typeof SearchQueryListItem + | typeof SearchRouterItem; type Section = { /** Title of the section */ @@ -574,6 +581,8 @@ type SelectionListHandle = { scrollAndHighlightItem?: (items: string[], timeout: number) => void; clearInputAfterSelect?: () => void; scrollToIndex: (index: number, animated?: boolean) => void; + updateAndScrollToFocusedIndex: (newFocusedIndex: number) => void; + updateExternalTextInputFocus: (isTextInputFocused: boolean) => void; }; type ItemLayout = { diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index a03e9dbb9aa2..93755cf0ce40 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -66,6 +66,7 @@ function BaseTextInput( prefixContainerStyle = [], prefixStyle = [], contentWidth, + loadingSpinnerStyle, ...props }: BaseTextInputProps, ref: ForwardedRef, @@ -379,7 +380,7 @@ function BaseTextInput( )} {!!inputProps.secureTextEntry && ( diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index bfc3ab213dd0..0bfe3a46365d 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -72,6 +72,7 @@ function BaseTextInput( suffixContainerStyle = [], suffixStyle = [], contentWidth, + loadingSpinnerStyle, ...inputProps }: BaseTextInputProps, ref: ForwardedRef, @@ -425,7 +426,7 @@ function BaseTextInput( )} {!!inputProps.secureTextEntry && ( diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 92fe6b7dbbfc..c9844e33d594 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -137,6 +137,9 @@ type CustomBaseTextInputProps = { /** Style for the suffix container */ suffixContainerStyle?: StyleProp; + /** Style for the loading spinner */ + loadingSpinnerStyle?: StyleProp; + /** The width of inner content */ contentWidth?: number; }; diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts index 4705e2b69a68..e0211adcdba2 100644 --- a/src/components/TextWithTooltip/types.ts +++ b/src/components/TextWithTooltip/types.ts @@ -5,7 +5,7 @@ type TextWithTooltipProps = { text: string; /** Whether to show the tooltip text */ - shouldShowTooltip: boolean; + shouldShowTooltip?: boolean; /** Additional styles */ style?: StyleProp; diff --git a/src/languages/en.ts b/src/languages/en.ts index e72c6a422ee3..18dcbe35c9f6 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4143,6 +4143,10 @@ const translations = { past: 'Past', }, expenseType: 'Expense type', + recentSearches: 'Recent searches', + recentChats: 'Recent chats', + searchIn: 'Search in', + searchPlaceholder: 'Search for something', }, genericErrorPage: { title: 'Uh-oh, something went wrong!', diff --git a/src/languages/es.ts b/src/languages/es.ts index b2362001bbb9..8e16c03a91d3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4187,6 +4187,10 @@ const translations = { past: 'Anterior', }, expenseType: 'Tipo de gasto', + recentSearches: 'Búsquedas recientes', + recentChats: 'Chats recientes', + searchIn: 'Buscar en', + searchPlaceholder: 'Busca algo', }, genericErrorPage: { title: '¡Oh-oh, algo salió mal!', diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 09e4e20668cd..47cee2c7c2b4 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -843,11 +843,16 @@ function isCannedSearchQuery(queryJSON: SearchQueryJSON) { return !queryJSON.filters; } +function getContextualSuggestionQuery(reportID: string) { + return `type:chat in:${reportID}`; +} + function isCorrectSearchUserName(displayName?: string) { return displayName && displayName.toUpperCase() !== CONST.REPORT.OWNER_EMAIL_FAKE; } export { + getContextualSuggestionQuery, buildQueryStringFromFilterFormValues, buildSearchQueryJSON, buildSearchQueryString, diff --git a/src/styles/index.ts b/src/styles/index.ts index aca67b37ed9f..d602384cdecf 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3622,10 +3622,11 @@ const styles = (theme: ThemeColors) => lineHeight: 16, }, - searchRouterInput: { + searchRouterTextInputContainer: { borderRadius: variables.componentBorderRadiusSmall, - borderWidth: 2, - borderColor: theme.borderFocus, + borderWidth: 1, + borderBottomWidth: 1, + paddingHorizontal: 8, }, searchRouterInputResults: { @@ -4697,6 +4698,14 @@ const styles = (theme: ThemeColors) => borderRadius: 8, }, + searchQueryListItemStyle: { + alignItems: 'center', + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 12, + borderRadius: 8, + }, + selectionListStickyHeader: { backgroundColor: theme.appBG, }, diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index f4be70391eb5..ed4651bcf2e0 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -37,6 +37,10 @@ export default { maxHeight: '100%', }, + mh85vh: { + maxHeight: '85vh', + }, + mnh100: { minHeight: '100%', }, diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 993a6dba1628..c2008d8a68f0 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -15,6 +15,10 @@ export default { margin: 8, }, + m3: { + margin: 12, + }, + m4: { margin: 16, }, diff --git a/src/types/onyx/RecentSearch.ts b/src/types/onyx/RecentSearch.ts new file mode 100644 index 000000000000..738d57956e5c --- /dev/null +++ b/src/types/onyx/RecentSearch.ts @@ -0,0 +1,13 @@ +/** + * Model of a single recent search + */ +type RecentSearchItem = { + /** Query string for the recent search */ + query: string; + + /** Timestamp of recent search */ + timestamp: string; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {RecentSearchItem}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 420ccf884f91..abad91f78c77 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -64,6 +64,7 @@ import type QuickAction from './QuickAction'; import type RecentlyUsedCategories from './RecentlyUsedCategories'; import type RecentlyUsedReportFields from './RecentlyUsedReportFields'; import type RecentlyUsedTags from './RecentlyUsedTags'; +import type {RecentSearchItem} from './RecentSearch'; import type RecentWaypoint from './RecentWaypoint'; import type ReimbursementAccount from './ReimbursementAccount'; import type Report from './Report'; @@ -230,6 +231,7 @@ export type { WorkspaceTooltip, CardFeeds, SaveSearch, + RecentSearchItem, ImportedSpreadsheet, ValidateMagicCodeAction, };