Skip to content

Commit f643069

Browse files
authored
Merge pull request #97767 from c3024/chaitanya/97583-footer-reset-currency
Fix footer Reset falling back to a stale selected-expense currency
2 parents e271561 + 90eaed1 commit f643069

4 files changed

Lines changed: 189 additions & 5 deletions

File tree

src/components/Search/SearchPageFooter.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ type SearchPageFooterProps = {
4242
isTotalLoading: boolean;
4343

4444
/** Function to call when the footer currency changes */
45-
onCurrencyChange: (currency: string | undefined) => void;
45+
onCurrencyChange: (currency: string) => void;
4646
};
4747

4848
function SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoading, onCurrencyChange}: SearchPageFooterProps) {
@@ -69,11 +69,12 @@ function SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoadi
6969
return;
7070
}
7171

72+
// Reset (no item) selects the default explicitly so figures loaded in another currency get converted to it.
7273
const nextCurrency = item?.value ?? defaultCurrency;
7374
if (!nextCurrency) {
7475
return;
7576
}
76-
onCurrencyChange(nextCurrency === defaultCurrency ? undefined : nextCurrency);
77+
onCurrencyChange(nextCurrency);
7778
};
7879

7980
const renderCurrencyPopup: FilterPopupButtonProps['PopoverComponent'] = ({closeOverlay, isExpanded}) => (

src/components/Search/SearchResultsProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const defaultSearchInfo: SearchResultsInfo = {
3636
isLoading: false,
3737
count: 0,
3838
total: 0,
39-
currency: '',
39+
currency: undefined,
4040
};
4141

4242
function SearchResultsProvider({children}: SearchResultsProviderProps) {

src/components/Search/SearchSelectionFooter.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import useActivePolicy from '@hooks/useActivePolicy';
12
import useNetwork from '@hooks/useNetwork';
23
import useOnyx from '@hooks/useOnyx';
34
import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals';
@@ -88,6 +89,9 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
8889
const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext();
8990
const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true, areAllMatchingItemsSelected);
9091
const {isOffline} = useNetwork();
92+
const activePolicy = useActivePolicy();
93+
// The server converts search figures to the active policy's currency when the query carries no explicit target.
94+
const searchTargetCurrency = activePolicy?.outputCurrency ?? CONST.CURRENCY.USD;
9195
const [footerCurrencyState, setFooterCurrencyState] = useState<FooterCurrencyState>({
9296
searchHash: undefined,
9397
selectedCurrency: undefined,
@@ -377,7 +381,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
377381
]);
378382

379383
const handleFooterCurrencyChange = useCallback(
380-
(currency: string | undefined) => {
384+
(currency: string) => {
381385
setFooterCurrencyState({
382386
searchHash: currentSearchHash,
383387
selectedCurrency: currency,
@@ -464,7 +468,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
464468
count={footerData.count}
465469
total={footerData.total}
466470
currency={footerData.currency}
467-
defaultCurrency={effectiveDefaultCurrency}
471+
defaultCurrency={searchTargetCurrency}
468472
isTotalLoading={isFooterTotalLoading}
469473
onCurrencyChange={handleFooterCurrencyChange}
470474
/>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import {act, render} from '@testing-library/react-native';
2+
3+
import SearchSelectionFooter from '@components/Search/SearchSelectionFooter';
4+
import type {SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types';
5+
6+
import {getFooterConvertedAmounts} from '@libs/actions/Search';
7+
8+
import CONST from '@src/CONST';
9+
import ONYXKEYS from '@src/ONYXKEYS';
10+
import type {SearchResults} from '@src/types/onyx';
11+
12+
import Onyx from 'react-native-onyx';
13+
14+
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';
15+
16+
jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false})));
17+
18+
jest.mock('@hooks/useSearchShouldCalculateTotals', () => jest.fn(() => true));
19+
20+
jest.mock('@libs/actions/Search', () => ({
21+
getFooterConvertedAmounts: jest.fn(),
22+
}));
23+
24+
const mockSearchQueryContext: {current: {currentSearchHash: number; currentSearchKey: undefined; currentSearchQueryJSON: {hash: number; type: string} | undefined}} = {
25+
current: {currentSearchHash: 1, currentSearchKey: undefined, currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE}},
26+
};
27+
const mockSelectedTransactions: {current: SelectedTransactions} = {current: {}};
28+
jest.mock('@components/Search/SearchContext', () => ({
29+
useSearchQueryContext: () => mockSearchQueryContext.current,
30+
useSearchResultsContext: () => ({currentSearchResults: undefined}),
31+
useSearchSelectionContext: () => ({selectedTransactions: mockSelectedTransactions.current, areAllMatchingItemsSelected: false, selectedReports: []}),
32+
}));
33+
34+
type CapturedFooterProps = {defaultCurrency?: string; currency?: string; onCurrencyChange?: (currency: string) => void};
35+
const mockCapturedFooterProps: {current: CapturedFooterProps | undefined} = {current: undefined};
36+
jest.mock('@components/Search/SearchPageFooter', () => ({
37+
__esModule: true,
38+
default: (props: CapturedFooterProps) => {
39+
mockCapturedFooterProps.current = props;
40+
return null;
41+
},
42+
}));
43+
44+
// The currency of the selected expense — deliberately different from every other currency in this test so a leak
45+
// from any wrong fallback source is easy to spot.
46+
const SELECTED_EXPENSE_CURRENCY = 'JPY';
47+
48+
// The Preferences > Payment currency setting, stored as the personal policy's output currency. Deliberately not USD
49+
// so the test can tell the real fallback apart from the USD last resort.
50+
const PAYMENT_CURRENCY = CONST.CURRENCY.GBP;
51+
52+
const ACCOUNT_ID = 1;
53+
const PERSONAL_POLICY_ID = 'personalPolicy1';
54+
const WORKSPACE_POLICY_ID = 'workspacePolicy1';
55+
56+
function buildSearchResults(currency: string | undefined, count = 1): SearchResults {
57+
return {
58+
search: {
59+
count,
60+
currency,
61+
total: -100,
62+
offset: 0,
63+
isLoading: false,
64+
hash: 1,
65+
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
66+
sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE,
67+
sortOrder: CONST.SEARCH.SORT_ORDER.DESC,
68+
hasMoreResults: false,
69+
hasResults: true,
70+
},
71+
data: {},
72+
};
73+
}
74+
75+
function buildSelectedTransaction(currency: string, groupCurrency?: string, groupAmount?: number): SelectedTransactionInfo {
76+
return {
77+
isSelected: true,
78+
canReject: false,
79+
canHold: false,
80+
canSplit: false,
81+
hasBeenSplit: false,
82+
canChangeReport: false,
83+
isHeld: false,
84+
canUnhold: false,
85+
action: CONST.SEARCH.ACTION_TYPES.VIEW,
86+
policyID: undefined,
87+
amount: 100,
88+
currency,
89+
groupCurrency,
90+
groupAmount,
91+
isFromOneTransactionReport: false,
92+
};
93+
}
94+
95+
describe('SearchSelectionFooter', () => {
96+
beforeAll(() => {
97+
Onyx.init({keys: ONYXKEYS});
98+
});
99+
100+
beforeEach(async () => {
101+
mockSearchQueryContext.current = {currentSearchHash: 1, currentSearchKey: undefined, currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE}};
102+
mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY)};
103+
mockCapturedFooterProps.current = undefined;
104+
// Clear here rather than in afterEach: Onyx.clear() there re-renders the previous test's still-mounted
105+
// component (testing-library only unmounts it afterwards), and those renders can record mock calls.
106+
jest.clearAllMocks();
107+
await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID});
108+
// Accounts without a workspace have their personal policy as the active policy.
109+
await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, PERSONAL_POLICY_ID);
110+
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${PERSONAL_POLICY_ID}`, {id: PERSONAL_POLICY_ID, outputCurrency: PAYMENT_CURRENCY});
111+
await waitForBatchedUpdates();
112+
});
113+
114+
afterEach(async () => {
115+
await Onyx.clear();
116+
});
117+
118+
it("offers the user's live payment currency as the Reset target when there is no active workspace", async () => {
119+
// A fresh no-workspace account: the active policy is the personal policy, and the only selected expense
120+
// happens to be in a different currency (JPY) from the live payment currency (GBP).
121+
render(<SearchSelectionFooter searchResults={buildSearchResults(undefined)} />);
122+
await waitForBatchedUpdates();
123+
124+
// The footer's Reset/default currency follows the live payment currency (the personal policy's output
125+
// currency), not the selected expense's own (stale) currency.
126+
expect(mockCapturedFooterProps.current?.defaultCurrency).toBe(PAYMENT_CURRENCY);
127+
});
128+
129+
it("offers the active workspace's currency as the Reset target when one is set", async () => {
130+
// The server converts search figures to the active policy's currency, so with an active workspace the Reset
131+
// target is the workspace currency, not the personal payment currency.
132+
await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, WORKSPACE_POLICY_ID);
133+
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${WORKSPACE_POLICY_ID}`, {id: WORKSPACE_POLICY_ID, outputCurrency: CONST.CURRENCY.EUR});
134+
await waitForBatchedUpdates();
135+
136+
render(<SearchSelectionFooter searchResults={buildSearchResults(CONST.CURRENCY.EUR)} />);
137+
await waitForBatchedUpdates();
138+
139+
expect(mockCapturedFooterProps.current?.defaultCurrency).toBe(CONST.CURRENCY.EUR);
140+
});
141+
142+
it('converts the figures when Reset selects a default the figures are not denominated in', async () => {
143+
// The payment currency changed after the snapshot loaded: the selected group's server-converted figure is
144+
// still denominated in the old payment currency (INR), while the live default is now GBP.
145+
mockSelectedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY, 'INR', -100)};
146+
147+
// A partial selection (1 of 2), so the footer uses the client-side selected total.
148+
render(<SearchSelectionFooter searchResults={buildSearchResults(undefined, 2)} />);
149+
await waitForBatchedUpdates();
150+
151+
// No picker choice yet, so nothing converts.
152+
expect(getFooterConvertedAmounts).not.toHaveBeenCalled();
153+
154+
// Reset passes the default through onCurrencyChange as an explicit selection.
155+
await act(async () => {
156+
mockCapturedFooterProps.current?.onCurrencyChange?.(PAYMENT_CURRENCY);
157+
await waitForBatchedUpdates();
158+
});
159+
160+
// The chosen default differs from the figures' denomination, so a conversion to it is requested.
161+
expect(getFooterConvertedAmounts).toHaveBeenCalledWith(expect.objectContaining({targetCurrency: PAYMENT_CURRENCY}));
162+
});
163+
164+
it('does not convert when Reset selects the currency the figures are already denominated in', async () => {
165+
mockSelectedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY, PAYMENT_CURRENCY, -100)};
166+
167+
render(<SearchSelectionFooter searchResults={buildSearchResults(undefined, 2)} />);
168+
await waitForBatchedUpdates();
169+
170+
await act(async () => {
171+
mockCapturedFooterProps.current?.onCurrencyChange?.(PAYMENT_CURRENCY);
172+
await waitForBatchedUpdates();
173+
});
174+
175+
// The figures are already in the chosen currency, so no request is made and the snapshot data is used as-is.
176+
expect(getFooterConvertedAmounts).not.toHaveBeenCalled();
177+
expect(mockCapturedFooterProps.current?.currency).toBe(PAYMENT_CURRENCY);
178+
});
179+
});

0 commit comments

Comments
 (0)