diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 44ec0632b64b..7193af2b66c7 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8391,6 +8391,9 @@ const CONST = { CHRONOS_TIMER_BUTTON: 'HeaderView-ChronosTimerButton', DETAILS_BUTTON: 'HeaderView-DetailsButton', }, + BLOCKING_VIEW: { + RETRY_BUTTON: 'BlockingView-RetryButton', + }, SEARCH: { SEARCH_BUTTON: 'Search-SearchButton', TRANSACTION_GROUP_LIST_ITEM: 'Search-TransactionGroupListItem', diff --git a/src/components/BlockingViews/BlockingView.tsx b/src/components/BlockingViews/BlockingView.tsx index 4d92fab92fc7..0e2cfacf6193 100644 --- a/src/components/BlockingViews/BlockingView.tsx +++ b/src/components/BlockingViews/BlockingView.tsx @@ -1,3 +1,4 @@ +import Button from '@components/ButtonComposed'; import Icon from '@components/Icon'; import Lottie from '@components/Lottie'; import type DotLottieAnimation from '@components/LottieAnimations/types'; @@ -5,6 +6,7 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -12,6 +14,7 @@ import useAbsentPageSpan from '@libs/telemetry/useAbsentPageSpan'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {ImageContentFit} from 'expo-image'; @@ -48,6 +51,12 @@ type BaseBlockingViewProps = { /** Function to call when pressing the navigation link */ onLinkPress?: () => void; + /** Translation key for an optional CTA button rendered below the subtitle */ + buttonTranslationKey?: TranslationPaths; + + /** Function to call when pressing the CTA button. The button only renders when this and `buttonTranslationKey` are both provided */ + onButtonPress?: () => void; + /** Whether we should embed the link with subtitle */ shouldEmbedLinkWithSubtitle?: boolean; @@ -110,6 +119,8 @@ function BlockingView({ subtitleStyle, linkTranslationKey, subtitleKeyBelowLink, + buttonTranslationKey, + onButtonPress, iconWidth = variables.iconSizeSuperLarge, iconHeight = variables.iconSizeSuperLarge, onLinkPress = () => Navigation.dismissModal(), @@ -126,6 +137,7 @@ function BlockingView({ testID, }: BlockingViewProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const SubtitleWrapper = shouldEmbedLinkWithSubtitle ? Text : View; const subtitleWrapperStyle = useMemo( () => (shouldEmbedLinkWithSubtitle ? [styles.textAlignCenter] : [styles.alignItemsCenter, styles.justifyContentCenter]), @@ -185,6 +197,14 @@ function BlockingView({ )} + {!!onButtonPress && !!buttonTranslationKey && ( + + )} ); } diff --git a/src/components/BlockingViews/FullPageErrorView.tsx b/src/components/BlockingViews/FullPageErrorView.tsx index 4863b3bba29d..649a9e9444b7 100644 --- a/src/components/BlockingViews/FullPageErrorView.tsx +++ b/src/components/BlockingViews/FullPageErrorView.tsx @@ -3,6 +3,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import type {TranslationPaths} from '@src/languages/types'; + import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import React from 'react'; @@ -34,9 +36,26 @@ type FullPageErrorViewProps = { subtitleStyle?: StyleProp; containerStyle?: StyleProp; + + /** Translation key for an optional CTA button rendered below the subtitle */ + buttonTranslationKey?: TranslationPaths; + + /** Function to call when pressing the CTA button. The button only renders when this and `buttonTranslationKey` are both provided */ + onButtonPress?: () => void; }; -function FullPageErrorView({testID, children = null, shouldShow = false, title = '', subtitle = '', shouldForceFullScreen = false, subtitleStyle, containerStyle}: FullPageErrorViewProps) { +function FullPageErrorView({ + testID, + children = null, + shouldShow = false, + title = '', + subtitle = '', + shouldForceFullScreen = false, + subtitleStyle, + containerStyle, + buttonTranslationKey, + onButtonPress, +}: FullPageErrorViewProps) { const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); @@ -52,9 +71,12 @@ function FullPageErrorView({testID, children = null, shouldShow = false, title = iconWidth={variables.errorPageIconWidth} iconHeight={variables.errorPageIconHeight} title={title} + titleStyles={[styles.mt0, styles.mb2]} subtitle={subtitle} subtitleStyle={subtitleStyle} - containerStyle={containerStyle} + containerStyle={[styles.gap5, containerStyle]} + buttonTranslationKey={buttonTranslationKey} + onButtonPress={onButtonPress} /> diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 429c8b9434b6..ab37b2acd7b1 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -182,6 +182,10 @@ function Search({ const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0, areAllMatchingItemsSelected); + // Retrying a failed page always resets pagination to the first page, so totals eligibility + // must be evaluated as if we're on the first page rather than the (possibly paginated) offset. + const shouldCalculateTotalsOnRetry = useSearchShouldCalculateTotals(currentSearchKey, hash, true, areAllMatchingItemsSelected); + const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); const searchListRef = useRef | null>(null); @@ -942,6 +946,24 @@ function Search({ isBreakLine: shouldUseNarrowLayout, })} subtitle={translate(isInvalidQuery ? 'errorPage.wrongTypeSubtitle' : 'errorPage.subtitle')} + // Retrying an invalid query won't help, so the retry button is only offered for other errors. + {...(!isInvalidQuery && { + buttonTranslationKey: 'common.tryAgain', + onButtonPress: () => { + // A failed load-more clears the whole snapshot (data: null), so retrying with the + // paginated offset would refetch only the later page into an empty snapshot and drop + // the initial results. Reset pagination to the first page before retrying. + setOffset(0); + handleSearch({ + queryJSON, + searchKey: currentSearchKey, + offset: 0, + shouldCalculateTotals: shouldCalculateTotalsOnRetry, + prevReportsLength: filteredDataLength, + isLoading: !!searchResults?.search?.isLoading, + }); + }, + })} /> ); diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index 8bc0ef93ad8e..201cae1c0d66 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -9,7 +9,7 @@ import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import {useFocusEffect} from '@react-navigation/native'; -import {useEffect} from 'react'; +import {useEffect, useState} from 'react'; import useNetwork from './useNetwork'; import usePrevious from './usePrevious'; @@ -36,7 +36,24 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { const hash = queryJSON?.hash; const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, true); - // Depend on the values that can trigger a search instead of the whole snapshot, which gets a new reference after every Onyx merge. + // Tracks the jsonCode of the current query's most recent SEARCH response. It's the single source of + // truth for both fire paths (the page-level fire below and the user-driven re-search in + // SearchPage/SearchPageNarrow), so the error view can reliably tell an INVALID_SEARCH_QUERY apart from a + // retryable failure. `null` means no response has landed yet for the current query. + const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); + + // Reset on query change so a stale code from a previous query (e.g. INVALID_SEARCH_QUERY) can't + // misclassify the new query's failure and wrongly hide/show the Retry button. Adjusting state during + // render (rather than in an effect) is the React-recommended pattern for resetting state on a prop + // change and avoids the extra committed render an effect would cost. + const [prevHash, setPrevHash] = useState(hash); + if (hash !== prevHash) { + setPrevHash(hash); + setSearchRequestResponseStatusCode(null); + } + + // Derived primitives so effects do not depend on the whole snapshot object (new reference every + // Onyx merge) while exhaustive-deps still sees every transition that matters for firing search(). const isSnapshotDataLoaded = queryJSON ? isSearchDataLoaded(currentSearchResults, queryJSON) : false; // Keep `isLoading` as a dependency so an unresolved search retries when temporary search prevention changes it to false. const isSnapshotSearchLoading = !!currentSearchResults?.search?.isLoading; @@ -74,7 +91,11 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { return; } const shouldSkipWaitForWrites = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); - search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals, isLoading: false, skipWaitForWrites: shouldSkipWaitForWrites}); + // Capture the response code so an invalid query opened directly (deep link / initial mount) is + // classified correctly, instead of leaving the status at `null` and offering a pointless Retry. + search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals, isLoading: false, skipWaitForWrites: shouldSkipWaitForWrites})?.then((jsonCode) => + setSearchRequestResponseStatusCode(Number(jsonCode ?? 0)), + ); }, [hash, isOffline, shouldUseLiveData, queryJSON, isSnapshotDataLoaded, isSnapshotSearchLoading, currentSearchKey, shouldCalculateTotals]); useFocusEffect(() => { @@ -87,6 +108,8 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { } openSearch(); }, [isOffline, prevIsOffline]); + + return {searchRequestResponseStatusCode, setSearchRequestResponseStatusCode}; } export default useSearchPageSetup; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index d4d78421d3c7..cd65dabf2294 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -50,7 +50,7 @@ function SearchPage({route}: SearchPageProps) { const [lastNonEmptySearchResults, setLastNonEmptySearchResults] = useState(undefined); - useSearchPageSetup(currentSearchQueryJSON); + const {searchRequestResponseStatusCode, setSearchRequestResponseStatusCode} = useSearchPageSetup(currentSearchQueryJSON); useSeedMyExpensesSearch(); // Adjust state during rendering rather than in a useEffect: the value is consumed in the same @@ -108,18 +108,19 @@ function SearchPage({route}: SearchPageProps) { setIsSorting(false); }, [currentSearchResults?.isLoading, isSorting, prevIsLoading]); - const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); - - const handleSearchAction = useCallback((value: SearchParams | string) => { - if (typeof value === 'string') { - searchInServer(value); - } else { - setSearchRequestResponseStatusCode(null); - search(value)?.then((jsonCode) => { - setSearchRequestResponseStatusCode(Number(jsonCode ?? 0)); - }); - } - }, []); + const handleSearchAction = useCallback( + (value: SearchParams | string) => { + if (typeof value === 'string') { + searchInServer(value); + } else { + setSearchRequestResponseStatusCode(null); + search(value)?.then((jsonCode) => { + setSearchRequestResponseStatusCode(Number(jsonCode ?? 0)); + }); + } + }, + [setSearchRequestResponseStatusCode], + ); const onSortPressedCallback = useCallback(() => { setIsSorting(true); @@ -145,6 +146,8 @@ function SearchPage({route}: SearchPageProps) { void; onSortPressedCallback: () => void; /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ searchOverlayContent: React.ReactNode; @@ -73,6 +75,8 @@ function SearchPageNarrow({ queryJSON, searchResults, isMobileSelectionModeEnabled, + searchRequestResponseStatusCode, + setSearchRequestResponseStatusCode, onSortPressedCallback, searchOverlayContent, onSearchContentReady, @@ -96,8 +100,6 @@ function SearchPageNarrow({ const {saveScrollOffset} = useContext(ScrollOffsetContext); const receiptDropTargetRef = useRef(null); - const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); - const scrollOffset = useSharedValue(0); const topBarOffset = useSharedValue(StyleUtils.searchHeaderDefaultOffset); @@ -151,13 +153,17 @@ function SearchPageNarrow({ const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery()})); - const handleSearchAction = useCallback((value: SearchParams | string) => { - if (typeof value === 'string') { - searchInServer(value); - } else { - search(value)?.then((jsonCode) => setSearchRequestResponseStatusCode(Number(jsonCode ?? 0))); - } - }, []); + const handleSearchAction = useCallback( + (value: SearchParams | string) => { + if (typeof value === 'string') { + searchInServer(value); + } else { + setSearchRequestResponseStatusCode(null); + search(value)?.then((jsonCode) => setSearchRequestResponseStatusCode(Number(jsonCode ?? 0))); + } + }, + [setSearchRequestResponseStatusCode], + ); const navigation = useNavigation(); // When pre-inserted behind the RHP (not focused), always start in static rendering diff --git a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx index 66bc4b37af6b..e6a0b1172ce0 100644 --- a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx +++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx @@ -31,7 +31,7 @@ function SpendOverTimeSectionContent() { const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {query, queryJSON, groupBy, view, sortedData, state} = useSpendOverTimeData(); + const {query, queryJSON, groupBy, view, sortedData, state, retry} = useSpendOverTimeData(); if (!queryJSON || !view || !groupBy || view === CONST.SEARCH.VIEW.TABLE || state === SPEND_OVER_TIME_STATE.HIDDEN) { return null; @@ -79,8 +79,10 @@ function SpendOverTimeSectionContent() { titleStyles={[styles.mt0, styles.mb2]} subtitle={translate('errorPage.subtitle')} subtitleStyle={styles.textSupporting} - containerStyle={[{minHeight: CHART_CONTENT_MIN_HEIGHT}, styles.gap5]} + containerStyle={[{minHeight: CHART_CONTENT_MIN_HEIGHT}, styles.gap5, styles.pb5]} contentFitImage="contain" + buttonTranslationKey="common.tryAgain" + onButtonPress={retry} /> )} {(state === SPEND_OVER_TIME_STATE.LOADING || state === SPEND_OVER_TIME_STATE.READY) && ( diff --git a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts index 4138feea0aa6..ab633b7849c5 100644 --- a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts +++ b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts @@ -65,7 +65,7 @@ function useSpendOverTimeData() { const {isOffline} = useNetwork(); const isFocused = useIsFocused(); - const onConfigChanged = useEffectEvent(() => { + const retry = () => { // `search.isLoading` is persisted and may be stale after a reload. Call `search()` again and let it ignore a request that is still running. if (!queryJSON || isOffline) { return; @@ -79,6 +79,10 @@ function useSpendOverTimeData() { isLoading: false, shouldUpdateLastSearchParams: false, }); + }; + + const onConfigChanged = useEffectEvent(() => { + retry(); }); useEffect(() => { @@ -123,6 +127,7 @@ function useSpendOverTimeData() { view, sortedData, state, + retry, }; }