Make loading state for opensearchpage more accurate#85952
Conversation
|
@srikarparsi Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
JmillsExpensify
left a comment
There was a problem hiding this comment.
No product review required.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9e8ca63a9a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
|
@JS00001 we should get a C+ to review/test this PR too |
aimane-chnaif
left a comment
There was a problem hiding this comment.
Any reason for removing unit test?
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppios.moviOS: mWeb SafariMacOS: Chrome / SafariScreen.Recording.2026-03-20.at.7.38.02.pm.movScreen.Recording.2026-03-20.at.7.39.09.pm.movoffline.mov |
It tested only the method we removed |
yes, but offline case is still valid. We generally add unit tests for hooks. Let's see what AI suggests. @MelvinBot please suggest code changes for adding unit tests of |
|
Here's a suggested unit test file for the // tests/unit/hooks/useSearchTypeMenuSections.test.ts
/* eslint-disable @typescript-eslint/naming-convention */
import {renderHook} from '@testing-library/react-native';
import useNetwork from '@hooks/useNetwork';
import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
jest.mock('@libs/ReportUtils', () => ({
getPersonalDetailsForAccountID: jest.fn(),
hasEmptyReportsForPolicy: jest.fn(() => false),
hasViolations: jest.fn(() => false),
}));
jest.mock('@userActions/Report', () => ({
createNewReport: jest.fn(() => ({reportID: 'mock-report-id'})),
}));
jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}})));
jest.mock('@hooks/useCreateEmptyReportConfirmation', () => jest.fn(() => ({openCreateReportConfirmation: jest.fn()})));
jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false})));
jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: jest.fn(() => false)})));
const onyxData: Record<string, unknown> = {};
const mockUseOnyx = jest.fn(
(
key: string,
options?: {
selector?: (value: unknown) => unknown;
},
) => {
const value = onyxData[key];
const selectedValue = options?.selector ? options.selector(value as never) : value;
return [selectedValue];
},
);
jest.mock('@hooks/useOnyx', () => ({
__esModule: true,
default: (key: string, options?: {selector?: (value: unknown) => unknown}) => mockUseOnyx(key, options),
}));
const mockUseMappedPolicies = jest.fn(() => [onyxData[ONYXKEYS.COLLECTION.POLICY], {}]);
jest.mock('@hooks/useMappedPolicies', () => ({
__esModule: true,
default: () => mockUseMappedPolicies(),
}));
describe('useSearchTypeMenuSections', () => {
beforeEach(() => {
onyxData[ONYXKEYS.COLLECTION.POLICY] = {};
onyxData[ONYXKEYS.SESSION] = {email: 'test@example.com', accountID: 1};
onyxData[ONYXKEYS.SAVED_SEARCHES] = {};
onyxData[ONYXKEYS.COLLECTION.REPORT] = {};
(useNetwork as jest.Mock).mockReturnValue({isOffline: false});
mockUseOnyx.mockClear();
});
it('returns typeMenuSections with at least the Explore section', () => {
const {result} = renderHook(() => useSearchTypeMenuSections());
expect(result.current.typeMenuSections.length).toBeGreaterThanOrEqual(1);
const exploreSection = result.current.typeMenuSections.find((s) => s.translationPath === 'common.explore');
expect(exploreSection).toBeDefined();
expect(exploreSection?.menuItems.length).toBeGreaterThanOrEqual(1);
});
it('returns activeItemIndex of -1 when hash matches a saved search', () => {
onyxData[ONYXKEYS.SAVED_SEARCHES] = {
12345: {name: 'My saved search', query: 'type:expense'},
};
const {result} = renderHook(() => useSearchTypeMenuSections({hash: 12345}));
expect(result.current.activeItemIndex).toBe(-1);
});
it('returns activeItemIndex of -1 when no similarSearchHash matches any item', () => {
const {result} = renderHook(() => useSearchTypeMenuSections({similarSearchHash: 99999}));
expect(result.current.activeItemIndex).toBe(-1);
});
it('returns correct activeItemIndex when similarSearchHash matches an item', () => {
const {result} = renderHook(() => useSearchTypeMenuSections());
// Get the first item's similarSearchHash from the generated sections
const firstItem = result.current.typeMenuSections[0]?.menuItems[0];
expect(firstItem).toBeDefined();
const {result: resultWithMatch} = renderHook(() => useSearchTypeMenuSections({similarSearchHash: firstItem.similarSearchHash}));
expect(resultWithMatch.current.activeItemIndex).toBe(0);
});
it('returns correct flattened activeItemIndex for items in later sections', () => {
// Set up a policy that enables multiple sections (Todo section needs a paid group policy)
onyxData[ONYXKEYS.COLLECTION.POLICY] = {
policy1: {
id: 'policy1',
type: 'team',
role: 'admin',
owner: 'test@example.com',
employeeList: {'test@example.com': {accountID: 1, submitsTo: 'test@example.com'}},
exporter: 'test@example.com',
approver: 'test@example.com',
approvalMode: 'basic',
isPolicyExpenseChatEnabled: true,
reimbursementChoice: 'reimburseManual',
},
};
const {result} = renderHook(() => useSearchTypeMenuSections());
// If we have multiple sections, find an item in the second section
if (result.current.typeMenuSections.length > 1) {
const firstSectionLength = result.current.typeMenuSections[0].menuItems.length;
const secondSectionFirstItem = result.current.typeMenuSections[1]?.menuItems[0];
if (secondSectionFirstItem) {
const {result: resultWithMatch} = renderHook(() =>
useSearchTypeMenuSections({similarSearchHash: secondSectionFirstItem.similarSearchHash}),
);
expect(resultWithMatch.current.activeItemIndex).toBe(firstSectionLength);
}
}
});
it('shows saved searches section when saved searches exist', () => {
onyxData[ONYXKEYS.SAVED_SEARCHES] = {
12345: {name: 'My saved search', query: 'type:expense'},
};
const {result} = renderHook(() => useSearchTypeMenuSections());
const savedSection = result.current.typeMenuSections.find((s) => s.translationPath === 'search.savedSearchesMenuItemTitle');
expect(savedSection).toBeDefined();
});
it('includes optimistically deleted saved searches when offline', () => {
(useNetwork as jest.Mock).mockReturnValue({isOffline: true});
onyxData[ONYXKEYS.SAVED_SEARCHES] = {
12345: {
name: 'Deleted search',
query: 'type:expense',
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
},
};
const {result} = renderHook(() => useSearchTypeMenuSections());
const savedSection = result.current.typeMenuSections.find((s) => s.translationPath === 'search.savedSearchesMenuItemTitle');
expect(savedSection).toBeDefined();
});
it('excludes optimistically deleted saved searches when online', () => {
(useNetwork as jest.Mock).mockReturnValue({isOffline: false});
onyxData[ONYXKEYS.SAVED_SEARCHES] = {
12345: {
name: 'Deleted search',
query: 'type:expense',
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
},
};
const {result} = renderHook(() => useSearchTypeMenuSections());
const savedSection = result.current.typeMenuSections.find((s) => s.translationPath === 'search.savedSearchesMenuItemTitle');
expect(savedSection).toBeUndefined();
});
});What's covered
The last two tests cover the offline case the reviewer mentioned — the |
|
Please add above tests if applicable |
|
@MarioExpensify Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
🎯 @aimane-chnaif, thanks for reviewing and testing this PR! 🎉 An E/App issue has been created to issue payment here: #85987. |
🔍 PR ReviewOverall: The approach is sound — replacing the hacky What looks good
Items to consider
|
|
🚧 @luacmartins has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
Codex Review: Didn't find any major issues. Breezy! ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚀 Deployed to staging by https://github.com/luacmartins in version: 9.3.42-0 🚀
Bundle Size Analysis (Sentry): |
Explanation of Change
QOL change. Lets make the loading state for 'OpenSearchPage' more accurate. We previously used a hacky
policy?.employeeList !== undefined && policy?.exporter !== undefined);(Since exporter was only returned in opensearchpage), but lets just add a key to track thisFixed Issues
N/A
Tests
Clear your cache
Open the search page
Ensure a loading skeleton shows up for the suggested searches
Refresh the page or navigate away and back
Ensure the skeleton doesnt show up again
Offline tests
Ensure that the skeleton doesnt permanently block the screen in offline mode
QA Steps
Same as tests
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Screen.Recording.2026-03-20.at.10.01.04.AM.mov