Skip to content

Commit 1124418

Browse files
committed
Polish saved search tab highlight edge cases on narrow Search
1 parent fec6b33 commit 1124418

3 files changed

Lines changed: 224 additions & 14 deletions

File tree

src/hooks/useSearchTypeMenuSections.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,18 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams
131131
);
132132

133133
const activeItemIndex = useMemo(() => {
134-
const isSavedSearchActive = hash !== undefined && !!savedSearches && Object.keys(savedSearches).some((key) => Number(key) === hash);
134+
const isSavedSearchActive =
135+
hash !== undefined &&
136+
!!savedSearches &&
137+
Object.entries(savedSearches).some(([key, item]) => {
138+
if (Number(key) !== hash) {
139+
return false;
140+
}
141+
if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) {
142+
return false;
143+
}
144+
return true;
145+
});
135146

136147
if (isSavedSearchActive) {
137148
return -1;
@@ -169,7 +180,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams
169180
}
170181

171182
return -1;
172-
}, [typeMenuSections, savedSearches, hash, similarSearchHash, sortBy, sortOrder, type]);
183+
}, [typeMenuSections, savedSearches, hash, similarSearchHash, sortBy, sortOrder, type, isOffline]);
173184

174185
const activeKey = activeItemIndex < 0 ? undefined : typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex)?.key;
175186

src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
1+
// Static twin of SearchTypeMenuNarrow - used for fast perceived performance.
2+
// Keep hooks and Onyx subscriptions to an absolute minimum; add new ones only
3+
// when strictly necessary. UI must stay visually identical to the interactive version.
4+
import React from 'react';
15
import {useSession} from '@components/OnyxListItemProvider';
26
import type {SearchQueryJSON} from '@components/Search/types';
37
import type {TabSelectorBaseItem} from '@components/TabSelector/types';
4-
58
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
69
import useLocalize from '@hooks/useLocalize';
10+
import useNetwork from '@hooks/useNetwork';
711
import useOnyx from '@hooks/useOnyx';
8-
912
import type {SearchTypeMenuItem} from '@libs/SearchUIUtils';
1013
import {getSuggestedSearches} from '@libs/SearchUIUtils';
11-
1214
import {SearchTypeMenuNarrowContent} from '@pages/Search/SearchTypeMenuNarrow';
13-
1415
import CONST from '@src/CONST';
1516
import ONYXKEYS from '@src/ONYXKEYS';
16-
17-
// Static twin of SearchTypeMenuNarrow - used for fast perceived performance.
18-
// Keep hooks and Onyx subscriptions to an absolute minimum; add new ones only
19-
// when strictly necessary. UI must stay visually identical to the interactive version.
20-
import React from 'react';
21-
2217
import staticPolicyInfoSelector from './staticPolicyInfoSelector';
2318

19+
type SavedSearchMinimal = {
20+
name: string;
21+
query: string;
22+
pendingAction?: string;
23+
};
24+
2425
function getActiveKey(similarSearchHash: number, hasGroupPolicy: boolean, searches: Record<string, SearchTypeMenuItem>): string {
2526
const reportsSearch = searches[CONST.SEARCH.SEARCH_KEYS.REPORTS];
2627
const expensesSearch = searches[CONST.SEARCH.SEARCH_KEYS.EXPENSES];
@@ -29,10 +30,32 @@ function getActiveKey(similarSearchHash: number, hasGroupPolicy: boolean, search
2930
return candidates.find((entry) => similarSearchHash === entry.similarSearchHash)?.key ?? reportsSearch.key;
3031
}
3132

33+
function getActiveSavedSearch(savedSearches: Record<string, SavedSearchMinimal> | undefined, hash: number, isOffline: boolean): {key: string; title: string} | undefined {
34+
if (!savedSearches) {
35+
return undefined;
36+
}
37+
const entry = Object.entries(savedSearches).find(([key, item]) => {
38+
if (Number(key) !== hash) {
39+
return false;
40+
}
41+
if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) {
42+
return false;
43+
}
44+
return true;
45+
});
46+
if (!entry) {
47+
return undefined;
48+
}
49+
const [key, item] = entry;
50+
return {key, title: item.name || item.query || key};
51+
}
52+
3253
function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) {
3354
const {translate} = useLocalize();
34-
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Receipt', 'Document', 'Pencil']);
55+
const {isOffline} = useNetwork();
56+
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Receipt', 'Document', 'Pencil', 'Bookmark']);
3557
const [policyInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: staticPolicyInfoSelector});
58+
const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES);
3659
const hasGroupPolicy = policyInfo?.hasGroupPolicy ?? false;
3760
const session = useSession();
3861
const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID;
@@ -51,7 +74,12 @@ function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) {
5174
tabs.push({key: submitSearch.key, icon: expensifyIcons.Pencil, title: translate(submitSearch.translationPath)});
5275
}
5376

