Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
76 changes: 46 additions & 30 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof CONST.SEARCH.FAILURE_KIND> = 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 (
<View style={[shouldUseNarrowLayout ? styles.searchListContentContainerStyles(!!hasFilterBars) : styles.mt3, styles.flex1]}>
<FullPageErrorView
shouldShow
containerStyle={styles.searchBlockingErrorViewContainer}
subtitleStyle={styles.textSupporting}
title={
isInvalidQuery
? translate('errorPage.title', {
isBreakLine: shouldUseNarrowLayout,
})
: translate('search.searchResults.staleResults.title')
}
subtitle={translate(isInvalidQuery ? 'errorPage.wrongTypeSubtitle' : 'search.searchResults.staleResults.subtitle')}
// A failed request leaves results that are out of date rather than broken, so that case gets the
// refresh copy and illustration. An invalid query keeps the error copy, since it really did fail.
{...(!isInvalidQuery && {
illustration: 'FolderSync',
illustrationWidth: variables.iconSizeUltraLarge,
illustrationHeight: variables.iconSizeUltraLarge,
buttonTranslationKey: 'search.searchResults.staleResults.buttonText',
onButtonPress: () => {
// 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]}
/>
</View>
);
Expand Down
6 changes: 3 additions & 3 deletions src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
},
Expand Down
33 changes: 33 additions & 0 deletions tests/ui/SearchPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, {
Expand Down
24 changes: 22 additions & 2 deletions tests/unit/Search/searchSnapshotStateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -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);
});
});
Loading