Skip to content
Open
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
15 changes: 13 additions & 2 deletions src/hooks/useSearchTypeMenuSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,18 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams
);

const activeItemIndex = useMemo(() => {
const isSavedSearchActive = hash !== undefined && !!savedSearches && Object.keys(savedSearches).some((key) => Number(key) === hash);
const isSavedSearchActive =
hash !== undefined &&
!!savedSearches &&
Object.entries(savedSearches).some(([key, item]) => {
if (Number(key) !== hash) {
return false;
}
if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) {
return false;
}
return true;
});

if (isSavedSearchActive) {
return -1;
Expand Down Expand Up @@ -169,7 +180,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams
}

return -1;
}, [typeMenuSections, savedSearches, hash, similarSearchHash, sortBy, sortOrder, type]);
}, [typeMenuSections, savedSearches, hash, similarSearchHash, sortBy, sortOrder, type, isOffline]);

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

Expand Down
35 changes: 33 additions & 2 deletions src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {TabSelectorBaseItem} from '@components/TabSelector/types';

import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';

import type {SearchTypeMenuItem} from '@libs/SearchUIUtils';
Expand All @@ -13,6 +14,9 @@ import {SearchTypeMenuNarrowContent} from '@pages/Search/SearchTypeMenuNarrow';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {SaveSearch} from '@src/types/onyx';

import type {OnyxEntry} from 'react-native-onyx';

// Static twin of SearchTypeMenuNarrow - used for fast perceived performance.
// Keep hooks and Onyx subscriptions to an absolute minimum; add new ones only
Expand All @@ -29,10 +33,32 @@ function getActiveKey(similarSearchHash: number, hasGroupPolicy: boolean, search
return candidates.find((entry) => similarSearchHash === entry.similarSearchHash)?.key ?? reportsSearch.key;
}

function getActiveSavedSearch(savedSearches: OnyxEntry<SaveSearch>, hash: number, isOffline: boolean): {key: string; title: string} | undefined {
if (!savedSearches) {
return undefined;
}
const entry = Object.entries(savedSearches).find(([key, item]) => {
if (Number(key) !== hash) {
return false;
}
if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) {
return false;
}
return true;
});
if (!entry) {
return undefined;
}
const [key, item] = entry;
return {key, title: item.name || item.query || key};
}

function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) {
const {translate} = useLocalize();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Receipt', 'Document', 'Pencil']);
const {isOffline} = useNetwork();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Receipt', 'Document', 'Pencil', 'Bookmark']);
const [policyInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: staticPolicyInfoSelector});
const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES);
const hasGroupPolicy = policyInfo?.hasGroupPolicy ?? false;
const session = useSession();
const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID;
Expand All @@ -51,7 +77,12 @@ function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) {
tabs.push({key: submitSearch.key, icon: expensifyIcons.Pencil, title: translate(submitSearch.translationPath)});
}

const activeKey = getActiveKey(queryJSON.similarSearchHash, hasGroupPolicy, suggestedSearches);
const activeSavedSearch = getActiveSavedSearch(savedSearches, queryJSON.hash, isOffline);
if (activeSavedSearch) {
tabs.push({key: activeSavedSearch.key, icon: expensifyIcons.Bookmark, title: activeSavedSearch.title});
}

const activeKey = activeSavedSearch?.key ?? getActiveKey(queryJSON.similarSearchHash, hasGroupPolicy, suggestedSearches);

return (
<SearchTypeMenuNarrowContent
Expand Down
179 changes: 179 additions & 0 deletions tests/ui/components/SearchTypeMenuSavedSearchHighlightTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import {act, render, screen} from '@testing-library/react-native';

import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';

import useNetwork from '@hooks/useNetwork';

import {buildSearchQueryJSON} from '@libs/SearchQueryUtils';

import StaticSearchTypeMenu from '@pages/Search/SearchPageNarrow/StaticSearchTypeMenu';
import SearchTypeMenuNarrow from '@pages/Search/SearchTypeMenuNarrow';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

import type * as ReactNavigation from '@react-navigation/native';

import React from 'react';
import Onyx from 'react-native-onyx';

import {translateLocal} from '../../utils/TestHelper';
import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';

jest.mock('@hooks/useTodoCounts', () => ({
__esModule: true,
default: jest.fn(() => ({counts: {}})),
}));

jest.mock('@react-navigation/native', () => {
const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native');

return {
...actualNavigation,
useNavigation: jest.fn(() => ({dispatch: jest.fn()})),
useIsFocused: jest.fn(() => true),
};
});
jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false})));

