Skip to content

Commit 352b27f

Browse files
committed
Merge remote-tracking branch 'origin/main' into refactor/5477/v5-compat
2 parents 8ea37a4 + d1385fb commit 352b27f

File tree

28 files changed

+1864
-97
lines changed

28 files changed

+1864
-97
lines changed

app/_locales/en/messages.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/_locales/en_GB/messages.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 4 additions & 0 deletions
Loading

test/e2e/tests/settings/state-logs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,7 @@
10411041
"seasonStatusLoading": "boolean",
10421042
"rewardsEnabled": "boolean",
10431043
"rewardsBadgeHidden": "boolean",
1044+
"accountLinkedTimestamp": "null",
10441045
"errorToast": {
10451046
"actionText": "string",
10461047
"description": "string",
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
import { InternalAccount } from '@metamask/keyring-internal-api';
5+
import { useI18nContext } from '../../../hooks/useI18nContext';
6+
import { useLinkAccountAddress } from '../../../hooks/rewards/useLinkAccountAddress';
7+
import { createMockInternalAccount } from '../../../../test/jest/mocks';
8+
import AddRewardsAccount from './AddRewardsAccount';
9+
10+
// Mock dependencies
11+
jest.mock('../../../hooks/useI18nContext', () => ({
12+
useI18nContext: jest.fn(),
13+
}));
14+
15+
jest.mock('../../../hooks/rewards/useLinkAccountAddress', () => ({
16+
useLinkAccountAddress: jest.fn(),
17+
}));
18+
19+
const mockUseI18nContext = useI18nContext as jest.MockedFunction<
20+
typeof useI18nContext
21+
>;
22+
23+
const mockUseLinkAccountAddress = useLinkAccountAddress as jest.MockedFunction<
24+
typeof useLinkAccountAddress
25+
>;
26+
27+
describe('AddRewardsAccount', () => {
28+
const mockT = jest.fn((key: string) => {
29+
if (key === 'rewardsLinkAccount') {
30+
return 'Link Account';
31+
}
32+
if (key === 'rewardsLinkAccountError') {
33+
return 'Error linking account';
34+
}
35+
if (key === 'rewardsPointsIcon') {
36+
return 'Rewards Points Icon';
37+
}
38+
return key;
39+
});
40+
41+
const mockLinkAccountAddress = jest.fn();
42+
const defaultHookReturn = {
43+
linkAccountAddress: mockLinkAccountAddress,
44+
isLoading: false,
45+
isError: false,
46+
};
47+
48+
let mockAccount: InternalAccount;
49+
50+
beforeEach(() => {
51+
jest.clearAllMocks();
52+
mockUseI18nContext.mockReturnValue(mockT);
53+
mockUseLinkAccountAddress.mockReturnValue(defaultHookReturn);
54+
mockAccount = createMockInternalAccount({
55+
id: 'test-account-1',
56+
address: '0x1234567890123456789012345678901234567890',
57+
name: 'Test Account',
58+
});
59+
});
60+
61+
describe('rendering', () => {
62+
it('should render the component with account', () => {
63+
render(<AddRewardsAccount account={mockAccount} />);
64+
65+
expect(screen.getByText('Link Account')).toBeInTheDocument();
66+
});
67+
68+
it('should render the rewards points icon when not loading', () => {
69+
render(<AddRewardsAccount account={mockAccount} />);
70+
71+
const image = screen.getByAltText('Rewards Points Icon');
72+
expect(image).toBeInTheDocument();
73+
expect(image).toHaveAttribute(
74+
'src',
75+
'./images/metamask-rewards-points-alternative.svg',
76+
);
77+
expect(image).toHaveAttribute('width', '16');
78+
expect(image).toHaveAttribute('height', '16');
79+
});
80+
81+
it('should return null when account is not provided', () => {
82+
const { container } = render(
83+
<AddRewardsAccount account={null as unknown as InternalAccount} />,
84+
);
85+
86+
expect(container.firstChild).toBeNull();
87+
});
88+
89+
it('should call useLinkAccountAddress hook', () => {
90+
render(<AddRewardsAccount account={mockAccount} />);
91+
92+
expect(mockUseLinkAccountAddress).toHaveBeenCalled();
93+
});
94+
95+
it('should call useI18nContext hook', () => {
96+
render(<AddRewardsAccount account={mockAccount} />);
97+
98+
expect(mockUseI18nContext).toHaveBeenCalled();
99+
});
100+
});
101+
102+
describe('loading state', () => {
103+
it('should show loading icon when isLoading is true', () => {
104+
mockUseLinkAccountAddress.mockReturnValue({
105+
...defaultHookReturn,
106+
isLoading: true,
107+
});
108+
109+
const { container } = render(<AddRewardsAccount account={mockAccount} />);
110+
111+
const loadingIcon = container.querySelector('svg');
112+
expect(loadingIcon).toBeInTheDocument();
113+
expect(
114+
screen.queryByAltText('Rewards Points Icon'),
115+
).not.toBeInTheDocument();
116+
});
117+
118+
it('should disable button when isLoading is true', () => {
119+
mockUseLinkAccountAddress.mockReturnValue({
120+
...defaultHookReturn,
121+
isLoading: true,
122+
});
123+
124+
render(<AddRewardsAccount account={mockAccount} />);
125+
126+
const button = screen.getByText('Link Account').closest('button');
127+
expect(button).toBeDisabled();
128+
});
129+
130+
it('should not disable button when isLoading is false', () => {
131+
render(<AddRewardsAccount account={mockAccount} />);
132+
133+
const button = screen.getByText('Link Account').closest('button');
134+
expect(button).not.toBeDisabled();
135+
});
136+
});
137+
138+
describe('error state', () => {
139+
it('should show error icon when isError is true', () => {
140+
mockUseLinkAccountAddress.mockReturnValue({
141+
...defaultHookReturn,
142+
isError: true,
143+
});
144+
145+
const { container } = render(<AddRewardsAccount account={mockAccount} />);
146+
147+
const errorIcons = container.querySelectorAll('svg');
148+
expect(errorIcons.length).toBeGreaterThan(0);
149+
});
150+
151+
it('should show error text when isError is true', () => {
152+
mockUseLinkAccountAddress.mockReturnValue({
153+
...defaultHookReturn,
154+
isError: true,
155+
});
156+
157+
render(<AddRewardsAccount account={mockAccount} />);
158+
159+
expect(screen.getByText('Error linking account')).toBeInTheDocument();
160+
expect(screen.queryByText('Link Account')).not.toBeInTheDocument();
161+
});
162+
163+
it('should not show error icon when isError is false', () => {
164+
const { container } = render(<AddRewardsAccount account={mockAccount} />);
165+
166+
// When not in error state, there should be no SVG icon in endAccessory
167+
// Only the image should be present in startAccessory
168+
const svgIcons = container.querySelectorAll('svg');
169+
expect(svgIcons.length).toBe(0);
170+
});
171+
});
172+
173+
describe('user interaction', () => {
174+
it('should call linkAccountAddress when button is clicked', async () => {
175+
mockLinkAccountAddress.mockResolvedValue(true);
176+
177+
render(<AddRewardsAccount account={mockAccount} />);
178+
179+
const button = screen.getByText('Link Account');
180+
fireEvent.click(button);
181+
182+
await waitFor(() => {
183+
expect(mockLinkAccountAddress).toHaveBeenCalledWith(mockAccount);
184+
});
185+
});
186+
187+
it('should not call linkAccountAddress when account is null', async () => {
188+
mockLinkAccountAddress.mockResolvedValue(true);
189+
190+
const { container } = render(
191+
<AddRewardsAccount account={null as unknown as InternalAccount} />,
192+
);
193+
194+
expect(container.firstChild).toBeNull();
195+
expect(mockLinkAccountAddress).not.toHaveBeenCalled();
196+
});
197+
198+
it('should handle click when linkAccountAddress is called', async () => {
199+
mockLinkAccountAddress.mockResolvedValue(true);
200+
201+
render(<AddRewardsAccount account={mockAccount} />);
202+
203+
const button = screen.getByText('Link Account');
204+
fireEvent.click(button);
205+
206+
await waitFor(() => {
207+
expect(mockLinkAccountAddress).toHaveBeenCalledTimes(1);
208+
});
209+
});
210+
});
211+
212+
describe('combined states', () => {
213+
it('should show loading icon and disable button when loading', () => {
214+
mockUseLinkAccountAddress.mockReturnValue({
215+
...defaultHookReturn,
216+
isLoading: true,
217+
isError: false,
218+
});
219+
220+
const { container } = render(<AddRewardsAccount account={mockAccount} />);
221+
222+
const loadingIcon = container.querySelector('svg');
223+
expect(loadingIcon).toBeInTheDocument();
224+
const button = screen.getByText('Link Account').closest('button');
225+
expect(button).toBeDisabled();
226+
});
227+
228+
it('should show error icon and error text when error occurs', () => {
229+
mockUseLinkAccountAddress.mockReturnValue({
230+
...defaultHookReturn,
231+
isLoading: false,
232+
isError: true,
233+
});
234+
235+
const { container } = render(<AddRewardsAccount account={mockAccount} />);
236+
237+
const errorIcons = container.querySelectorAll('svg');
238+
expect(errorIcons.length).toBeGreaterThan(0);
239+
expect(screen.getByText('Error linking account')).toBeInTheDocument();
240+
});
241+
242+
it('should show normal state when not loading and no error', () => {
243+
const { container } = render(<AddRewardsAccount account={mockAccount} />);
244+
245+
expect(screen.getByAltText('Rewards Points Icon')).toBeInTheDocument();
246+
expect(screen.getByText('Link Account')).toBeInTheDocument();
247+
// No SVG Icon components should be present, only the image
248+
const svgIcons = container.querySelectorAll('svg');
249+
expect(svgIcons.length).toBe(0);
250+
});
251+
});
252+
253+
describe('translation', () => {
254+
it('should call translation function with rewardsLinkAccount key', () => {
255+
render(<AddRewardsAccount account={mockAccount} />);
256+
257+
expect(mockT).toHaveBeenCalledWith('rewardsLinkAccount');
258+
});
259+
260+
it('should call translation function with rewardsLinkAccountError key when error', () => {
261+
mockUseLinkAccountAddress.mockReturnValue({
262+
...defaultHookReturn,
263+
isError: true,
264+
});
265+
266+
render(<AddRewardsAccount account={mockAccount} />);
267+
268+
expect(mockT).toHaveBeenCalledWith('rewardsLinkAccountError');
269+
});
270+
271+
it('should call translation function with rewardsPointsIcon key for image alt', () => {
272+
render(<AddRewardsAccount account={mockAccount} />);
273+
274+
expect(mockT).toHaveBeenCalledWith('rewardsPointsIcon');
275+
});
276+
});
277+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React, { useCallback } from 'react';
2+
import { InternalAccount } from '@metamask/keyring-internal-api';
3+
import {
4+
IconSize,
5+
IconName,
6+
TextButton,
7+
TextVariant,
8+
Icon,
9+
IconColor,
10+
} from '@metamask/design-system-react';
11+
import { useLinkAccountAddress } from '../../../hooks/rewards/useLinkAccountAddress';
12+
import { useI18nContext } from '../../../hooks/useI18nContext';
13+
14+
type AddRewardsAccountProps = {
15+
account: InternalAccount;
16+
};
17+
18+
const AddRewardsAccount: React.FC<AddRewardsAccountProps> = ({ account }) => {
19+
const t = useI18nContext();
20+
const { linkAccountAddress, isLoading, isError } = useLinkAccountAddress();
21+
22+
const handleClick = useCallback(async () => {
23+
if (!account) {
24+
return;
25+
}
26+
27+
await linkAccountAddress(account);
28+
}, [account, linkAccountAddress]);
29+
30+
if (!account) {
31+
return null;
32+
}
33+
34+
return (
35+
<TextButton
36+
onClick={handleClick}
37+
textProps={{ variant: TextVariant.BodySm }}
38+
data-testid="add-rewards-account-button"
39+
startAccessory={
40+
isLoading ? (
41+
<Icon name={IconName.Loading} size={IconSize.Sm} />
42+
) : (
43+
<img
44+
src={'./images/metamask-rewards-points-alternative.svg'}
45+
alt={t('rewardsPointsIcon')}
46+
width={16}
47+
height={16}
48+
/>
49+
)
50+
}
51+
endAccessory={
52+
isError ? (
53+
<Icon
54+
name={IconName.Refresh}
55+
size={IconSize.Sm}
56+
color={IconColor.IconAlternative}
57+
/>
58+
) : undefined
59+
}
60+
disabled={isLoading}
61+
>
62+
{isError ? t('rewardsLinkAccountError') : t('rewardsLinkAccount')}
63+
</TextButton>
64+
);
65+
};
66+
67+
export default AddRewardsAccount;

0 commit comments

Comments
 (0)