Skip to content

Commit 329ac8c

Browse files
authored
feat: add new multichain account version for sitecell (#35302)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR adds two new ui components. `MultichainAccountSiteCell` and `MultichainAccountSiteCellToolTip` that uses the new account groups. The PR does not hook up the components and are not testable. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/35302?quickstart=1) ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** Not testable because it is not used. ## **Screenshots/Recordings** ### **After** These are storybook screenshots. <img width="234" height="216" alt="Screenshot 2025-08-21 at 21 12 56" src="https://github.com/user-attachments/assets/d37ab46f-83d4-4dda-823e-96b46c970f1a" /> <img width="426" height="213" alt="Screenshot 2025-08-21 at 21 20 07" src="https://github.com/user-attachments/assets/52ecb16f-f545-4a85-9972-c2599006a656" /> <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.
1 parent 892b20d commit 329ac8c

10 files changed

Lines changed: 2259 additions & 0 deletions
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React from 'react';
2+
import { StoryFn, Meta } from '@storybook/react';
3+
import { MultichainAccountAvatarGroup } from './multichain-avatar-group';
4+
5+
// Reusable avatar data for consistency across stories
6+
const mockAvatar1 = {
7+
avatarValue: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
8+
symbol: 'ETH',
9+
};
10+
11+
const mockAvatar2 = {
12+
avatarValue: '0x123456789abcdef0123456789abcdef012345678',
13+
symbol: 'MATIC',
14+
};
15+
16+
const mockAvatar3 = {
17+
avatarValue: '0xabcdef1234567890abcdef1234567890abcdef12',
18+
symbol: 'BNB',
19+
};
20+
21+
const mockAvatar4 = {
22+
avatarValue: '0x987654321fedcba0987654321fedcba0987654321',
23+
symbol: 'USDC',
24+
};
25+
26+
const mockAvatar5 = {
27+
avatarValue: '0x5555555555555555555555555555555555555555',
28+
symbol: 'DAI',
29+
};
30+
31+
export default {
32+
title: 'Components/MultichainAccounts/MultichainAccountAvatarGroup',
33+
component: MultichainAccountAvatarGroup,
34+
parameters: {
35+
docs: {
36+
description: {
37+
component:
38+
'A component that displays a group of account avatars with configurable limits and styling',
39+
},
40+
},
41+
},
42+
argTypes: {
43+
className: {
44+
control: 'text',
45+
description: 'Additional CSS class name for styling',
46+
},
47+
limit: {
48+
control: { type: 'number', min: 1, max: 10 },
49+
description: 'Maximum number of avatars to display',
50+
},
51+
members: {
52+
control: 'object',
53+
description:
54+
'Array of member objects with avatarValue and optional symbol',
55+
},
56+
},
57+
} as Meta<typeof MultichainAccountAvatarGroup>;
58+
59+
const Template: StoryFn<typeof MultichainAccountAvatarGroup> = (args) => (
60+
<div
61+
style={{
62+
width: '300px',
63+
height: '100px',
64+
display: 'flex',
65+
alignItems: 'center',
66+
justifyContent: 'center',
67+
border: '1px solid #ccc',
68+
borderRadius: '8px',
69+
padding: '16px',
70+
backgroundColor: '#f5f5f5',
71+
}}
72+
>
73+
<MultichainAccountAvatarGroup {...args} />
74+
</div>
75+
);
76+
77+
export const Default = Template.bind({});
78+
Default.args = {
79+
members: [mockAvatar1, mockAvatar2, mockAvatar3],
80+
};
81+
82+
export const WithLimit = Template.bind({});
83+
WithLimit.args = {
84+
members: [mockAvatar1, mockAvatar2, mockAvatar3, mockAvatar4, mockAvatar5],
85+
limit: 3,
86+
};
87+
88+
export const ManyMembers = Template.bind({});
89+
ManyMembers.args = {
90+
members: Array.from({ length: 10 }, (_, index) => ({
91+
avatarValue: `0x${index.toString().padStart(40, '0')}`,
92+
symbol: `TOKEN${index}`,
93+
})),
94+
limit: 4,
95+
};
96+
97+
export const SingleMember = Template.bind({});
98+
SingleMember.args = {
99+
members: [mockAvatar1],
100+
};
101+
102+
export const NoSymbols = Template.bind({});
103+
NoSymbols.args = {
104+
members: [
105+
{ avatarValue: mockAvatar1.avatarValue },
106+
{ avatarValue: mockAvatar2.avatarValue },
107+
{ avatarValue: mockAvatar3.avatarValue },
108+
],
109+
};
110+
111+
export const EmptyGroup = Template.bind({});
112+
EmptyGroup.args = {
113+
members: [],
114+
};
115+
116+
export const WithCustomClassName = Template.bind({});
117+
WithCustomClassName.args = {
118+
members: [mockAvatar1, mockAvatar2, mockAvatar3],
119+
className: 'custom-avatar-group-class',
120+
};
121+
122+
export const HighLimit = Template.bind({});
123+
HighLimit.args = {
124+
members: [mockAvatar1, mockAvatar2, mockAvatar3],
125+
limit: 10,
126+
};
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import React from 'react';
2+
import { renderWithProvider } from '../../../../../test/jest';
3+
import configureStore from '../../../../store/store';
4+
import mockState from '../../../../../test/data/mock-state.json';
5+
import { MultichainAccountAvatarGroup } from './multichain-avatar-group';
6+
7+
const TEST_IDS = {
8+
AVATAR_GROUP: 'avatar-group',
9+
AVATAR_ACCOUNT: (index: number) => `avatar-account-${index}`,
10+
} as const;
11+
12+
describe('MultichainAccountAvatarGroup', () => {
13+
const store = configureStore({
14+
metamask: {
15+
...mockState.metamask,
16+
},
17+
});
18+
19+
const mockAvatar1 = {
20+
avatarValue: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
21+
symbol: 'ETH',
22+
};
23+
24+
const mockAvatar2 = {
25+
avatarValue: '0x123456789abcdef0123456789abcdef012345678',
26+
symbol: 'MATIC',
27+
};
28+
29+
const mockAvatar3 = {
30+
avatarValue: '0xabcdef1234567890abcdef1234567890abcdef12',
31+
symbol: 'BNB',
32+
};
33+
34+
const mockAvatar4 = {
35+
avatarValue: '0x987654321fedcba0987654321fedcba0987654321',
36+
symbol: 'USDC',
37+
};
38+
39+
const mockAvatar5 = {
40+
avatarValue: '0x5555555555555555555555555555555555555555',
41+
symbol: 'DAI',
42+
};
43+
44+
const mockMembers = [mockAvatar1, mockAvatar2, mockAvatar3];
45+
46+
const defaultProps = {
47+
members: mockMembers,
48+
};
49+
50+
it('renders avatar group with correct test id', () => {
51+
const { getByTestId } = renderWithProvider(
52+
<MultichainAccountAvatarGroup {...defaultProps} />,
53+
store,
54+
);
55+
56+
const avatarGroup = getByTestId(TEST_IDS.AVATAR_GROUP);
57+
expect(avatarGroup).toBeInTheDocument();
58+
});
59+
60+
it('renders correct number of avatars based on limit', () => {
61+
const { getByTestId } = renderWithProvider(
62+
<MultichainAccountAvatarGroup {...defaultProps} limit={2} />,
63+
store,
64+
);
65+
66+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(0))).toBeInTheDocument();
67+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(1))).toBeInTheDocument();
68+
expect(() => getByTestId(TEST_IDS.AVATAR_ACCOUNT(2))).toThrow();
69+
});
70+
71+
it('renders all avatars when limit is greater than members count', () => {
72+
const { getByTestId } = renderWithProvider(
73+
<MultichainAccountAvatarGroup {...defaultProps} limit={10} />,
74+
store,
75+
);
76+
77+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(0))).toBeInTheDocument();
78+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(1))).toBeInTheDocument();
79+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(2))).toBeInTheDocument();
80+
expect(() => getByTestId(TEST_IDS.AVATAR_ACCOUNT(3))).toThrow();
81+
});
82+
83+
it('renders avatars in reverse order', () => {
84+
const { getByTestId } = renderWithProvider(
85+
<MultichainAccountAvatarGroup {...defaultProps} />,
86+
store,
87+
);
88+
89+
// The component reverses the order, so the first rendered avatar should be the last member
90+
const firstRenderedAvatar = getByTestId(TEST_IDS.AVATAR_ACCOUNT(0));
91+
expect(firstRenderedAvatar).toBeInTheDocument();
92+
});
93+
94+
it('applies custom className', () => {
95+
const customClassName = 'custom-avatar-group';
96+
const { getByTestId } = renderWithProvider(
97+
<MultichainAccountAvatarGroup
98+
{...defaultProps}
99+
className={customClassName}
100+
/>,
101+
store,
102+
);
103+
104+
const avatarGroup = getByTestId(TEST_IDS.AVATAR_GROUP);
105+
expect(avatarGroup).toHaveClass(customClassName);
106+
});
107+
108+
it('renders with default limit when not provided', () => {
109+
const { getByTestId } = renderWithProvider(
110+
<MultichainAccountAvatarGroup {...defaultProps} />,
111+
store,
112+
);
113+
114+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(0))).toBeInTheDocument();
115+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(1))).toBeInTheDocument();
116+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(2))).toBeInTheDocument();
117+
expect(() => getByTestId(TEST_IDS.AVATAR_ACCOUNT(3))).toThrow();
118+
});
119+
120+
it('renders empty avatar group when no members provided', () => {
121+
const { getByTestId } = renderWithProvider(
122+
<MultichainAccountAvatarGroup members={[]} />,
123+
store,
124+
);
125+
126+
const avatarGroup = getByTestId(TEST_IDS.AVATAR_GROUP);
127+
expect(avatarGroup).toBeInTheDocument();
128+
129+
expect(() => getByTestId(TEST_IDS.AVATAR_ACCOUNT(0))).toThrow();
130+
});
131+
132+
it('handles members with only avatarValue (no symbol)', () => {
133+
const membersWithoutSymbol = [
134+
{
135+
avatarValue: mockAvatar1.avatarValue,
136+
},
137+
{
138+
avatarValue: mockAvatar2.avatarValue,
139+
},
140+
];
141+
142+
const { getByTestId } = renderWithProvider(
143+
<MultichainAccountAvatarGroup members={membersWithoutSymbol} />,
144+
store,
145+
);
146+
147+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(0))).toBeInTheDocument();
148+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(1))).toBeInTheDocument();
149+
expect(() => getByTestId(TEST_IDS.AVATAR_ACCOUNT(2))).toThrow();
150+
});
151+
152+
it('handles large number of members with limit', () => {
153+
const manyMembers = [
154+
mockAvatar1,
155+
mockAvatar2,
156+
mockAvatar3,
157+
mockAvatar4,
158+
mockAvatar5,
159+
];
160+
161+
const { getByTestId } = renderWithProvider(
162+
<MultichainAccountAvatarGroup members={manyMembers} limit={4} />,
163+
store,
164+
);
165+
166+
// Should render only 4 avatars due to limit
167+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(0))).toBeInTheDocument();
168+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(1))).toBeInTheDocument();
169+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(2))).toBeInTheDocument();
170+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(3))).toBeInTheDocument();
171+
expect(() => getByTestId(TEST_IDS.AVATAR_ACCOUNT(4))).toThrow();
172+
});
173+
174+
it('handles many members with generated data', () => {
175+
const manyMembers = Array.from({ length: 10 }, (_, index) => ({
176+
avatarValue: `0x${index.toString().padStart(40, '0')}`,
177+
symbol: `TOKEN${index}`,
178+
}));
179+
180+
const { getByTestId } = renderWithProvider(
181+
<MultichainAccountAvatarGroup members={manyMembers} limit={4} />,
182+
store,
183+
);
184+
185+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(0))).toBeInTheDocument();
186+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(1))).toBeInTheDocument();
187+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(2))).toBeInTheDocument();
188+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(3))).toBeInTheDocument();
189+
expect(() => getByTestId(TEST_IDS.AVATAR_ACCOUNT(4))).toThrow();
190+
});
191+
192+
it('respects default limit of 4 when members exceed limit', () => {
193+
const manyMembers = [
194+
mockAvatar1,
195+
mockAvatar2,
196+
mockAvatar3,
197+
mockAvatar4,
198+
mockAvatar5,
199+
];
200+
201+
const { getByTestId } = renderWithProvider(
202+
<MultichainAccountAvatarGroup members={manyMembers} />,
203+
store,
204+
);
205+
206+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(0))).toBeInTheDocument();
207+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(1))).toBeInTheDocument();
208+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(2))).toBeInTheDocument();
209+
expect(getByTestId(TEST_IDS.AVATAR_ACCOUNT(3))).toBeInTheDocument();
210+
expect(() => getByTestId(TEST_IDS.AVATAR_ACCOUNT(4))).toThrow();
211+
});
212+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React from 'react';
2+
import classnames from 'classnames';
3+
import {
4+
AlignItems,
5+
BorderRadius,
6+
Display,
7+
} from '../../../../helpers/constants/design-system';
8+
import {
9+
AvatarAccount,
10+
AvatarAccountSize,
11+
AvatarAccountVariant,
12+
Box,
13+
} from '../../../component-library';
14+
15+
type MultichainAccountAvatarGroupProps = {
16+
className?: string;
17+
limit?: number;
18+
members: { avatarValue: string; symbol?: string }[];
19+
};
20+
21+
export const MultichainAccountAvatarGroup: React.FC<
22+
MultichainAccountAvatarGroupProps
23+
> = ({ className = '', limit = 4, members = [] }): JSX.Element => {
24+
const visibleMembers = members.slice(0, limit).reverse();
25+
26+
return (
27+
<Box
28+
alignItems={AlignItems.center}
29+
display={Display.Flex}
30+
className={classnames('multichain-avatar-group', className)}
31+
data-testid="avatar-group"
32+
gap={1}
33+
>
34+
<Box display={Display.Flex} alignItems={AlignItems.center} gap={1}>
35+
{visibleMembers.map((member, i) => {
36+
return (
37+
<Box borderRadius={BorderRadius.full} key={i}>
38+
<AvatarAccount
39+
data-testid={`avatar-account-${i}`}
40+
size={AvatarAccountSize.Xs}
41+
address={member.avatarValue}
42+
// TODO: Switch to maskicon once it is available
43+
variant={AvatarAccountVariant.Blockies}
44+
/>
45+
</Box>
46+
);
47+
})}
48+
</Box>
49+
</Box>
50+
);
51+
};

0 commit comments

Comments
 (0)