function Wrapper({children}: {children: React.ReactNode}) {
return (
<OnyxListItemProvider>
<LocaleContextProvider>{children}</LocaleContextProvider>
</OnyxListItemProvider>
);
}

describe('Search saved-search tab highlight', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
});

afterEach(async () => {
await act(async () => {
await Onyx.clear();
});
await waitForBatchedUpdatesWithAct();
jest.clearAllMocks();
});

it('keeps saved search selected in static narrow menu when similar hash collides', async () => {
const baseQuery = 'type:expense status:all';
const savedQueryString = `${baseQuery} sortBy:amount`;
const savedQueryJSON = buildSearchQueryJSON(savedQueryString);

if (!savedQueryJSON) {
throw new Error('Failed to build saved query JSON');
}

await act(async () => {
await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, {
[savedQueryJSON.hash]: {
name: 'My saved search',
query: savedQueryString,
},
});
});

render(
<Wrapper>
<StaticSearchTypeMenu queryJSON={savedQueryJSON} />
</Wrapper>,
);
await waitForBatchedUpdatesWithAct();

expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy();
});

it('keeps saved search selected in interactive narrow menu when similar hash collides', async () => {
const baseQuery = 'type:expense status:all';
const savedQueryString = `${baseQuery} sortBy:amount`;
const savedQueryJSON = buildSearchQueryJSON(savedQueryString);

if (!savedQueryJSON) {
throw new Error('Failed to build saved query JSON');
}

await act(async () => {
await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, {
[savedQueryJSON.hash]: {
name: 'My saved search',
query: savedQueryString,
},
});
});

render(
<Wrapper>
<SearchTypeMenuNarrow queryJSON={savedQueryJSON} />
</Wrapper>,
);
await waitForBatchedUpdatesWithAct();

expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy();
});

it('does not show pending-delete saved search as selected in static narrow menu while online', async () => {
const baseQuery = 'type:expense status:all';
const savedQueryString = `${baseQuery} sortBy:amount`;
const savedQueryJSON = buildSearchQueryJSON(savedQueryString);

if (!savedQueryJSON) {
throw new Error('Failed to build saved query JSON');
}

jest.mocked(useNetwork).mockReturnValue({isOffline: false});

await act(async () => {
await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, {
[savedQueryJSON.hash]: {
name: 'My saved search',
query: savedQueryString,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
},
});
});

render(
<Wrapper>
<StaticSearchTypeMenu queryJSON={savedQueryJSON} />
</Wrapper>,
);
await waitForBatchedUpdatesWithAct();

expect(screen.queryByRole('tab', {name: 'My saved search'})).toBeNull();
});

it('highlights built-in tab when saved search is pending delete while online (interactive narrow)', async () => {
const baseQuery = 'type:expense status:all';
const savedQueryString = `${baseQuery} sortBy:amount`;
const savedQueryJSON = buildSearchQueryJSON(savedQueryString);

if (!savedQueryJSON) {
throw new Error('Failed to build saved query JSON');
}

jest.mocked(useNetwork).mockReturnValue({isOffline: false});

await act(async () => {
await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, {
[savedQueryJSON.hash]: {
name: 'My saved search',
query: savedQueryString,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
},
});
});

render(
<Wrapper>
<SearchTypeMenuNarrow queryJSON={savedQueryJSON} />
</Wrapper>,
);
await waitForBatchedUpdatesWithAct();

expect(screen.queryByRole('tab', {name: 'My saved search'})).toBeNull();
expect(screen.getByRole('tab', {name: translateLocal('search.tabs.expenses'), selected: true})).toBeTruthy();
});
});
Loading