Skip to content

Commit d84e29d

Browse files
authored
feat: IdentityCard and Socials components (#1489)
1 parent 5e292c7 commit d84e29d

29 files changed

+1295
-41
lines changed

.changeset/twenty-lies-allow.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@coinbase/onchainkit': patch
3+
---
4+
5+
-**feat**: Added `IdentityCard` and `Socials` components. By @cpcramer #1489

playground/nextjs-app-router/components/AppProvider.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { WalletPreference } from './form/wallet-type';
1010
export enum OnchainKitComponent {
1111
Fund = 'fund',
1212
Identity = 'identity',
13+
IdentityCard = 'identity-card',
1314
Checkout = 'checkout',
1415
Swap = 'swap',
1516
SwapDefault = 'swap-default',

playground/nextjs-app-router/components/Demo.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import DemoOptions from './DemoOptions';
66
import CheckoutDemo from './demo/Checkout';
77
import FundDemo from './demo/Fund';
88
import IdentityDemo from './demo/Identity';
9+
import { IdentityCardDemo } from './demo/IdentityCard';
910
import NFTCardDemo from './demo/NFTCard';
1011
import NFTMintCardDemo from './demo/NFTMintCard';
1112
import SwapDemo from './demo/Swap';
@@ -88,6 +89,10 @@ function Demo() {
8889
return <NFTCardDemo />;
8990
}
9091

92+
if (activeComponent === OnchainKitComponent.IdentityCard) {
93+
return <IdentityCardDemo />;
94+
}
95+
9196
return <></>;
9297
}
9398

playground/nextjs-app-router/components/demo/Identity.tsx

+8-5
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import {
44
Badge,
55
Identity,
66
Name,
7+
Socials,
78
useAddress,
89
useAvatar,
910
useName,
1011
} from '@coinbase/onchainkit/identity';
1112
import { useEffect } from 'react';
12-
import { base } from 'viem/chains';
13+
import { base, mainnet } from 'viem/chains';
1314
import { useAccount } from 'wagmi';
1415

1516
export default function IdentityDemo() {
@@ -50,24 +51,26 @@ export default function IdentityDemo() {
5051
Default Chain
5152
</h2>
5253
<div className="flex items-center space-x-4">
53-
<Identity address={address}>
54+
<Identity address={address} chain={mainnet}>
5455
<Avatar />
5556
<Name>
5657
<Badge />
5758
</Name>
5859
<Address />
60+
<Socials />
5961
</Identity>
6062
</div>
6163
</div>
6264
<div className="space-y-2">
6365
<h2 className="font-medium text-gray-500 text-sm">Base Chain</h2>
6466
<div className="flex items-center space-x-4">
65-
<Identity address={address}>
66-
<Avatar chain={base} />
67-
<Name chain={base}>
67+
<Identity address={address} chain={base}>
68+
<Avatar />
69+
<Name>
6870
<Badge />
6971
</Name>
7072
<Address />
73+
<Socials />
7174
</Identity>
7275
</div>
7376
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use client';
2+
3+
import { IdentityCard } from '@coinbase/onchainkit/identity';
4+
import { base, mainnet } from 'viem/chains';
5+
import { useAccount } from 'wagmi';
6+
7+
export function IdentityCardDemo() {
8+
const { address } = useAccount();
9+
10+
if (!address) {
11+
return null;
12+
}
13+
14+
return (
15+
<div className="mx-auto max-w-2xl p-4">
16+
<div className="grid grid-cols-2 gap-6">
17+
<div className="space-y-2">
18+
<h2 className="font-medium text-gray-500 text-sm">
19+
Mainnet Identity
20+
</h2>
21+
<IdentityCard address={address} chain={mainnet} />
22+
</div>
23+
<div className="space-y-2">
24+
<h2 className="font-medium text-gray-500 text-sm">Base Identity</h2>
25+
<IdentityCard address={address} chain={base} />
26+
</div>
27+
</div>
28+
</div>
29+
);
30+
}

playground/nextjs-app-router/components/demo/Wallet.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
EthBalance,
55
Identity,
66
Name,
7+
Socials,
78
} from '@coinbase/onchainkit/identity';
89
import { color } from '@coinbase/onchainkit/theme';
910
import {
@@ -28,11 +29,12 @@ function WalletComponent() {
2829
<Name />
2930
</ConnectWallet>
3031
<WalletDropdown>
31-
<Identity className="px-4 pt-3 pb-2" hasCopyAddressOnClick={true}>
32+
<Identity className="px-4 pt-3 pb-2" hasCopyAddressOnClick={false}>
3233
<Avatar />
3334
<Name />
3435
<Address className={color.foregroundMuted} />
3536
<EthBalance />
37+
<Socials />
3638
</Identity>
3739
<WalletDropdownBasename />
3840
<WalletDropdownLink

playground/nextjs-app-router/components/form/active-component.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export function ActiveComponent() {
2727
<SelectContent>
2828
<SelectItem value={OnchainKitComponent.Fund}>Fund</SelectItem>
2929
<SelectItem value={OnchainKitComponent.Identity}>Identity</SelectItem>
30+
<SelectItem value={OnchainKitComponent.IdentityCard}>
31+
IdentityCard
32+
</SelectItem>
3033
<SelectItem value={OnchainKitComponent.Checkout}>Checkout</SelectItem>
3134
<SelectItem value={OnchainKitComponent.Transaction}>
3235
Transaction
+125-16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { fireEvent, render, screen } from '@testing-library/react';
22
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
33
import '@testing-library/jest-dom';
44
import { getSlicedAddress } from '../utils/getSlicedAddress';
@@ -14,74 +14,117 @@ vi.mock('../utils/getSlicedAddress', () => ({
1414
}));
1515

1616
vi.mock('./IdentityProvider', () => ({
17-
useIdentityContext: vi.fn(),
17+
useIdentityContext: vi.fn(() => ({
18+
address: undefined,
19+
ensName: undefined,
20+
loading: false,
21+
error: null,
22+
})),
1823
}));
1924

2025
const useIdentityContextMock = mock(useIdentityContext);
2126

2227
const mockGetSlicedAddress = (addr: string) =>
2328
`${addr.slice(0, 5)}...${addr.slice(-4)}`;
2429

30+
const mockClipboard = {
31+
writeText: vi.fn().mockResolvedValue(undefined),
32+
};
33+
34+
Object.defineProperty(navigator, 'clipboard', {
35+
value: mockClipboard,
36+
configurable: true,
37+
});
38+
2539
describe('Address component', () => {
2640
const testIdentityProviderAddress = '0xIdentityAddress';
2741
const testAddressComponentAddress =
2842
'0x1234567890abcdef1234567890abcdef12345678';
2943

3044
beforeEach(() => {
3145
vi.clearAllMocks();
46+
mockClipboard.writeText.mockClear();
47+
useIdentityContextMock.mockReturnValue({
48+
address: undefined,
49+
ensName: undefined,
50+
loading: false,
51+
error: null,
52+
});
3253
});
3354

3455
it('should console.error and return null when no address is provided', () => {
35-
vi.mocked(useIdentityContext).mockReturnValue({});
56+
useIdentityContextMock.mockReturnValue({
57+
address: undefined,
58+
ensName: undefined,
59+
loading: false,
60+
error: null,
61+
});
62+
3663
const consoleErrorSpy = vi
3764
.spyOn(console, 'error')
3865
.mockImplementation(() => {});
3966
const { container } = render(<Address />);
67+
4068
expect(consoleErrorSpy).toHaveBeenCalledWith(
4169
'Address: an Ethereum address must be provided to the Identity or Address component.',
4270
);
4371
expect(container.firstChild).toBeNull();
72+
73+
consoleErrorSpy.mockRestore();
4474
});
4575

4676
it('renders the sliced address when address supplied to Identity', () => {
4777
useIdentityContextMock.mockReturnValue({
4878
address: testAddressComponentAddress,
79+
ensName: undefined,
80+
loading: false,
81+
error: null,
4982
});
5083
(getSlicedAddress as Mock).mockReturnValue(
5184
mockGetSlicedAddress(testAddressComponentAddress),
5285
);
5386

54-
const { getByText } = render(<Address />);
87+
render(<Address />);
88+
expect(getSlicedAddress).toHaveBeenCalledWith(testAddressComponentAddress);
5589
expect(
56-
getByText(mockGetSlicedAddress(testAddressComponentAddress)),
90+
screen.getByText(mockGetSlicedAddress(testAddressComponentAddress)),
5791
).toBeInTheDocument();
5892
});
5993

60-
it('renders the sliced address when address supplied to Identity', () => {
61-
useIdentityContextMock.mockReturnValue({});
94+
it('renders the sliced address when address supplied directly to component', () => {
95+
useIdentityContextMock.mockReturnValue({
96+
address: undefined,
97+
ensName: undefined,
98+
loading: false,
99+
error: null,
100+
});
62101
(getSlicedAddress as Mock).mockReturnValue(
63102
mockGetSlicedAddress(testAddressComponentAddress),
64103
);
65104

66-
const { getByText } = render(
67-
<Address address={testAddressComponentAddress} />,
68-
);
105+
render(<Address address={testAddressComponentAddress} />);
106+
expect(getSlicedAddress).toHaveBeenCalledWith(testAddressComponentAddress);
69107
expect(
70-
getByText(mockGetSlicedAddress(testAddressComponentAddress)),
108+
screen.getByText(mockGetSlicedAddress(testAddressComponentAddress)),
71109
).toBeInTheDocument();
72110
});
73111

74-
it('displays sliced address when ENS name is not available and isSliced is set to true', () => {
75-
useIdentityContextMock.mockReturnValue({});
112+
it('displays sliced address when ENS name is not available and isSliced is true', () => {
113+
useIdentityContextMock.mockReturnValue({
114+
address: undefined,
115+
ensName: undefined,
116+
loading: false,
117+
error: null,
118+
});
76119
(getSlicedAddress as Mock).mockReturnValue(
77120
mockGetSlicedAddress(testAddressComponentAddress),
78121
);
79122

80123
render(<Address address={testAddressComponentAddress} isSliced={true} />);
124+
expect(getSlicedAddress).toHaveBeenCalledWith(testAddressComponentAddress);
81125
expect(
82126
screen.getByText(mockGetSlicedAddress(testAddressComponentAddress)),
83127
).toBeInTheDocument();
84-
expect(getSlicedAddress).toHaveBeenCalledWith(testAddressComponentAddress);
85128
});
86129

87130
it('displays full address when isSliced is false and ENS name is not available', () => {
@@ -91,7 +134,7 @@ describe('Address component', () => {
91134
expect(getSlicedAddress).not.toHaveBeenCalled();
92135
});
93136

94-
it('use identity context address if provided', () => {
137+
it('uses identity context address if provided', () => {
95138
useIdentityContextMock.mockReturnValue({
96139
address: testIdentityProviderAddress,
97140
});
@@ -100,12 +143,78 @@ describe('Address component', () => {
100143
expect(getSlicedAddress).not.toHaveBeenCalled();
101144
});
102145

103-
it('use component address over identity context if both are provided', () => {
146+
it('prioritizes component address over identity context address if both are provided', () => {
104147
useIdentityContextMock.mockReturnValue({
105148
address: testIdentityProviderAddress,
106149
});
107150
render(<Address address={testAddressComponentAddress} isSliced={false} />);
108151
expect(screen.getByText(testAddressComponentAddress)).toBeInTheDocument();
109152
expect(getSlicedAddress).not.toHaveBeenCalled();
110153
});
154+
155+
describe('clipboard functionality', () => {
156+
const testAddress = '0x1234567890abcdef';
157+
158+
beforeEach(() => {
159+
vi.clearAllMocks();
160+
useIdentityContextMock.mockReturnValue({
161+
address: undefined,
162+
ensName: undefined,
163+
loading: false,
164+
error: null,
165+
});
166+
});
167+
168+
it('copies address to clipboard on click', () => {
169+
render(<Address address={testAddress} />);
170+
const element = screen.getByTestId('ockAddress');
171+
fireEvent.click(element);
172+
expect(mockClipboard.writeText).toHaveBeenCalledWith(testAddress);
173+
});
174+
175+
it('shows Copied text after clicking', async () => {
176+
render(<Address address={testAddress} />);
177+
const element = screen.getByTestId('ockAddress');
178+
179+
const tooltipSpan = element.querySelector('span');
180+
expect(tooltipSpan).toHaveTextContent('Copy');
181+
182+
fireEvent.click(element);
183+
184+
await vi.waitFor(() => {
185+
expect(tooltipSpan).toHaveTextContent('Copied');
186+
});
187+
});
188+
189+
it('handles clipboard error', async () => {
190+
mockClipboard.writeText.mockRejectedValueOnce(new Error('Failed'));
191+
const consoleSpy = vi
192+
.spyOn(console, 'error')
193+
.mockImplementation(() => {});
194+
195+
render(<Address address={testAddress} />);
196+
const element = screen.getByTestId('ockAddress');
197+
fireEvent.click(element);
198+
199+
await vi.waitFor(() => {
200+
expect(consoleSpy).toHaveBeenCalled();
201+
});
202+
203+
consoleSpy.mockRestore();
204+
});
205+
206+
it('copies on Enter key', () => {
207+
render(<Address address={testAddress} />);
208+
const element = screen.getByTestId('ockAddress');
209+
fireEvent.keyDown(element, { key: 'Enter' });
210+
expect(mockClipboard.writeText).toHaveBeenCalledWith(testAddress);
211+
});
212+
213+
it('copies on Space key', () => {
214+
render(<Address address={testAddress} />);
215+
const element = screen.getByTestId('ockAddress');
216+
fireEvent.keyDown(element, { key: ' ' });
217+
expect(mockClipboard.writeText).toHaveBeenCalledWith(testAddress);
218+
});
219+
});
111220
});

0 commit comments

Comments
 (0)