diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 54ba7d8d6a2c..ec6b616f5ab6 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1574,6 +1574,7 @@ const ROUTES = { getRoute: ({name, jsonQuery}: {name: string; jsonQuery: SearchQueryString}) => `search/saved-search/rename?name=${name}&q=${encodeURIComponent(jsonQuery)}` as const, }, SEARCH_COLUMNS: 'search/columns', + SEARCH_ADVANCED_FILTERS_DESCRIBE: 'search/filters/describe', SEARCH_ADVANCED_FILTERS: 'search/filters', SEARCH_ADVANCED_FILTERS_CONTENT: { route: 'search/filters/:filterKey', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 687ed28474cf..bc0d4c6f0501 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -49,6 +49,7 @@ const SCREENS = { COLUMNS_RHP: 'Search_Columns_RHP', ADVANCED_FILTERS_RHP: 'Search_Advanced_Filters_RHP', ADVANCED_FILTERS_CONTENT_RHP: 'Search_Advanced_Filters_Content_RHP', + ADVANCED_FILTERS_DESCRIBE_RHP: 'Search_Advanced_Filters_Describe_RHP', SAVED_SEARCH_RENAME_RHP: 'Search_Saved_Search_Rename_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', TRANSACTION_HOLD_REASON_SEARCH: 'Search_Transaction_Hold_Reason_Search', diff --git a/src/components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent.tsx new file mode 100644 index 000000000000..191a9e6cd4b5 --- /dev/null +++ b/src/components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent.tsx @@ -0,0 +1,106 @@ +/** + * Renders the natural-language ("Describe your search") input that parses a plain-English + * query into a structured search URL and navigates the user to the results. + */ +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {parseExpenseFilters} from '@libs/actions/Search'; +import {getFilterFromQuery} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type SearchNLFilterContentProps = { + /** Called with the destination route once the query is successfully parsed */ + onSuccess: (route: Route) => void; + + /** Optional style override for the input container */ + containerStyle?: StyleProp; + + /** Optional style override for the submit button container */ + buttonContainerStyle?: StyleProp; +}; + +function SearchNLFilterContent({onSuccess, containerStyle, buttonContainerStyle}: SearchNLFilterContentProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [nlQuery, setNlQuery] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const {currentSearchQueryJSON} = useSearchQueryContext(); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + + const handleSubmit = () => { + const trimmedQuery = nlQuery.trim(); + if (!trimmedQuery) { + return; + } + setIsLoading(true); + setErrorMessage(''); + const queryPolicyID = getFilterFromQuery(currentSearchQueryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID).value?.at(0); + const policyID = queryPolicyID ?? activePolicyID; + parseExpenseFilters(trimmedQuery, policyID) + .then((result) => { + setIsLoading(false); + if (!result) { + return; + } + if (result.success) { + const searchQuery = new URL(result.searchURL).searchParams.get('q') ?? ''; + onSuccess(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); + } else { + setErrorMessage(result.message); + } + }) + .catch(() => { + setIsLoading(false); + setErrorMessage(translate('common.genericErrorMessage')); + }); + }; + + return ( + <> + + {translate('search.filters.describeSearch.description')} + + + + + ); +} + +export default SearchNLFilterContent; diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx index eadcc3d4e5c9..438c38c0cbc2 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx @@ -1,19 +1,32 @@ +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; import SafeTriangle from '@components/SafeTriangle'; import FilterList from '@components/Search/FilterComponents/AdvancedFilters/FilterList'; import SearchAdvancedFiltersContent from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; +import SearchNLFilterContent from '@components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; import type {SearchQueryJSON} from '@components/Search/types'; +import SpacerView from '@components/SpacerView'; +import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import getButtonState from '@libs/getButtonState'; +import Navigation from '@libs/Navigation/Navigation'; import {getFilterNegatableValue} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import React, {useRef, useState} from 'react'; import {View} from 'react-native'; @@ -25,46 +38,110 @@ import ReportFieldFilterContentPopupWrapper from './ReportFieldFilterContentPopu import TextInputFilterContentPopupWrapper from './TextInputFilterContentPopupWrapper'; type SearchAdvancedFiltersPopupProps = { + /** Query JSON to build the current filter state */ queryJSON: SearchQueryJSON; + + /** Closes the filters popover overlay */ + closeOverlay: () => void; }; -function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps) { +function SearchAdvancedFiltersPopup({queryJSON, closeOverlay}: SearchAdvancedFiltersPopupProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); const [selectedFilter, setSelectedFilter] = useState(CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE); + const [isDescribeMode, setIsDescribeMode] = useState(false); const filterContentRef = useRef(null); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const icons = useMemoizedLazyExpensifyIcons(['Sparkles', 'ArrowRight']); const {updateFilterQueryParams} = useUpdateFilterQuery(queryJSON); + const selectFilter = (key: SearchFilter['key']) => { + setIsDescribeMode(false); + setSelectedFilter(key); + }; + + const getDescribeButtonBackground = (pressed: boolean) => { + if (pressed) { + return styles.buttonHoveredBG; + } + if (isDescribeMode) { + return styles.hoveredComponentBG; + } + return undefined; + }; + + const handleNLSuccess = (route: Route) => { + closeOverlay(); + Navigation.navigate(route); + }; + return ( - + + [styles.typeFilterMenu, getDescribeButtonBackground(pressed)]} + accessible + accessibilityLabel={translate('search.filters.describeSearch.title')} + role={CONST.ROLE.BUTTON} + sentryLabel="SearchAdvancedFiltersPopup-DescribeSearch" + onHoverIn={() => setIsDescribeMode(true)} + onPress={() => setIsDescribeMode(true)} + > + {({pressed}) => ( + <> + + {translate('search.filters.describeSearch.title')} + + + )} + + + + - + {isDescribeMode ? ( + + ) : ( + + )} diff --git a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx index e012742c704d..6f300a32e0e8 100644 --- a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx @@ -92,7 +92,12 @@ function SearchAdvancedFiltersButton({queryJSON}: SearchAdvancedFiltersButtonPro /> ); - const filtersPopup = () => ; + const filtersPopup = ({closeOverlay}: {closeOverlay: () => void}) => ( + + ); return ( `${name} es ${value}`, + describeSearch: { + title: 'Describe tu búsqueda', + inputLabel: 'Tu búsqueda', + description: 'Usa un inglés sencillo para describir lo que buscas, como "comidas de más de 50 $ el mes pasado".', + buttonText: 'Crear filtros', + }, filterType: {label: 'Tipo de filtro', has: {positive: 'tiene', negative: 'no tiene'}, is: {positive: 'es', negative: 'no es'}}, }, chartTitles: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a29e0d3cd1ca..cbb50357a1d9 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -9117,6 +9117,12 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Payer', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exporter', }, + describeSearch: { + title: 'Décrivez votre recherche', + inputLabel: 'Votre recherche', + description: 'Décrivez en anglais simple ce que vous recherchez, par exemple « repas de plus de 50 $ le mois dernier ».', + buttonText: 'Créer des filtres', + }, filterType: {label: 'Type de filtre', has: {positive: 'a', negative: 'n’a pas'}, is: {positive: 'est', negative: 'n’est pas'}}, }, display: { diff --git a/src/languages/it.ts b/src/languages/it.ts index e53c4ba792c4..459eb07f6a24 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -9053,6 +9053,12 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Paga', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Esporta', }, + describeSearch: { + title: 'Descrivi la tua ricerca', + inputLabel: 'La tua ricerca', + description: 'Usa un inglese semplice per descrivere cosa stai cercando, ad esempio: "pasti oltre 50 $ lo scorso mese".', + buttonText: 'Crea filtri', + }, filterType: {label: 'Tipo di filtro', has: {positive: 'ha', negative: 'non ha'}, is: {positive: 'è', negative: 'non è'}}, }, display: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 6e9882c19cec..00b0a7634e46 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8941,6 +8941,12 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.PAY]: '支払う', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'エクスポート', }, + describeSearch: { + title: '検索内容を入力してください', + inputLabel: '検索結果', + description: '「先月50ドルを超えた食事代」のように、探したい内容を平易な英語で入力してください。', + buttonText: 'フィルターを作成', + }, filterType: {label: 'フィルタータイプ', has: {positive: '持っています', negative: '持っていません'}, is: {positive: 'は', negative: 'ではありません'}}, }, display: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c989dbe5fdf9..9421f3c29213 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -9025,6 +9025,12 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Betalen', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exporteren', }, + describeSearch: { + title: 'Beschrijf je zoekopdracht', + inputLabel: 'Je zoekopdracht', + description: 'Gebruik eenvoudig Engels om te beschrijven wat je zoekt, zoals: "maaltijden boven de $50 van afgelopen maand."', + buttonText: 'Filters maken', + }, filterType: {label: 'Filtertype', has: {positive: 'heeft', negative: 'heeft niet'}, is: {positive: 'is', negative: 'is niet'}}, }, display: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index b19a2e67ac02..9ae3d3761105 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9002,6 +9002,12 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Zapłać', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Eksportuj', }, + describeSearch: { + title: 'Opisz swoje wyszukiwanie', + inputLabel: 'Twoje wyszukiwanie', + description: 'Użyj prostego angielskiego, żeby opisać, czego szukasz, na przykład „meals over $50 last month“.', + buttonText: 'Utwórz filtry', + }, filterType: {label: 'Typ filtra', has: {positive: 'ma', negative: 'nie ma'}, is: {positive: 'jest', negative: 'nie jest'}}, }, display: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d0764efc5e95..41f656420a8b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -9020,6 +9020,12 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Pagar', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exportar', }, + describeSearch: { + title: 'Descreva sua pesquisa', + inputLabel: 'Sua pesquisa', + description: 'Use inglês simples para descrever o que você procura, como "refeições acima de US$ 50 no mês passado."', + buttonText: 'Criar filtros', + }, filterType: {label: 'Tipo de filtro', has: {positive: 'tem', negative: 'não tem'}, is: {positive: 'é', negative: 'não é'}}, }, display: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d3ea8e3c596c..395fa5b743ba 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8706,6 +8706,12 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.PAY]: '支付', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: '导出', }, + describeSearch: { + title: '描述你的搜索', + inputLabel: '你的搜索', + description: '请用简单的英文描述你要查找的内容,例如:"meals over $50 last month(上个月超过 50 美元的餐饮)"。', + buttonText: '创建筛选条件', + }, filterType: {label: '筛选类型', has: {positive: '有', negative: '没有'}, is: {positive: '是', negative: '不是'}}, }, display: { diff --git a/src/libs/API/parameters/ParseExpenseFiltersParams.ts b/src/libs/API/parameters/ParseExpenseFiltersParams.ts new file mode 100644 index 000000000000..d943e09ee35a --- /dev/null +++ b/src/libs/API/parameters/ParseExpenseFiltersParams.ts @@ -0,0 +1,7 @@ +type ParseExpenseFiltersParams = { + nlQuery: string; + policyID?: string; + today?: string; +}; + +export default ParseExpenseFiltersParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 25a07189b2f5..e893c1b7d459 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -99,6 +99,7 @@ export type {default as OpenRoomMembersPageParams} from './OpenRoomMembersPagePa export type {default as OpenPlaidCompanyCardLoginParams} from './OpenPlaidCompanyCardLoginParams'; export type {default as OpenPolicyReceiptPartnersPageParams} from './OpenPolicyReceiptPartnersPageParams'; export type {default as OpenPolicyHRPageParams} from './OpenPolicyHRPageParams'; +export type {default as ParseExpenseFiltersParams} from './ParseExpenseFiltersParams'; export type {default as PaymentCardParams} from './PaymentCardParams'; export type {default as AddPersonalPlaidCardParams} from './AddPersonalPlaidCardParams'; export type {default as PusherPingParams} from './PusherPingParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 6964eb3ebc4f..e62fdb5340fc 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1618,6 +1618,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { LINK_CARD_FEED_TO_POLICY: 'LinkCardFeedToPolicy', REVEAL_CARD_PIN: 'RevealCardPIN', CHANGE_CARD_PIN: 'ChangeCardPIN', + PARSE_EXPENSE_FILTERS: 'ParseExpenseFilters', } as const; type SideEffectRequestCommand = ValueOf; @@ -1666,6 +1667,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.LINK_CARD_FEED_TO_POLICY]: Parameters.LinkCardToPolicyParams; [SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_CARD_PIN]: Parameters.RevealCardPINParams; [SIDE_EFFECT_REQUEST_COMMANDS.CHANGE_CARD_PIN]: Parameters.ChangeCardPINParams; + [SIDE_EFFECT_REQUEST_COMMANDS.PARSE_EXPENSE_FILTERS]: Parameters.ParseExpenseFiltersParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index f0dcd676a907..ed9bdc5aac2f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1236,6 +1236,7 @@ const SearchReportActionsModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchAdvancedFiltersPage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_CONTENT_RHP]: () => require('../../../../pages/Search/SearchAdvancedFiltersContentPage').default, + [SCREENS.SEARCH.ADVANCED_FILTERS_DESCRIBE_RHP]: () => require('@pages/Search/SearchNLFilterPage').default, }); const SearchSavedSearchModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index efe8cc597285..b73f28bb3065 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1973,6 +1973,7 @@ const config: LinkingOptions['config'] = { screens: { [SCREENS.SEARCH.ADVANCED_FILTERS_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS, [SCREENS.SEARCH.ADVANCED_FILTERS_CONTENT_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_CONTENT.route, + [SCREENS.SEARCH.ADVANCED_FILTERS_DESCRIBE_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_DESCRIBE, }, }, [SCREENS.RIGHT_MODAL.SEARCH_SAVE]: ROUTES.SEARCH_SAVE, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 2a6e243ec355..0daa08c81f56 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -3313,6 +3313,7 @@ type SearchFullscreenNavigatorParamList = { type SearchAdvancedFiltersParamList = { [SCREENS.SEARCH.ADVANCED_FILTERS_RHP]: Record; + [SCREENS.SEARCH.ADVANCED_FILTERS_DESCRIBE_RHP]: Record; [SCREENS.SEARCH.ADVANCED_FILTERS_CONTENT_RHP]: { filterKey: string; }; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 23a4f8e92a9e..df86cf5dbf5e 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -20,7 +20,7 @@ import type { ReportExportParams, SubmitReportParams, } from '@libs/API/parameters'; -import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getCommandURL} from '@libs/ApiUtils'; import deferModalPresentationAfterPopoverDismiss from '@libs/deferModalPresentationAfterPopoverDismiss'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; @@ -928,6 +928,21 @@ function openSearchCardFiltersPage() { read(READ_COMMANDS.OPEN_SEARCH_CARD_FILTERS_PAGE, null, {finallyData}); } +type ParseExpenseFiltersResult = {success: true; searchURL: string; humanReadableSummary: string} | {success: false; message: string}; + +function parseExpenseFilters(nlQuery: string, policyID?: string): Promise { + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.PARSE_EXPENSE_FILTERS, {nlQuery, policyID, today}) + .then((response) => { + if (response?.success === true && response.searchURL) { + return {success: true, searchURL: response.searchURL, humanReadableSummary: response.humanReadableSummary ?? ''} as const; + } + return {success: false, message: response?.message ?? ''} as const; + }) + .catch(() => ({success: false, message: ''}) as const); +} + function openSearchCategoryFiltersPage() { const optimisticData: Array> = [ { @@ -2191,6 +2206,7 @@ export { getPayMoneyOnSearchInvoiceParams, handlePreventSearchAPI, openSearchCardFiltersPage, + parseExpenseFilters, openSearchCategoryFiltersPage, getPolicyFromSearchSnapshot, getReportFromSearchSnapshot, diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx index 5eea2db68804..f950c9f239a9 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx @@ -1,8 +1,11 @@ import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import FilterList from '@components/Search/FilterComponents/AdvancedFilters/FilterList'; +import SpacerView from '@components/SpacerView'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -21,6 +24,7 @@ function SearchAdvancedFiltersBase() { const {translate} = useLocalize(); const {currentDraftFilters, shouldShowResetFilters} = useContext(SearchAdvancedFiltersContext); const {applyFilters, resetFilters} = useContext(SearchAdvancedFiltersActionContext); + const icons = useMemoizedLazyExpensifyIcons(['Sparkles']); return ( + Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS_DESCRIBE)} + /> + { + Navigation.dismissModal({afterTransition: () => Navigation.navigate(route)}); + }; + + return ( + + Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS)} + /> + + + ); +} + +export default SearchNLFilterPage; diff --git a/src/styles/index.ts b/src/styles/index.ts index d364a6465f57..839a61f3cab7 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5356,6 +5356,7 @@ const staticStyles = (theme: ThemeColors) => }, typeFiltersPopupContainer: { + width: CONST.ADVANCED_FILTERS_POPOVER_WIDTH - CONST.ADVANCED_FILTERS_CONTENT_WIDTH, borderRightWidth: 1, borderRightColor: theme.border, paddingVertical: 8, diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 1c4d1f18fef4..ed43b893fea5 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -124,6 +124,15 @@ type Response = { /** Transactions pending 3DS review returned from GetTransactionsPending3DSReview */ transactionsPending3DSReview?: TransactionsPending3DSReview; + /** Whether the ParseExpenseFilters command successfully parsed the natural-language query */ + success?: boolean; + + /** Search URL returned by ParseExpenseFilters on success */ + searchURL?: string; + + /** Human-readable summary of the parsed filters returned by ParseExpenseFilters on success */ + humanReadableSummary?: string; + /** Cache key returned from GetExpensifyCardStatementPDF */ statementKey?: string; }; diff --git a/tests/ui/SessionTest.tsx b/tests/ui/SessionTest.tsx index 1e1798e7d408..d167c61394e4 100644 --- a/tests/ui/SessionTest.tsx +++ b/tests/ui/SessionTest.tsx @@ -319,6 +319,75 @@ describe('Support auth token login', () => { ); }); +describe('Support auth token login', () => { + beforeEach(() => { + jest.restoreAllMocks(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + + jest.spyOn(Session, 'signInWithSupportAuthToken').mockImplementation(() => {}); + + // Set the keys the app needs to finish loading rather than going through a full OpenApp round-trip. + jest.spyOn(AppActions, 'openApp').mockImplementation(() => + Onyx.multiSet({ + [ONYXKEYS.IS_LOADING_APP]: false, + [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + [ONYXKEYS.HAS_LOADED_APP]: true, + [ONYXKEYS.NVP_ONBOARDING]: {hasCompletedGuidedSetupFlow: true}, + }), + ); + }); + + afterEach(async () => { + cleanup(); + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + PusherHelper.teardown(); + jest.clearAllMocks(); + Linking.setInitialURL(''); + setLastShortAuthToken(null); + }); + + // Renders the full App and processes a supportal transition, so it runs longer than the suite default. + it( + 'does not fire the support sign-in again when LogOutPreviousUserPage re-processes an already-handled token', + async () => { + expect(hasAuthToken()).toBe(false); + + // Sign in so the app is on AuthScreens, where LogOutPreviousUserPage owns the /transition route. + const {unmount: unmount1} = render(); + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID_2, TEST_USER_LOGIN_2, undefined, TEST_AUTH_TOKEN_2); + + await waitForBatchedUpdatesWithAct(); + + expect(hasAuthToken()).toBe(true); + unmount1(); + + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + + // The public transition page (LogInWithShortLivedAuthTokenPage) records the support token when it + // fires the sign-in. Simulate that, then re-process the SAME support deep link, which lands on + // LogOutPreviousUserPage. It must skip the duplicate sign-in; before the fix it fired + // unconditionally and tripped the support-token rate limit. + setLastShortAuthToken(TEST_SUPPORT_AUTH_TOKEN); + Linking.setInitialURL(getSupportAuthURL()); + const {unmount: unmount2} = render(); + + await waitForBatchedUpdatesWithAct(); + + expect(Session.signInWithSupportAuthToken).not.toHaveBeenCalled(); + + unmount2(); + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + }, + 4 * 60 * 1000, + ); +}); + describe('SAML re-fire loop guard', () => { beforeEach(() => { jest.restoreAllMocks(); diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 8e4f2e6e9843..c991ea58a8e0 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -1171,6 +1171,61 @@ describe('formatCurrentUserToAttendee', () => { }); }); +describe('formatCurrentUserToAttendee', () => { + test('returns undefined when current user has no login or display name', () => { + const currentUser = { + accountID: 2840332, + }; + + expect(IOUUtils.formatCurrentUserToAttendee(currentUser)).toBeUndefined(); + }); + + test('returns undefined when current user has only a display name', () => { + const currentUser = { + accountID: 2840332, + displayName: 'John Smith', + }; + + expect(IOUUtils.formatCurrentUserToAttendee(currentUser)).toBeUndefined(); + }); + + test('uses login and display name when current user login exists', () => { + const currentUser = { + accountID: 2840332, + login: 'john.smith@example.com', + displayName: 'John Smith', + }; + + const attendees = IOUUtils.formatCurrentUserToAttendee(currentUser); + + expect(attendees).toEqual([ + { + email: 'john.smith@example.com', + displayName: 'John Smith', + avatarUrl: '', + }, + ]); + }); + + test('uses session email when current user login is missing', () => { + const currentUser = { + accountID: 2840332, + email: 'john.smith@example.com', + displayName: '', + }; + + const attendees = IOUUtils.formatCurrentUserToAttendee(currentUser); + + expect(attendees).toEqual([ + { + email: 'john.smith@example.com', + displayName: 'john.smith@example.com', + avatarUrl: '', + }, + ]); + }); +}); + describe('isParticipantP2P', () => { it('should return true for P2P participant with accountID and isPolicyExpenseChat false', () => { const participant = {