diff --git a/frontend/src/components/ContributorProfile.test.tsx b/frontend/src/components/ContributorProfile.test.tsx index 9a65ea4f..bd44122e 100644 --- a/frontend/src/components/ContributorProfile.test.tsx +++ b/frontend/src/components/ContributorProfile.test.tsx @@ -162,16 +162,16 @@ describe('ContributorProfile', () => { it('does not render copy button when wallet is empty', () => { render(); - expect(screen.queryByTestId('copy-wallet-btn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('copy-wallet')).not.toBeInTheDocument(); }); - it('calls clipboard API when copy button is clicked', () => { + it('calls clipboard API when copy button is clicked', async () => { const writeText = jest.fn().mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText } }); render(); fireEvent.click(screen.getByTestId('copy-wallet-btn')); - expect(writeText).toHaveBeenCalledWith(defaultProps.walletAddress); + await expect(writeText).toHaveBeenCalledWith(defaultProps.walletAddress); }); // ── Recent bounties tests ────────────────────────────────────────────────── diff --git a/frontend/src/components/ContributorProfile.tsx b/frontend/src/components/ContributorProfile.tsx index 93b73efb..81e6af96 100644 --- a/frontend/src/components/ContributorProfile.tsx +++ b/frontend/src/components/ContributorProfile.tsx @@ -1,10 +1,11 @@ 'use client'; -import React, { useState } from 'react'; +import React from 'react'; import type { ContributorBadgeStats } from '../types/badges'; import { computeBadges } from '../types/badges'; import { BadgeGrid } from './badges'; import { TimeAgo } from './common/TimeAgo'; +import { CopyAddress } from './common/CopyAddress'; interface RecentBounty { title: string; @@ -145,28 +146,11 @@ export const ContributorProfile: React.FC = ({ creatorTotalEscrowed = 0, creatorCompletionRate = 0, }) => { - const [copied, setCopied] = useState(false); - - const truncatedWallet = walletAddress - ? `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}` - : 'Not connected'; - const badges = badgeStats ? computeBadges(badgeStats) : []; const earnedCount = badges.filter((b) => b.earned).length; const mostRecentPrTimestamp = badgeStats?.prSubmissionTimestampsUtc?.[badgeStats.prSubmissionTimestampsUtc.length - 1]; - const handleCopyWallet = async () => { - if (!walletAddress) return; - try { - await navigator.clipboard.writeText(walletAddress); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // clipboard API may not be available in all contexts - } - }; - return (
{/* Profile Header */} @@ -197,23 +181,16 @@ export const ContributorProfile: React.FC = ({ )}
-

{truncatedWallet}

- {walletAddress && ( - + {walletAddress ? ( + + ) : ( +

Not connected

)}
{joinDate && ( diff --git a/frontend/src/components/common/CopyAddress.test.tsx b/frontend/src/components/common/CopyAddress.test.tsx new file mode 100644 index 00000000..ad506200 --- /dev/null +++ b/frontend/src/components/common/CopyAddress.test.tsx @@ -0,0 +1,233 @@ +/** + * @jest-environment jsdom + */ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CopyAddress, truncateAddress } from './CopyAddress'; + +// Mock navigator.clipboard +const mockWriteText = vi.fn(); +Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, +}); + +describe('truncateAddress', () => { + it('truncates address with default length', () => { + const address = 'C2Tv7g9qL4R8K3m5P9xQ2Y7Z'; + expect(truncateAddress(address)).toBe('C2Tv...7Z'); + }); + + it('truncates with custom start/end chars', () => { + const address = 'C2Tv7g9qL4R8K3m5P9xQ2Y7Z'; + expect(truncateAddress(address, 6, 4)).toBe('C2Tv7g...7Z'); + }); + + it('returns full address if too short to truncate', () => { + const address = 'ABC123'; + expect(truncateAddress(address)).toBe('ABC123'); + }); + + it('returns empty string for empty address', () => { + expect(truncateAddress('')).toBe(''); + }); +}); + +describe('CopyAddress', () => { + const testAddress = 'C2Tv7g9qL4R8K3m5P9xQ2Y7Z8W1BAGS'; + + beforeEach(() => { + vi.clearAllMocks(); + mockWriteText.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders truncated address', () => { + render(); + expect(screen.getByTestId('copy-address-text').textContent).toBe('C2Tv...AGS'); + }); + + it('shows full address in title attribute', () => { + render(); + expect(screen.getByTestId('copy-address-text').getAttribute('title')).toBe(testAddress); + }); + + it('renders copy icon by default', () => { + render(); + expect(screen.getByTestId('copy-address-icon')).toBeTruthy(); + }); + + it('hides icon when showIcon=false', () => { + render(); + expect(screen.queryByTestId('copy-address-btn')).toBeNull(); + }); + + it('copies address to clipboard on click', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-address-btn')); + }); + + expect(mockWriteText).toHaveBeenCalledWith(testAddress); + }); + + it('shows checkmark after successful copy', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-address-btn')); + }); + + expect(screen.getByTestId('copy-address-checkmark')).toBeTruthy(); + }); + + it('returns to copy icon after 2 seconds', async () => { + vi.useFakeTimers(); + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-address-btn')); + }); + + expect(screen.getByTestId('copy-address-checkmark')).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(2100); + }); + + await waitFor(() => { + expect(screen.queryByTestId('copy-address-checkmark')).toBeNull(); + }); + + vi.useRealTimers(); + }); + + it('shows error icon on copy failure', async () => { + mockWriteText.mockRejectedValue(new Error('Clipboard error')); + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-address-btn')); + }); + + expect(screen.getByTestId('copy-address-error')).toBeTruthy(); + }); + + it('calls onCopy callback after successful copy', async () => { + const onCopy = vi.fn(); + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('copy-address-btn')); + }); + + expect(onCopy).toHaveBeenCalled(); + }); + + it('has correct aria-label', () => { + render(); + expect(screen.getByTestId('copy-address-btn').getAttribute('aria-label')).toBe( + 'Copy C2Tv...AGS to clipboard' + ); + }); + + it('uses custom ariaLabel when provided', () => { + render(); + expect(screen.getByTestId('copy-address-btn').getAttribute('aria-label')).toBe( + 'Copy wallet address' + ); + }); + + it('handles Enter key press', async () => { + render(); + + await act(async () => { + fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: 'Enter' }); + }); + + expect(mockWriteText).toHaveBeenCalledWith(testAddress); + }); + + it('handles Space key press', async () => { + render(); + + await act(async () => { + fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: ' ' }); + }); + + expect(mockWriteText).toHaveBeenCalledWith(testAddress); + }); + + it('does not copy on other key presses', async () => { + render(); + + fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: 'Tab' }); + + expect(mockWriteText).not.toHaveBeenCalled(); + }); + + it('prevents default on Enter/Space key', async () => { + render(); + + const event = fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: 'Enter' }); + // The handler calls preventDefault, but fireEvent doesn't easily test this + // The fact that it doesn't throw is a basic test + }); + + it('returns null when address is empty', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByTestId('copy-address').className).toContain('my-custom-class'); + }); + + it('applies custom data-testid', () => { + render(); + expect(screen.getByTestId('wallet-address')).toBeTruthy(); + expect(screen.getByTestId('wallet-address-text')).toBeTruthy(); + expect(screen.getByTestId('wallet-address-btn')).toBeTruthy(); + }); + + it('updates title attribute after copy', async () => { + render(); + const btn = screen.getByTestId('copy-address-btn'); + + expect(btn.getAttribute('title')).toBe('Copy to clipboard'); + + await act(async () => { + fireEvent.click(btn); + }); + + expect(btn.getAttribute('title')).toBe('Copied!'); + }); + + it('has screen reader status element', () => { + render(); + const status = screen.getByTestId('copy-address-status'); + expect(status.getAttribute('role')).toBe('status'); + expect(status.getAttribute('aria-live')).toBe('polite'); + }); + + it('has sr-only class on status element', () => { + render(); + expect(screen.getByTestId('copy-address-status').className).toContain('sr-only'); + }); + + it('button has type="button"', () => { + render(); + expect(screen.getByTestId('copy-address-btn').getAttribute('type')).toBe('button'); + }); + + it('uses custom startChars and endChars', () => { + render(); + expect(screen.getByTestId('copy-address-text').textContent).toBe('C2Tv7g...1BAGS'); + }); +}); diff --git a/frontend/src/components/common/CopyAddress.tsx b/frontend/src/components/common/CopyAddress.tsx new file mode 100644 index 00000000..7474f942 --- /dev/null +++ b/frontend/src/components/common/CopyAddress.tsx @@ -0,0 +1,171 @@ +/** + * CopyAddress Component + * Reusable component for displaying and copying wallet/contract addresses + */ +import { useState, useCallback } from 'react'; + +export interface CopyAddressProps { + /** Full address to display and copy */ + address: string; + /** Number of characters to show at start (default: 4) */ + startChars?: number; + /** Number of characters to show at end (default: 4) */ + endChars?: number; + /** Custom className for styling */ + className?: string; + /** Whether to show copy icon (default: true) */ + showIcon?: boolean; + /** Custom label for screen readers */ + ariaLabel?: string; + /** Callback after successful copy */ + onCopy?: () => void; + /** Test id for testing */ + 'data-testid'?: string; +} + +/** + * Truncates an address for display (e.g., "C2Tv...BAGS") + */ +export function truncateAddress( + address: string, + startChars: number = 4, + endChars: number = 4 +): string { + if (!address || address.length <= startChars + endChars + 3) { + return address || ''; + } + return `${address.slice(0, startChars)}...${address.slice(-endChars)}`; +} + +/** + * CopyAddress component - displays truncated address with copy functionality + */ +export function CopyAddress({ + address, + startChars = 4, + endChars = 4, + className = '', + showIcon = true, + ariaLabel, + onCopy, + 'data-testid': dataTestId = 'copy-address', +}: CopyAddressProps): JSX.Element | null { + const [copied, setCopied] = useState(false); + const [copyError, setCopyError] = useState(false); + + const truncatedAddress = truncateAddress(address, startChars, endChars); + const displayLabel = ariaLabel || `Copy ${truncatedAddress} to clipboard`; + + const handleCopy = useCallback(async () => { + if (!address) return; + + try { + await navigator.clipboard.writeText(address); + setCopied(true); + setCopyError(false); + onCopy?.(); + + // Reset copied state after 2 seconds + setTimeout(() => setCopied(false), 2000); + } catch (err) { + setCopyError(true); + // Reset error state after 2 seconds + setTimeout(() => setCopyError(false), 2000); + } + }, [address, onCopy]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleCopy(); + } + }, + [handleCopy] + ); + + if (!address) { + return null; + } + + return ( + + {/* Truncated address with full address as tooltip */} + + {truncatedAddress} + + + {/* Copy button */} + {showIcon && ( + + )} + + {/* Screen reader announcement */} + + {copied ? 'Address copied to clipboard' : copyError ? 'Failed to copy address' : ''} + + + ); +} diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index bb48a1ec..99b64085 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -43,4 +43,6 @@ export type { MarkdownRendererProps } from './MarkdownRenderer'; export { SolFoundryLogoMark } from './SolFoundryLogoMark'; export type { SolFoundryLogoMarkProps, SolFoundryLogoMarkSize } from './SolFoundryLogoMark'; export { LoadingButton, Spinner } from './LoadingButton'; -export type { LoadingButtonProps } from './LoadingButton'; \ No newline at end of file +export type { LoadingButtonProps } from './LoadingButton'; +export { CopyAddress, truncateAddress } from './CopyAddress'; +export type { CopyAddressProps } from './CopyAddress'; \ No newline at end of file