54-
const activeKey = getActiveKey(queryJSON.similarSearchHash, hasGroupPolicy, suggestedSearches);
77+
const activeSavedSearch = getActiveSavedSearch(savedSearches as Record<string, SavedSearchMinimal> | undefined, queryJSON.hash, isOffline);
78+
if (activeSavedSearch) {
79+
tabs.push({key: activeSavedSearch.key, icon: expensifyIcons.Bookmark, title: activeSavedSearch.title});
80+
}
81+
82+
const activeKey = activeSavedSearch?.key ?? getActiveKey(queryJSON.similarSearchHash, hasGroupPolicy, suggestedSearches);
5583

5684
return (
5785
<SearchTypeMenuNarrowContent
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import {act, render, screen} from '@testing-library/react-native';
2+
import React from 'react';
3+
import Onyx from 'react-native-onyx';
4+
import type * as ReactNavigation from '@react-navigation/native';
5+
import {LocaleContextProvider} from '@components/LocaleContextProvider';
6+
import OnyxListItemProvider from '@components/OnyxListItemProvider';
7+
import useNetwork from '@hooks/useNetwork';
8+
import {buildSearchQueryJSON} from '@libs/SearchQueryUtils';
9+
import StaticSearchTypeMenu from '@pages/Search/SearchPageNarrow/StaticSearchTypeMenu';
10+
import SearchTypeMenuNarrow from '@pages/Search/SearchTypeMenuNarrow';
11+
import CONST from '@src/CONST';
12+
import ONYXKEYS from '@src/ONYXKEYS';
13+
import {translateLocal} from '../../utils/TestHelper';
14+
import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';
15+
16+
jest.mock('@hooks/useTodoCounts', () => ({
17+
__esModule: true,
18+
default: jest.fn(() => ({counts: {}})),
19+
}));
20+
21+
jest.mock('@react-navigation/native', () => {
22+
const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native');
23+
24+
return {
25+
...actualNavigation,
26+
useNavigation: jest.fn(() => ({dispatch: jest.fn()})),
27+
useIsFocused: jest.fn(() => true),
28+
};
29+
});
30+
jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false})));
31+
32+
function Wrapper({children}: {children: React.ReactNode}) {
33+
return (
34+
<OnyxListItemProvider>
35+
<LocaleContextProvider>{children}</LocaleContextProvider>
36+
</OnyxListItemProvider>
37+
);
38+
}
39+
40+
describe('Search saved-search tab highlight', () => {
41+
beforeAll(() => {
42+
Onyx.init({keys: ONYXKEYS});
43+
});
44+
45+
afterEach(async () => {
46+
await act(async () => {
47+
await Onyx.clear();
48+
});
49+
await waitForBatchedUpdatesWithAct();
50+
jest.clearAllMocks();
51+
});
52+
53+
it('keeps saved search selected in static narrow menu when similar hash collides', async () => {
54+
const baseQuery = 'type:expense status:all';
55+
const savedQueryString = `${baseQuery} sortBy:amount`;
56+
const savedQueryJSON = buildSearchQueryJSON(savedQueryString);
57+
58+
if (!savedQueryJSON) {
59+
throw new Error('Failed to build saved query JSON');
60+
}
61+
62+
await act(async () => {
63+
await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, {
64+
[savedQueryJSON.hash]: {
65+
name: 'My saved search',
66+
query: savedQueryString,
67+
},
68+
});
69+
});
70+
71+
render(
72+
<Wrapper>
73+
<StaticSearchTypeMenu queryJSON={savedQueryJSON} />
74+
</Wrapper>,
75+
);
76+
await waitForBatchedUpdatesWithAct();
77+
78+
expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy();
79+
});
80+
81+
it('keeps saved search selected in interactive narrow menu when similar hash collides', async () => {
82+
const baseQuery = 'type:expense status:all';
83+
const savedQueryString = `${baseQuery} sortBy:amount`;
84+
const savedQueryJSON = buildSearchQueryJSON(savedQueryString);
85+
86+
if (!savedQueryJSON) {
87+
throw new Error('Failed to build saved query JSON');
88+
}
89+
90+
await act(async () => {
91+
await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, {
92+
[savedQueryJSON.hash]: {
93+
name: 'My saved search',
94+
query: savedQueryString,
95+
},
96+
});
97+
});
98+
99+
render(
100+
<Wrapper>
101+
<SearchTypeMenuNarrow queryJSON={savedQueryJSON} />
102+
</Wrapper>,
103+
);
104+
await waitForBatchedUpdatesWithAct();
105+
106+
expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy();
107+
});
108+
109+
it('does not show pending-delete saved search as selected in static narrow menu while online', async () => {
110+
const baseQuery = 'type:expense status:all';
111+
const savedQueryString = `${baseQuery} sortBy:amount`;
112+
const savedQueryJSON = buildSearchQueryJSON(savedQueryString);
113+
114+
if (!savedQueryJSON) {
115+
throw new Error('Failed to build saved query JSON');
116+
}
117+
118+
(useNetwork as jest.Mock).mockReturnValue({isOffline: false});
119+
120+
await act(async () => {
121+
await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, {
122+
[savedQueryJSON.hash]: {
123+
name: 'My saved search',
124+
query: savedQueryString,
125+
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
126+
},
127+
});
128+
});
129+
130+
render(
131+
<Wrapper>
132+
<StaticSearchTypeMenu queryJSON={savedQueryJSON} />
133+
</Wrapper>,
134+
);
135+
await waitForBatchedUpdatesWithAct();
136+
137+
expect(screen.queryByRole('tab', {name: 'My saved search'})).toBeNull();
138+
});
139+
140+
it('highlights built-in tab when saved search is pending delete while online (interactive narrow)', async () => {
141+
const baseQuery = 'type:expense status:all';
142+
const savedQueryString = `${baseQuery} sortBy:amount`;
143+
const savedQueryJSON = buildSearchQueryJSON(savedQueryString);
144+
145+
if (!savedQueryJSON) {
146+
throw new Error('Failed to build saved query JSON');
147+
}
148+
149+
(useNetwork as jest.Mock).mockReturnValue({isOffline: false});
150+
151+
await act(async () => {
152+
await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, {
153+
[savedQueryJSON.hash]: {
154+
name: 'My saved search',
155+
query: savedQueryString,
156+
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
157+
},
158+
});
159+
});
160+
161+
render(
162+
<Wrapper>
163+
<SearchTypeMenuNarrow queryJSON={savedQueryJSON} />
164+
</Wrapper>,
165+
);
166+
await waitForBatchedUpdatesWithAct();
167+
168+
expect(screen.queryByRole('tab', {name: 'My saved search'})).toBeNull();
169+
expect(screen.getByRole('tab', {name: translateLocal('search.tabs.expenses'), selected: true})).toBeTruthy();
170+
});
171+
});

0 commit comments

Comments
 (0)