Skip to content

Commit 5216163

Browse files
committed
chore(suite-desktop): improve coverage with analytics for yield
1 parent ac467a7 commit 5216163

15 files changed

Lines changed: 258 additions & 26 deletions

File tree

packages/suite/src/actions/wallet/unwrapNativeTokenThunks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export const submitUnwrapNativeTokenThunk = createThunk(
127127
dispatch(
128128
notificationsActions.addToast({
129129
type: 'tx-unwrap',
130+
isYieldFlowStep: !!yieldFlow,
130131
descriptor: account.descriptor,
131132
symbol: account.symbol,
132133
txid: sendResult.txid,

packages/suite/src/actions/wallet/wrapNativeTokenThunks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export const submitWrapNativeTokenThunk = createThunk(
164164
dispatch(
165165
notificationsActions.addToast({
166166
type: 'tx-wrap',
167+
isYieldFlowStep: !!yieldFlow,
167168
descriptor: account.descriptor,
168169
symbol: account.symbol,
169170
txid: sendResult.txid,

packages/suite/src/components/earn/yield/common/useWrappedNativeFlowAnalytics.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,25 @@ describe('useWrappedNativeFlowAnalytics', () => {
5454
);
5555
});
5656

57+
it.each([
58+
['wrap', 'wrap-max'],
59+
['unwrap', 'unwrap-max'],
60+
] as const)('reportMaxClick fires %s-max on the interaction event', (flowType, element) => {
61+
const { result } = renderFlowAnalytics({
62+
flowType,
63+
status: null,
64+
txid: null,
65+
networkSymbol: 'eth',
66+
});
67+
68+
act(() => result.current.reportMaxClick());
69+
70+
expect(mockReport).toHaveBeenCalledWith({
71+
type: events.yieldInteractionEvent.name,
72+
payload: { element, networkSymbol: 'eth' },
73+
});
74+
});
75+
5776
it('reports success with a duration when the broadcast confirms', () => {
5877
const { rerender } = renderFlowAnalytics(pendingWrap);
5978

packages/suite/src/components/earn/yield/common/useWrappedNativeFlowAnalytics.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type UseWrappedNativeFlowAnalyticsParams = {
2121
/**
2222
* Fires the `yield/wrap` / `yield/unwrap` events for the standalone wrap/unwrap flows: `submit` via
2323
* the returned `reportSubmit`, `success` / `error` on-chain, `leftPending` if the user leaves first.
24+
* `reportMaxClick` fires the max-button `yield/interaction` event.
2425
*/
2526
export const useWrappedNativeFlowAnalytics = ({
2627
flowType,
@@ -47,6 +48,17 @@ export const useWrappedNativeFlowAnalytics = ({
4748
report({ type: 'submit', action: 'continue', networkSymbol });
4849
}, [report, networkSymbol]);
4950

51+
// No `vaultId` — that is what separates these from the in-flow deposit-max / withdraw-max.
52+
const reportMaxClick = useCallback(() => {
53+
analytics.report({
54+
type: events.yieldInteractionEvent.name,
55+
payload: {
56+
element: flowType === 'wrap' ? 'wrap-max' : 'unwrap-max',
57+
networkSymbol,
58+
},
59+
});
60+
}, [analytics, flowType, networkSymbol]);
61+
5062
// Timed in an effect rather than during render so the hook stays pure.
5163
useEffect(() => {
5264
if (txid && startRef.current?.txid !== txid) {
@@ -95,5 +107,5 @@ export const useWrappedNativeFlowAnalytics = ({
95107
[report, latestRef],
96108
);
97109

98-
return { reportSubmit };
110+
return { reportSubmit, reportMaxClick };
99111
};

packages/suite/src/components/earn/yield/hooks/useYieldFiatInput.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useForm } from 'react-hook-form';
22

33
import { act, renderHook } from '@testing-library/react';
44

5+
import { events } from '@suite-common/analytics';
56
import { type YieldFlowFormValues } from '@suite-common/wallet-core';
67

78
import { useYieldFiatInput } from './useYieldFiatInput';
@@ -15,21 +16,38 @@ const mockState = {
1516
},
1617
};
1718

19+
const mockReport = jest.fn();
20+
1821
jest.mock('src/hooks/suite', () => ({
1922
useSelector: (selector: (state: unknown) => unknown) => selector(mockState),
2023
}));
2124

22-
const renderYieldFiatInput = () =>
25+
jest.mock('@suite-common/dependency-injection', () => {
26+
const analytics = { report: (...args: unknown[]) => mockReport(...args) };
27+
28+
return { useServices: () => ({ analytics }) };
29+
});
30+
31+
jest.mock('@suite/analytics', () => ({ selectDesktopAnalyticsDep: () => ({}) }));
32+
33+
const renderYieldFiatInput = (vaultId?: string) =>
2334
renderHook(() => {
2435
const methods = useForm<YieldFlowFormValues>({
2536
mode: 'onChange',
2637
defaultValues: { amountInput: '', fiatInput: '' },
2738
});
2839

29-
return { methods, fiat: useYieldFiatInput({ methods, symbol: 'eth', decimals: 18 }) };
40+
return {
41+
methods,
42+
fiat: useYieldFiatInput({ methods, symbol: 'eth', decimals: 18, vaultId }),
43+
};
3044
});
3145

3246
describe('useYieldFiatInput', () => {
47+
beforeEach(() => {
48+
jest.clearAllMocks();
49+
});
50+
3351
it('offers the fiat switch when a rate is available', () => {
3452
const { result } = renderYieldFiatInput();
3553

@@ -69,4 +87,37 @@ describe('useYieldFiatInput', () => {
6987

7088
expect(result.current.methods.getValues('amountInput')).toBe('0.099998500007499963');
7189
});
90+
91+
it('reports the unit switched to, in both directions', () => {
92+
const { result } = renderYieldFiatInput();
93+
94+
act(() => result.current.fiat.fiatToggle?.onToggle());
95+
act(() => result.current.fiat.fiatToggle?.onToggle());
96+
97+
expect(mockReport.mock.calls.map(([event]) => event.payload.value)).toEqual([
98+
'fiat',
99+
'crypto',
100+
]);
101+
expect(mockReport).toHaveBeenCalledWith({
102+
type: events.yieldInteractionEvent.name,
103+
payload: {
104+
element: 'amount-currency-toggle',
105+
value: 'fiat',
106+
networkSymbol: 'eth',
107+
vaultId: undefined,
108+
},
109+
});
110+
});
111+
112+
it('carries the vault id when the amount belongs to a vault flow', () => {
113+
const { result } = renderYieldFiatInput('morpho-weth');
114+
115+
act(() => result.current.fiat.fiatToggle?.onToggle());
116+
117+
expect(mockReport).toHaveBeenCalledWith(
118+
expect.objectContaining({
119+
payload: expect.objectContaining({ vaultId: 'morpho-weth' }),
120+
}),
121+
);
122+
});
72123
});

packages/suite/src/components/earn/yield/hooks/useYieldFiatInput.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { useCallback, useEffect, useMemo, useState } from 'react';
22
import { type UseFormReturn, useWatch } from 'react-hook-form';
33

4+
import { selectDesktopAnalyticsDep } from '@suite/analytics';
5+
import { events } from '@suite-common/analytics';
6+
import { useServices } from '@suite-common/dependency-injection';
47
import { type NetworkSymbol } from '@suite-common/wallet-config';
58
import {
69
type YieldFlowFormValues,
@@ -28,6 +31,7 @@ type UseYieldFiatInputParams = {
2831
symbol: NetworkSymbol | undefined;
2932
tokenAddress?: TokenAddress;
3033
decimals: number;
34+
vaultId?: string;
3135
};
3236

3337
export type UseYieldFiatInputResult = {
@@ -46,7 +50,9 @@ export const useYieldFiatInput = ({
4650
symbol,
4751
tokenAddress,
4852
decimals,
53+
vaultId,
4954
}: UseYieldFiatInputParams): UseYieldFiatInputResult => {
55+
const { analytics } = useServices(selectDesktopAnalyticsDep);
5056
const baseCurrencyCode = useSelector(selectBaseCurrency);
5157
const currentFiatRates = useSelector(selectCurrentFiatRates);
5258
const [currency, setCurrency] = useState<YieldCurrency>('crypto');
@@ -96,11 +102,28 @@ export const useYieldFiatInput = ({
96102
[currentRate, decimals, methods],
97103
);
98104

105+
// Derived from `currency` rather than the `setCurrency` updater, which React may invoke twice.
106+
const onToggle = useCallback(() => {
107+
const nextCurrency: YieldCurrency = currency === 'crypto' ? 'fiat' : 'crypto';
108+
109+
analytics.report({
110+
type: events.yieldInteractionEvent.name,
111+
payload: {
112+
element: 'amount-currency-toggle',
113+
value: nextCurrency,
114+
networkSymbol: symbol,
115+
vaultId,
116+
},
117+
});
118+
119+
setCurrency(nextCurrency);
120+
}, [analytics, currency, symbol, vaultId]);
121+
99122
const fiatToggle: YieldAmountCardFiatToggleProps | undefined = hasFiatRate
100123
? {
101124
currency,
102125
fiatSymbol,
103-
onToggle: () => setCurrency(prev => (prev === 'crypto' ? 'fiat' : 'crypto')),
126+
onToggle,
104127
onFiatAmountChange,
105128
}
106129
: undefined;

packages/suite/src/components/earn/yield/hooks/useYieldFlow.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export const useYieldFlow = ({
189189
symbol: rateToken?.symbol,
190190
tokenAddress: rateToken?.tokenAddress,
191191
decimals: token?.decimals ?? getNetwork(account.symbol).decimals,
192+
vaultId: vault.id,
192193
});
193194

194195
const getMaxAmount = () => {

packages/suite/src/components/earn/yield/unwrap/UnwrapNativeToken.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const UnwrapNativeToken = ({
7575

7676
const pendingTxStatus = useWrappedNativePendingTx(account, broadcast?.txid ?? null, 'unwrap');
7777

78-
const { reportSubmit } = useWrappedNativeFlowAnalytics({
78+
const { reportSubmit, reportMaxClick } = useWrappedNativeFlowAnalytics({
7979
flowType: 'unwrap',
8080
status: pendingTxStatus,
8181
txid: broadcast?.txid ?? null,
@@ -147,6 +147,12 @@ export const UnwrapNativeToken = ({
147147
unwrapMutation.mutate(unwrapAmount);
148148
});
149149

150+
const handleMaxClick = () => {
151+
reportMaxClick();
152+
153+
setMaxAmount(tokenBalance);
154+
};
155+
150156
const openTxDetail = (txid: string) => {
151157
dispatch(
152158
openModal({
@@ -223,7 +229,7 @@ export const UnwrapNativeToken = ({
223229
: undefined
224230
}
225231
fiatToggle={fiatToggle}
226-
onMaxClick={() => setMaxAmount(tokenBalance)}
232+
onMaxClick={handleMaxClick}
227233
onSubmit={handleSubmit}
228234
onPendingTxClick={openTxDetail}
229235
/>

packages/suite/src/components/earn/yield/wrap/WrapNativeToken.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const WrapNativeToken = ({ account, token }: WrapNativeTokenProps) => {
6161

6262
const pendingTxStatus = useWrappedNativePendingTx(account, broadcast?.txid ?? null, 'wrap');
6363

64-
const { reportSubmit } = useWrappedNativeFlowAnalytics({
64+
const { reportSubmit, reportMaxClick } = useWrappedNativeFlowAnalytics({
6565
flowType: 'wrap',
6666
status: pendingTxStatus,
6767
txid: broadcast?.txid ?? null,
@@ -127,6 +127,12 @@ export const WrapNativeToken = ({ account, token }: WrapNativeTokenProps) => {
127127
wrapMutation.mutate(wrapAmount);
128128
});
129129

130+
const handleMaxClick = () => {
131+
reportMaxClick();
132+
133+
setMaxAmount(maxWrapAmount);
134+
};
135+
130136
const openTxDetail = (txid: string) => {
131137
dispatch(
132138
openModal({
@@ -218,7 +224,7 @@ export const WrapNativeToken = ({ account, token }: WrapNativeTokenProps) => {
218224
: undefined
219225
}
220226
fiatToggle={fiatToggle}
221-
onMaxClick={() => setMaxAmount(maxWrapAmount)}
227+
onMaxClick={handleMaxClick}
222228
onSubmit={handleSubmit}
223229
onPendingTxClick={openTxDetail}
224230
/>

packages/suite/src/components/suite/notifications/NotificationRenderer/NotificationRenderer.test.tsx

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import '@suite-common/test-utils/globalOverrides';
22

33
import { Translation } from '@suite/intl';
4-
import { configureMockStore, screen } from '@suite-common/test-utils';
4+
import { events } from '@suite-common/analytics';
5+
import { configureMockStore, fireEvent, screen } from '@suite-common/test-utils';
56
import { type NotificationEntry } from '@suite-common/toast-notifications';
67

78
import { renderWithProviders } from 'src/support/test-utils/hooksHelper';
@@ -12,11 +13,21 @@ import { mockInitialAppState } from '../../../../../mocks/mockInitialAppState';
1213
import { type NotificationViewProps } from '../Notifications/NotificationGroup/NotificationList/NotificationView';
1314

1415
type TradingErrorNotification = Extract<NotificationEntry, { type: 'trading-error' }>;
16+
type WrapNotification = Extract<NotificationEntry, { type: 'tx-wrap' | 'tx-unwrap' }>;
17+
18+
const mockReport = jest.fn();
1519

1620
const MessageView = ({ message, messageValues }: NotificationViewProps) => (
1721
<Translation id={message} values={messageValues} />
1822
);
1923

24+
// Stands in for ToastNotificationView, the only view that wires `onCancel`.
25+
const DismissableView = ({ onCancel }: NotificationViewProps & { onCancel?: () => void }) => (
26+
<button type="button" onClick={onCancel}>
27+
dismiss
28+
</button>
29+
);
30+
2031
const renderTradingError = (payload: Omit<TradingErrorNotification, 'context' | 'id'>) => {
2132
const notification: TradingErrorNotification = { context: 'toast', id: 0, ...payload };
2233
const store = configureMockStore({
@@ -31,6 +42,64 @@ const renderTradingError = (payload: Omit<TradingErrorNotification, 'context' |
3142
);
3243
};
3344

45+
const renderWrapToast = (payload: Omit<WrapNotification, 'context' | 'id'>) => {
46+
const notification = { context: 'toast', id: 0, ...payload } as WrapNotification;
47+
const store = configureMockStore({
48+
preloadedState: mockInitialAppState,
49+
serializableCheck: { ignoredActions: [] },
50+
});
51+
const services = {
52+
...extraDependenciesDesktopMock.services,
53+
analytics: { ...extraDependenciesDesktopMock.services.analytics, report: mockReport },
54+
};
55+
56+
renderWithProviders(
57+
store,
58+
services,
59+
<NotificationRenderer render={DismissableView} notification={notification} />,
60+
);
61+
62+
fireEvent.click(screen.getByText('dismiss'));
63+
};
64+
65+
const wrapMetadata = {
66+
send: { symbol: 'eth', displaySymbol: 'ETH', amount: '1' },
67+
receive: { symbol: 'eth', displaySymbol: 'WETH', amount: '1' },
68+
} as const;
69+
70+
const wrapToastPayload = {
71+
type: 'tx-wrap',
72+
metadata: wrapMetadata,
73+
descriptor: '0xdescriptor',
74+
symbol: 'eth',
75+
txid: '0xwrap',
76+
formattedAmount: '1',
77+
} as const;
78+
79+
describe('NotificationRenderer wrap toast dismissal', () => {
80+
beforeEach(() => {
81+
jest.clearAllMocks();
82+
});
83+
84+
it.each([
85+
['tx-wrap', 'yieldWrapEvent'],
86+
['tx-unwrap', 'yieldUnwrapEvent'],
87+
] as const)('reports %s dismissal as sent/close', (type, eventKey) => {
88+
renderWrapToast({ ...wrapToastPayload, type });
89+
90+
expect(mockReport).toHaveBeenCalledWith({
91+
type: events[eventKey].name,
92+
payload: { type: 'sent', action: 'close', networkSymbol: 'eth' },
93+
});
94+
});
95+
96+
it('stays silent for an in-flow yield step', () => {
97+
renderWrapToast({ ...wrapToastPayload, isYieldFlowStep: true });
98+
99+
expect(mockReport).not.toHaveBeenCalled();
100+
});
101+
});
102+
34103
describe('NotificationRenderer trading-error', () => {
35104
it('step 1: renders the structured message when data is present, ignoring the partner message', () => {
36105
renderTradingError({

0 commit comments

Comments
 (0)