diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 67f6ad2c0904..d4bcb7a64048 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2677,6 +2677,8 @@ const CONST = { UTILITIES: 'Utilities', }, JSON_CODE: { + // Client-side placeholder, never sent by the server: the request failed without a usable response code + NO_RESPONSE: 0, SUCCESS: 200, BAD_REQUEST: 400, INVALID_SEARCH_QUERY: 401, @@ -7260,6 +7262,11 @@ const CONST = { LOADING: 'loading', LOADED: 'loaded', }, + FAILURE_KIND: { + STALE: 'stale', + INVALID_QUERY: 'invalidQuery', + FAILED: 'failed', + }, ACTION_FILTERS: { SUBMIT: 'submit', APPROVE: 'approve', diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 6fb1f7fae215..aea1479aff19 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -87,6 +87,7 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; @@ -1108,43 +1109,58 @@ function Search({ } if (hasErrors) { - const isInvalidQuery = responseStatusCode === CONST.JSON_CODE.INVALID_SEARCH_QUERY; cancelNavigationSpans(); + const retrySearch = () => { + // A response replaces the snapshot's results rather than appending to them, so retrying at + // the paginated offset would leave only that later page behind. Retry from the first page. + setOffset(0); + handleSearch({ + queryJSON, + searchKey: currentSearchKey, + offset: 0, + shouldCalculateTotals: shouldCalculateTotalsOnRetry, + prevReportsLength: filteredDataLength, + isLoading: !!searchResults?.search?.isLoading, + }); + }; + // failureData stores NO_RESPONSE when the request never got a server answer, so only the results' freshness is in + // doubt and the refresh copy fits. Any code the server did return marks a real failure and keeps the error copy, + // and an invalid query gets no button because re-sending it cannot succeed. + let failureKind: ValueOf = CONST.SEARCH.FAILURE_KIND.FAILED; + if (responseStatusCode === CONST.JSON_CODE.NO_RESPONSE) { + failureKind = CONST.SEARCH.FAILURE_KIND.STALE; + } else if (responseStatusCode === CONST.JSON_CODE.INVALID_SEARCH_QUERY) { + failureKind = CONST.SEARCH.FAILURE_KIND.INVALID_QUERY; + } + const errorTitle = translate('errorPage.title', {isBreakLine: shouldUseNarrowLayout}); + const errorViewByKind = { + [CONST.SEARCH.FAILURE_KIND.STALE]: { + title: translate('search.searchResults.staleResults.title'), + subtitle: translate('search.searchResults.staleResults.subtitle'), + illustration: 'FolderSync', + illustrationWidth: variables.iconSizeUltraLarge, + illustrationHeight: variables.iconSizeUltraLarge, + buttonTranslationKey: 'search.searchResults.staleResults.buttonText', + onButtonPress: retrySearch, + }, + [CONST.SEARCH.FAILURE_KIND.INVALID_QUERY]: { + title: errorTitle, + subtitle: translate('errorPage.wrongTypeSubtitle'), + }, + [CONST.SEARCH.FAILURE_KIND.FAILED]: { + title: errorTitle, + subtitle: translate('errorPage.subtitle'), + buttonTranslationKey: 'common.tryAgain', + onButtonPress: retrySearch, + }, + } as const; return ( { - // A response replaces the snapshot's results rather than appending to them, so retrying at - // the paginated offset would leave only that later page behind. Retry from the first page. - setOffset(0); - handleSearch({ - queryJSON, - searchKey: currentSearchKey, - offset: 0, - shouldCalculateTotals: shouldCalculateTotalsOnRetry, - prevReportsLength: filteredDataLength, - isLoading: !!searchResults?.search?.isLoading, - }); - }, - })} + {...errorViewByKind[failureKind]} /> ); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 1a449bb597cd..d48d5a9e9196 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -852,10 +852,10 @@ function getOnyxLoadingData( search: { type, ...(isSearchAPI && {isLoading: false}), - // 0 stands for "failed with no usable response code", which covers a network-level rejection that - // never reaches the server. A real HTTP failure overwrites it below once the response lands. Every + // NO_RESPONSE stands for "failed with no usable response code", which covers a network-level rejection + // that never reaches the server. A real HTTP failure overwrites it below once the response lands. Every // write of `errors` carries a code this way, so the error view never has to guess. - ...(isSearchRequest && {hash, responseJsonCode: 0}), + ...(isSearchRequest && {hash, responseJsonCode: CONST.JSON_CODE.NO_RESPONSE}), }, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, diff --git a/tests/ui/SearchPageTest.tsx b/tests/ui/SearchPageTest.tsx index caa0a79917b7..e9aa509fc00b 100644 --- a/tests/ui/SearchPageTest.tsx +++ b/tests/ui/SearchPageTest.tsx @@ -352,6 +352,39 @@ describe('SearchPageNarrow', () => { expect(screen.queryByText('Try again')).toBeNull(); }); + it('shows the error page with a retry button when the server rejected the query with a code other than invalid query', async () => { + // Given the page already requested the query, so an error that lands afterwards is its own and is kept + renderPage(); + + await act(async () => { + jest.runAllTimers(); + }); + + // When the server answers with a failure code that is not INVALID_SEARCH_QUERY + await setFailedSnapshot(CONST.JSON_CODE.EXP_ERROR); + + // Then the request really failed, so the error copy shows rather than the stale-results copy + expect(screen.getByText('Oops... Something went wrong')).toBeTruthy(); + expect(screen.getByText('Try again')).toBeTruthy(); + expect(screen.queryByText('Refresh needed')).toBeNull(); + }); + + it('shows the refresh copy when the request failed without a server response code', async () => { + renderPage(); + + await act(async () => { + jest.runAllTimers(); + }); + + // When the request failed before the server could answer, which failureData records as NO_RESPONSE + await setFailedSnapshot(CONST.JSON_CODE.NO_RESPONSE); + + // Then the results are only out of date, so the refresh copy shows + expect(screen.getByText('Refresh needed')).toBeTruthy(); + expect(screen.getByText('Refresh')).toBeTruthy(); + expect(screen.queryByText('Oops... Something went wrong')).toBeNull(); + }); + it('renders the empty state when a response without data reached the terminal loaded state', async () => { await act(async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.SNAPSHOT}${failedQueryJSON?.hash}`, { diff --git a/tests/unit/Search/searchSnapshotStateTest.ts b/tests/unit/Search/searchSnapshotStateTest.ts index fcd1f494274b..405c4ffcb9ef 100644 --- a/tests/unit/Search/searchSnapshotStateTest.ts +++ b/tests/unit/Search/searchSnapshotStateTest.ts @@ -197,6 +197,26 @@ describe('search snapshot terminal state', () => { expect(snapshot?.search?.responseJsonCode).toBe(CONST.JSON_CODE.INVALID_SEARCH_QUERY); }); + it('persists a non-401 server failure code over the NO_RESPONSE placeholder written by failureData', async () => { + const queryJSON = getQueryJSON(); + // failureData lands first and writes NO_RESPONSE. The real code must overwrite it, otherwise the error view + // would show the "stale results" copy for a request the server actually rejected. + jest.mocked(makeRequestWithSideEffects).mockImplementationOnce(async (_command, _parameters, onyxData) => { + await Onyx.update(onyxData?.optimisticData ?? []); + await Onyx.update(onyxData?.failureData ?? []); + await Onyx.update(onyxData?.finallyData ?? []); + return {jsonCode: CONST.JSON_CODE.EXP_ERROR}; + }); + + await search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, isLoading: false}); + await waitForBatchedUpdates(); + + const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const); + expect(snapshot?.errors).toBeDefined(); + expect(snapshot?.search?.responseJsonCode).toBe(CONST.JSON_CODE.EXP_ERROR); + expect(snapshot?.search?.responseJsonCode).not.toBe(CONST.JSON_CODE.NO_RESPONSE); + }); + it('does not persist a jsonCode for a successful response', async () => { const queryJSON = getQueryJSON(); jest.mocked(makeRequestWithSideEffects).mockResolvedValueOnce({jsonCode: CONST.JSON_CODE.SUCCESS}); @@ -257,7 +277,7 @@ describe('search snapshot terminal state', () => { expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADED); expect(snapshot?.errors).toBeDefined(); // There is no response to read a code from, but the errors still need one so the error view can - // classify them after a reload. 0 records "failed without a usable code" rather than leaving a gap. - expect(snapshot?.search?.responseJsonCode).toBe(0); + // classify them after a reload. NO_RESPONSE records "failed without a usable code" rather than leaving a gap. + expect(snapshot?.search?.responseJsonCode).toBe(CONST.JSON_CODE.NO_RESPONSE); }); });