From fdb4c004a838009c7a3e7e825b8751ad397882e1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 11:46:49 -0600 Subject: [PATCH 1/6] feat(ui): confirm Web3 wallet removal from its row --- .changeset/web3-wallet-removal-dialog.md | 2 + ...r-profile-web3-wallets-section.stories.tsx | 9 +- .../user-profile-profile-panel.view.test.tsx | 4 + .../user-profile-web3-actions.test.tsx | 76 ++++++++++ .../user-profile-profile-panel.view.tsx | 2 +- ...user-profile-remove-web3-wallet.dialog.tsx | 48 ++++++ .../user-profile-web3-wallet-row.view.tsx | 143 ++++++++++++++++++ ...user-profile-web3-wallets-section.view.tsx | 105 +++---------- .../user-profile-web3-wallets.messages.ts | 16 ++ 9 files changed, 317 insertions(+), 88 deletions(-) create mode 100644 .changeset/web3-wallet-removal-dialog.md create mode 100644 packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts diff --git a/.changeset/web3-wallet-removal-dialog.md b/.changeset/web3-wallet-removal-dialog.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/web3-wallet-removal-dialog.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index faa09ab806d..b2097b7a257 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -1,4 +1,5 @@ import { UserProfileWeb3WalletsSectionView } from '@clerk/ui/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -14,15 +15,17 @@ export const meta: StoryMeta = { }; export function Default() { + const [connected, setConnected] = useState(true); return ( undefined} - onManage={() => undefined} + onRemove={() => setConnected(false)} /> ); } diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index a76fbd926ec..7e7d0cc3d4f 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -296,6 +296,10 @@ describe('UserProfileProfilePanelView', () => { const removeWallet = screen.getByRole('menuitem', { name: 'Remove wallet' }); expect(removeWallet).toHaveAttribute('data-color', 'negative'); await user.click(removeWallet); + expect(onRemoveWeb3Wallet).not.toHaveBeenCalled(); + await user.click( + within(screen.getByRole('alertdialog', { name: 'Remove wallet?' })).getByRole('button', { name: 'Remove' }), + ); expect(onConnectWeb3Wallet).toHaveBeenCalledWith('disconnected'); expect(onSetPrimaryWeb3Wallet).toHaveBeenCalledWith('secondary'); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx new file mode 100644 index 00000000000..ebca8ee6df3 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx @@ -0,0 +1,76 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; + +const wallet = { id: 'metamask', provider: 'MetaMask', address: 'test' }; + +function renderWallets(onRemove: (id: string) => void | Promise) { + return render( + + + , + ); +} + +async function openRemoval(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); +} + +describe('Web3 wallet removal', () => { + it('returns keyboard focus to the menu when canceled', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + renderWallets(onRemove); + const trigger = screen.getByRole('button', { name: 'Manage MetaMask' }); + trigger.focus(); + await user.keyboard('{Enter}{Enter}'); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + await waitFor(() => expect(trigger).toHaveFocus()); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it('keeps focus in the section when a removed wallet disappears', async () => { + const user = userEvent.setup(); + function Example() { + const [wallets, setWallets] = useState([wallet]); + return ( + + setWallets(current => current.filter(item => item.id !== id))} + /> + + ); + } + render(); + await openRemoval(user); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage MetaMask' })).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('region', { name: 'Web3 wallets' })).toHaveFocus()); + }); + + it('shows a removal failure and allows retrying', async () => { + const user = userEvent.setup(); + const onRemove = vi + .fn() + .mockRejectedValueOnce(new Error('Unable to remove wallet')) + .mockResolvedValueOnce(undefined); + renderWallets(onRemove); + await openRemoval(user); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to remove wallet'); + await openRemoval(user); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + expect(onRemove).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx index 1a71ffc5a64..b9d75ce2166 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx @@ -32,7 +32,7 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect onConnectWeb3Wallet?: (id: string) => void; onManageWeb3Wallet?: (id: string) => void; onSetPrimaryWeb3Wallet?: (id: string) => void; - onRemoveWeb3Wallet?: (id: string) => void; + onRemoveWeb3Wallet?: (id: string) => void | Promise; /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ onDeleteAccount?: () => Promise; } diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx new file mode 100644 index 00000000000..0170cc6f125 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx @@ -0,0 +1,48 @@ +import { Button } from '../components/button'; +import type { DialogFocusTarget } from '../components/dialog'; +import { Dialog } from '../components/dialog'; +import { Heading } from '../components/heading'; +import { Text } from '../components/text'; +import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; + +export interface UserProfileRemoveWeb3WalletDialogProps { + provider: string; + address?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + finalFocus?: DialogFocusTarget; +} + +export function UserProfileRemoveWeb3WalletDialog({ + provider, + address, + open, + onOpenChange, + onConfirm, + finalFocus, +}: UserProfileRemoveWeb3WalletDialogProps) { + return ( + + + }>{m.removeDialog.title} + }> + {m.removeDialog.description.replace('{wallet}', address ? `${provider} (${address})` : provider)} + + + }>{m.removeDialog.cancel} + + + + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx new file mode 100644 index 00000000000..a4d806dd2c2 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx @@ -0,0 +1,143 @@ +import * as stylex from '@stylexjs/stylex'; +import type { RefObject } from 'react'; +import { useRef, useState } from 'react'; + +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { styles } from './user-profile-profile-panel.styles'; +import { UserProfileProviderIcon } from './user-profile-provider-icon'; +import { UserProfileRemoveWeb3WalletDialog } from './user-profile-remove-web3-wallet.dialog'; +import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; +import type { + UserProfileWeb3Wallet, + UserProfileWeb3WalletsSectionViewProps, +} from './user-profile-web3-wallets-section.view'; + +interface UserProfileWeb3WalletRowViewProps extends Pick< + UserProfileWeb3WalletsSectionViewProps, + 'onConnect' | 'onManage' | 'onSetPrimary' | 'onRemove' +> { + wallet: UserProfileWeb3Wallet; + removalFocusRef: RefObject; +} + +export function UserProfileWeb3WalletRowView({ + wallet, + onConnect, + onManage, + onSetPrimary, + onRemove, + removalFocusRef, +}: UserProfileWeb3WalletRowViewProps) { + const [open, setOpen] = useState(false); + const [removeError, setRemoveError] = useState(); + const removing = useRef(false); + const confirmedRemoval = useRef(false); + const connectRef = useRef(null); + const connected = wallet.connected ?? Boolean(wallet.address); + const actions: UserProfileMenuAction[] = []; + + const confirmRemove = async () => { + if (!onRemove || wallet.canRemove === false || removing.current) { + return; + } + removing.current = true; + confirmedRemoval.current = true; + setOpen(false); + try { + await onRemove(wallet.id); + } catch (error) { + setRemoveError(error instanceof Error ? error.message : m.removeError); + } finally { + removing.current = false; + } + }; + + if (!wallet.isPrimary && wallet.isVerified !== false && onSetPrimary) { + actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(wallet.id) }); + } + + if (onRemove && wallet.canRemove !== false) { + actions.push({ + label: m.remove, + color: 'negative', + onClick: () => { + if (removing.current) { + return; + } + confirmedRemoval.current = false; + setRemoveError(undefined); + setOpen(true); + }, + }); + } + if (!onSetPrimary && !onRemove && onManage) { + actions.push({ label: m.manage, onClick: () => onManage(wallet.id) }); + } + + return ( + + + {wallet.iconUrl ? : null} + + + + {wallet.provider} + {wallet.isPrimary ? {m.primary} : null} + {wallet.isVerified === false ? {m.unverified} : null} + + + {wallet.address ? ( + + {wallet.address.length <= 10 + ? wallet.address + : `${wallet.address.slice(0, 6)}...${wallet.address.slice(-4)}`} + + ) : null} + {removeError ? {removeError} : null} + + + {connected ? ( + + {onRemove && wallet.canRemove !== false ? ( + void confirmRemove()} + finalFocus={() => + confirmedRemoval.current ? (connectRef.current ?? removalFocusRef.current) : undefined + } + /> + ) : null} + + ) : null} + {!connected && onConnect ? ( + + ) : null} + + + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx index 6a497c9b982..1cc1896a81a 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -1,13 +1,8 @@ -import * as stylex from '@stylexjs/stylex'; +import { useRef } from 'react'; -import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; -import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import type { UserProfileMenuAction } from './user-profile-action-menu'; -import { UserProfileActionMenu } from './user-profile-action-menu'; -import { styles } from './user-profile-profile-panel.styles'; -import { UserProfileProviderIcon } from './user-profile-provider-icon'; +import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; +import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; export interface UserProfileWeb3Wallet { id: string; @@ -25,17 +20,9 @@ export interface UserProfileWeb3WalletsSectionViewProps { onConnect?: (id: string) => void; onManage?: (id: string) => void; onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void; + onRemove?: (id: string) => void | Promise; } -const shortenWeb3Address = (address: string) => { - if (address.length <= 10) { - return address; - } - - return `${address.slice(0, 6)}...${address.slice(-4)}`; -}; - export function UserProfileWeb3WalletsSectionView({ wallets, onConnect, @@ -43,75 +30,25 @@ export function UserProfileWeb3WalletsSectionView({ onSetPrimary, onRemove, }: UserProfileWeb3WalletsSectionViewProps) { + const sectionRef = useRef(null); return ( - - Web3 wallets + + {m.title} - {wallets.map(wallet => { - const connected = wallet.connected ?? Boolean(wallet.address); - const actions: UserProfileMenuAction[] = []; - const hasExplicitActions = Boolean(onSetPrimary || onRemove); - - if (!wallet.isPrimary && wallet.isVerified !== false && onSetPrimary) { - actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(wallet.id) }); - } - - if (onRemove && wallet.canRemove !== false) { - actions.push({ label: 'Remove wallet', color: 'negative', onClick: () => onRemove(wallet.id) }); - } - - if (!hasExplicitActions && onManage) { - actions.push({ label: 'Manage', onClick: () => onManage(wallet.id) }); - } - - const address = wallet.address ? shortenWeb3Address(wallet.address) : undefined; - const badges = ( - <> - {wallet.isPrimary ? Primary : null} - {wallet.isVerified === false ? Unverified : null} - - ); - - return ( - - - {wallet.iconUrl ? : null} - - - - {wallet.provider} - {badges} - - - {address ? {address} : null} - - - {connected ? ( - - ) : null} - {!connected && onConnect ? ( - - ) : null} - - - - ); - })} + {wallets.map(wallet => ( + + ))} ); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts new file mode 100644 index 00000000000..380f36860e2 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts @@ -0,0 +1,16 @@ +export const userProfileWeb3WalletsMessages = { + title: 'Web3 wallets', + connect: 'Connect', + manage: 'Manage', + remove: 'Remove wallet', + setPrimary: 'Set as primary', + primary: 'Primary', + unverified: 'Unverified', + removeError: 'Unable to remove wallet. Please try again.', + removeDialog: { + title: 'Remove wallet?', + description: 'Are you sure you want to remove {wallet}?', + confirm: 'Remove', + cancel: 'Cancel', + }, +}; From d8b144e114ce2ffe1cdc1319ea23ad15ca97296e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 17:22:37 -0600 Subject: [PATCH 2/6] refactor(ui): align Web3 wallet views with account row patterns --- .../user-profile-profile-panel.stories.tsx | 9 +- ...r-profile-web3-wallets-section.stories.tsx | 30 +-- .../user-profile-profile-panel.view.test.tsx | 14 +- .../user-profile-web3-actions.test.tsx | 50 +++-- ...profile-web3-wallets-section.view.test.tsx | 132 ++++++++++++++ .../user-profile-profile-panel.view.tsx | 12 +- ...user-profile-remove-web3-wallet.dialog.tsx | 43 +++-- .../user-profile-web3-wallet-row.view.tsx | 172 ++++++++---------- ...user-profile-web3-wallets-section.view.tsx | 65 ++++--- .../user-profile-web3-wallets.messages.ts | 5 +- .../user-profile-web3-wallets.styles.ts | 21 +++ 11 files changed, 370 insertions(+), 183 deletions(-) create mode 100644 packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index e8090770faf..a1a8a78b692 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -63,12 +63,9 @@ export function Default(_args: Record) { isPrimary: true, isVerified: true, }, - { - id: 'coinbase-wallet', - provider: 'Coinbase Wallet', - iconUrl: providerIconUrl('coinbase_wallet'), - connected: false, - }, + ]} + availableWeb3Providers={[ + { id: 'coinbase-wallet', provider: 'Coinbase Wallet', iconUrl: providerIconUrl('coinbase_wallet') }, ]} hasImage={Boolean(imageUrl)} imageUrl={imageUrl} diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index b2097b7a257..cde7c18ec68 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -16,23 +16,31 @@ export const meta: StoryMeta = { export function Default() { const [connected, setConnected] = useState(true); + const metamask = { + id: 'metamask', + provider: 'MetaMask', + iconUrl: 'https://img.clerk.com/static/metamask.svg', + }; return ( undefined} diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 7e7d0cc3d4f..6e28c4949fc 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -236,7 +236,9 @@ describe('UserProfileProfilePanelView', () => { it('renders provider images in icon frames', () => { const { container } = renderView({ connectedAccounts: [{ id: 'google', provider: 'Google', iconUrl: '/google.svg' }], - web3Wallets: [{ id: 'metamask', provider: 'MetaMask', iconUrl: '/metamask.svg' }], + web3Wallets: [ + { id: 'metamask', provider: 'MetaMask', address: 'test', isVerified: true, iconUrl: '/metamask.svg' }, + ], }); const frames = container.querySelectorAll('.cl-icon-frame'); @@ -268,12 +270,8 @@ describe('UserProfileProfilePanelView', () => { provider: 'Coinbase Wallet', isVerified: true, }, - { - id: 'disconnected', - provider: 'Coinbase Wallet', - connected: false, - }, ], + availableWeb3Providers: [{ id: 'disconnected', provider: 'Coinbase Wallet' }], onConnectWeb3Wallet, onSetPrimaryWeb3Wallet, onRemoveWeb3Wallet, @@ -288,7 +286,9 @@ describe('UserProfileProfilePanelView', () => { expect(within(screen.getByRole('region', { name: 'Web3 wallets' })).getByText('Primary')).toBeInTheDocument(); await user.click( - within(screen.getByRole('region', { name: 'Web3 wallets' })).getByRole('button', { name: 'Connect' }), + within(screen.getByRole('region', { name: 'Web3 wallets' })).getByRole('button', { + name: 'Connect Coinbase Wallet', + }), ); await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx index ebca8ee6df3..44c04db57b6 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx @@ -6,9 +6,9 @@ import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../MosaicProvider'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; -const wallet = { id: 'metamask', provider: 'MetaMask', address: 'test' }; +const wallet = { id: 'wallet_1', provider: 'MetaMask', address: 'test', isVerified: true }; -function renderWallets(onRemove: (id: string) => void | Promise) { +function renderWallets(onRemove: (id: string) => void) { return render( { expect(onRemove).not.toHaveBeenCalled(); }); - it('keeps focus in the section when a removed wallet disappears', async () => { + it('removes the wallet and its dialog when the caller updates the list', async () => { const user = userEvent.setup(); function Example() { const [wallets, setWallets] = useState([wallet]); @@ -55,22 +55,42 @@ describe('Web3 wallet removal', () => { await openRemoval(user); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage MetaMask' })).not.toBeInTheDocument()); - await waitFor(() => expect(screen.getByRole('region', { name: 'Web3 wallets' })).toHaveFocus()); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); }); - it('shows a removal failure and allows retrying', async () => { + it('keeps confirmation open while pending and allows retrying an error supplied by the caller', async () => { const user = userEvent.setup(); - const onRemove = vi - .fn() - .mockRejectedValueOnce(new Error('Unable to remove wallet')) - .mockResolvedValueOnce(undefined); - renderWallets(onRemove); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Unable to remove wallet'); + const onRemove = vi.fn(); + const { rerender } = renderWallets(onRemove); await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + const dialog = screen.getByRole('alertdialog'); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); + rerender( + + + , + ); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled(); + expect(within(dialog).getByRole('progressbar')).toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledTimes(1); + rerender( + + + , + ); + expect(within(dialog).getByRole('alert')).toHaveTextContent('Unable to remove wallet'); + await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); expect(onRemove).toHaveBeenCalledTimes(2); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); }); }); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx new file mode 100644 index 00000000000..5132216e2ad --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx @@ -0,0 +1,132 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; + +describe('Web3 wallets section', () => { + it.each([ + { isPrimary: true, isVerified: true, canSetPrimary: false }, + { isPrimary: false, isVerified: true, canSetPrimary: true }, + { isPrimary: false, isVerified: false, canSetPrimary: false }, + ])( + 'offers only applicable legacy wallet actions ($isPrimary, $isVerified)', + async ({ isPrimary, isVerified, canSetPrimary }) => { + const user = userEvent.setup(); + const onSetPrimary = vi.fn(); + render( + , + ); + expect(screen.getByText('MetaMask')).toBeVisible(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); + expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeVisible(); + expect(screen.getAllByRole('menuitem')).toHaveLength(canSetPrimary ? 2 : 1); + if (canSetPrimary) { + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimary).toHaveBeenCalledExactlyOnceWith('wallet_1'); + } else { + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + } + }, + ); + + it.each([true, false])( + 'renders an admin wallet and limits the removal warning to verified wallets (%s)', + async isVerified => { + const user = userEvent.setup(); + const address = '0x1234567890abcdef1234567890abcdef12345678'; + render( + , + ); + expect(screen.getByText('0x1234...5678')).toBeVisible(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: `Manage ${address}` })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + const dialog = screen.getByRole('alertdialog'); + expect(dialog).toHaveTextContent(address); + const warning = within(dialog).queryByText('You will no longer be able to sign in using this web3 wallet.'); + if (isVerified) { + expect(warning).toBeVisible(); + } else { + expect(warning).not.toBeInTheDocument(); + } + }, + ); + + it('shows connection errors beside the provider and forwards retry', async () => { + const user = userEvent.setup(); + const onConnect = vi.fn(); + render( + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Wallet extension not found'); + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + expect(onConnect).toHaveBeenCalledExactlyOnceWith('metamask'); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + it('shows the supplied primary error and forwards another set-primary attempt', async () => { + const user = userEvent.setup(); + const onSetPrimary = vi.fn(); + render( + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Unable to set primary'); + await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimary).toHaveBeenCalledExactlyOnceWith('wallet_1'); + }); + + it('forwards provider IDs separately from wallet IDs without filtering unverified wallets', async () => { + const user = userEvent.setup(); + const onConnect = vi.fn(); + const onRemove = vi.fn(); + render( + , + ); + expect(screen.getByText('Unverified')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + expect(onConnect).toHaveBeenCalledExactlyOnceWith('metamask'); + await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove', exact: true })); + expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx index b9d75ce2166..4319a8622cd 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-profile-panel.view.tsx @@ -13,7 +13,7 @@ import type { UserProfileConnectedAccount } from './user-profile-connected-accou import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; import { UserProfileDeleteSectionView } from './user-profile-delete-section/user-profile-delete-section.view'; import { styles } from './user-profile-profile-panel.styles'; -import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet }; @@ -26,13 +26,13 @@ export type { UserProfileEditNameValue } from './user-profile-account-section/us export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { connectedAccounts?: UserProfileConnectedAccount[]; web3Wallets?: UserProfileWeb3Wallet[]; + availableWeb3Providers?: UserProfileWeb3Provider[]; onConnectAccount?: (id: string) => void; onManageConnectedAccount?: (id: string) => void; onRemoveConnectedAccount?: (id: string) => void; onConnectWeb3Wallet?: (id: string) => void; - onManageWeb3Wallet?: (id: string) => void; onSetPrimaryWeb3Wallet?: (id: string) => void; - onRemoveWeb3Wallet?: (id: string) => void | Promise; + onRemoveWeb3Wallet?: (id: string) => void; /** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */ onDeleteAccount?: () => Promise; } @@ -51,6 +51,7 @@ export function UserProfileProfilePanelView({ phones = [], connectedAccounts = [], web3Wallets = [], + availableWeb3Providers = [], onProfilePictureChange, onProfilePictureReject, onRemoveProfilePicture, @@ -71,7 +72,6 @@ export function UserProfileProfilePanelView({ onManageConnectedAccount, onRemoveConnectedAccount, onConnectWeb3Wallet, - onManageWeb3Wallet, onSetPrimaryWeb3Wallet, onRemoveWeb3Wallet, onDeleteAccount, @@ -117,11 +117,11 @@ export function UserProfileProfilePanelView({ onRemove={onRemoveConnectedAccount} /> ) : null} - {web3Wallets.length > 0 ? ( + {web3Wallets.length > 0 || (availableWeb3Providers.length > 0 && onConnectWeb3Wallet) ? ( diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx index 0170cc6f125..235447e05e8 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx @@ -1,26 +1,28 @@ -import { Button } from '../components/button'; -import type { DialogFocusTarget } from '../components/dialog'; +import { Banner } from '../components/banner'; +import { Button, SubmitButton } from '../components/button'; import { Dialog } from '../components/dialog'; import { Heading } from '../components/heading'; import { Text } from '../components/text'; import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; export interface UserProfileRemoveWeb3WalletDialogProps { - provider: string; - address?: string; + address: string; + isVerified: boolean; open: boolean; onOpenChange: (open: boolean) => void; onConfirm: () => void; - finalFocus?: DialogFocusTarget; + isPending?: boolean; + errorMessage?: string; } export function UserProfileRemoveWeb3WalletDialog({ - provider, address, + isVerified, open, onOpenChange, onConfirm, - finalFocus, + isPending, + errorMessage, }: UserProfileRemoveWeb3WalletDialogProps) { return ( - + }>{m.removeDialog.title} }> - {m.removeDialog.description.replace('{wallet}', address ? `${provider} (${address})` : provider)} + {m.removeDialog.description.replace('{wallet}', address)} + {isVerified ? {m.removeDialog.signInWarning} : null} + {errorMessage ? ( + + {errorMessage} + + ) : null} - }>{m.removeDialog.cancel} - + diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx index a4d806dd2c2..fd4d3c5e703 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx @@ -1,131 +1,88 @@ import * as stylex from '@stylexjs/stylex'; -import type { RefObject } from 'react'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; -import { Icon } from '../components/icon'; +import { Icon, IconFrame } from '../components/icon'; import { Section } from '../components/section'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; -import { styles } from './user-profile-profile-panel.styles'; -import { UserProfileProviderIcon } from './user-profile-provider-icon'; import { UserProfileRemoveWeb3WalletDialog } from './user-profile-remove-web3-wallet.dialog'; import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; -import type { - UserProfileWeb3Wallet, - UserProfileWeb3WalletsSectionViewProps, -} from './user-profile-web3-wallets-section.view'; - -interface UserProfileWeb3WalletRowViewProps extends Pick< - UserProfileWeb3WalletsSectionViewProps, - 'onConnect' | 'onManage' | 'onSetPrimary' | 'onRemove' -> { - wallet: UserProfileWeb3Wallet; - removalFocusRef: RefObject; -} +import { styles } from './user-profile-web3-wallets.styles'; +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; export function UserProfileWeb3WalletRowView({ wallet, onConnect, - onManage, onSetPrimary, onRemove, - removalFocusRef, -}: UserProfileWeb3WalletRowViewProps) { +}: { + wallet: UserProfileWeb3Wallet | UserProfileWeb3Provider; + onConnect?: (id: string) => void; + onSetPrimary?: (id: string) => void; + onRemove?: (id: string) => void; +}) { const [open, setOpen] = useState(false); - const [removeError, setRemoveError] = useState(); - const removing = useRef(false); - const confirmedRemoval = useRef(false); - const connectRef = useRef(null); - const connected = wallet.connected ?? Boolean(wallet.address); + const iconUrl = wallet.iconUrl?.trim(); + const linkedWallet = 'address' in wallet ? wallet : undefined; + const address = linkedWallet?.address; + const shortAddress = address && (address.length <= 10 ? address : `${address.slice(0, 6)}...${address.slice(-4)}`); const actions: UserProfileMenuAction[] = []; - const confirmRemove = async () => { - if (!onRemove || wallet.canRemove === false || removing.current) { - return; - } - removing.current = true; - confirmedRemoval.current = true; - setOpen(false); - try { - await onRemove(wallet.id); - } catch (error) { - setRemoveError(error instanceof Error ? error.message : m.removeError); - } finally { - removing.current = false; - } - }; - - if (!wallet.isPrimary && wallet.isVerified !== false && onSetPrimary) { + if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary) { actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(wallet.id) }); } - - if (onRemove && wallet.canRemove !== false) { - actions.push({ - label: m.remove, - color: 'negative', - onClick: () => { - if (removing.current) { - return; - } - confirmedRemoval.current = false; - setRemoveError(undefined); - setOpen(true); - }, - }); - } - if (!onSetPrimary && !onRemove && onManage) { - actions.push({ label: m.manage, onClick: () => onManage(wallet.id) }); + if (linkedWallet && onRemove && linkedWallet.canRemove !== false) { + actions.push({ label: m.remove, color: 'negative', onClick: () => setOpen(true) }); } return ( - + - {wallet.iconUrl ? : null} + {wallet.provider || iconUrl ? ( + + + {iconUrl ? ( + + ) : ( + + {wallet.provider?.trim().charAt(0).toUpperCase()} + + )} + + + ) : null} - - - {wallet.provider} - {wallet.isPrimary ? {m.primary} : null} - {wallet.isVerified === false ? {m.unverified} : null} - + + {wallet.provider || shortAddress} + {linkedWallet?.isPrimary ? {m.primary} : null} + {linkedWallet && !linkedWallet.isVerified ? {m.unverified} : null} - {wallet.address ? ( - - {wallet.address.length <= 10 - ? wallet.address - : `${wallet.address.slice(0, 6)}...${wallet.address.slice(-4)}`} + {wallet.provider && address ? ( + + {shortAddress} ) : null} - {removeError ? {removeError} : null} - - {connected ? ( - - {onRemove && wallet.canRemove !== false ? ( - void confirmRemove()} - finalFocus={() => - confirmedRemoval.current ? (connectRef.current ?? removalFocusRef.current) : undefined - } - /> - ) : null} - - ) : null} - {!connected && onConnect ? ( + {onConnect ? ( + - ) : null} - + + ) : actions.length > 0 ? ( + + + {linkedWallet && onRemove && linkedWallet.canRemove !== false ? ( + onRemove(wallet.id)} + isPending={linkedWallet.isRemoving} + errorMessage={linkedWallet.removalError} + /> + ) : null} + + + ) : null} + {'connectError' in wallet && wallet.connectError ? {wallet.connectError} : null} + {linkedWallet?.primaryError ? {linkedWallet.primaryError} : null} ); } diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx index 1cc1896a81a..14d249fabf7 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -1,55 +1,66 @@ -import { useRef } from 'react'; - import { Section } from '../../components/section'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; -export interface UserProfileWeb3Wallet { +export interface UserProfileWeb3Provider { id: string; provider: string; - address?: string; iconUrl?: string; - connected?: boolean; + connectError?: string; +} + +export interface UserProfileWeb3Wallet { + id: string; + address: string; + provider?: string; + iconUrl?: string; isPrimary?: boolean; - isVerified?: boolean; + isVerified: boolean; canRemove?: boolean; + isRemoving?: boolean; + removalError?: string; + primaryError?: string; } export interface UserProfileWeb3WalletsSectionViewProps { wallets: UserProfileWeb3Wallet[]; + availableProviders?: UserProfileWeb3Provider[]; onConnect?: (id: string) => void; - onManage?: (id: string) => void; onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void | Promise; + onRemove?: (id: string) => void; } export function UserProfileWeb3WalletsSectionView({ wallets, + availableProviders = [], onConnect, - onManage, onSetPrimary, onRemove, }: UserProfileWeb3WalletsSectionViewProps) { - const sectionRef = useRef(null); return ( - + {m.title} - - {wallets.map(wallet => ( - - ))} - + {wallets.length > 0 || (availableProviders.length > 0 && onConnect) ? ( + + {wallets.map(wallet => ( + + ))} + {onConnect + ? availableProviders.map(provider => ( + + )) + : null} + + ) : null} ); } diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts index 380f36860e2..3a583ff7e22 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts @@ -6,11 +6,12 @@ export const userProfileWeb3WalletsMessages = { setPrimary: 'Set as primary', primary: 'Primary', unverified: 'Unverified', - removeError: 'Unable to remove wallet. Please try again.', removeDialog: { title: 'Remove wallet?', - description: 'Are you sure you want to remove {wallet}?', + description: '{wallet} will be removed from this account.', + signInWarning: 'You will no longer be able to sign in using this web3 wallet.', confirm: 'Remove', + pending: 'Removing wallet', cancel: 'Cancel', }, }; diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts new file mode 100644 index 00000000000..8c785da208b --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts @@ -0,0 +1,21 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + connectRow: { justifyContent: 'center' }, + icon: { display: 'block', objectFit: 'contain', height: space['6'], width: space['6'] }, + fallback: { + borderRadius: radiusVars['--cl-radius-sm'], + alignItems: 'center', + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, + color: colorVars['--cl-color-neutral'], + display: 'inline-flex', + fontSize: '0.625rem', + justifyContent: 'center', + height: space['6'], + width: space['6'], + }, + label: { gap: space['2'], alignItems: 'center', display: 'flex', flexWrap: 'wrap' }, + text: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }, +}); From 41132a72290dd8ea9710cbc5c7dba17c91b79b10 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 17:22:57 -0600 Subject: [PATCH 3/6] docs(swingset): demonstrate Web3 wallet states and actions --- packages/swingset/src/lib/registry.ts | 16 +++ .../fixtures/user-profile-web3-wallets.ts | 111 ++++++++++++++++++ .../user-profile-web3-wallets-section.mdx | 54 ++++++++- ...r-profile-web3-wallets-section.stories.tsx | 93 +++++++++------ .../user-profile-web3-actions.test.tsx | 2 +- ...user-profile-remove-web3-wallet.dialog.tsx | 10 +- .../user-profile-web3-wallet-row.view.tsx | 8 +- .../user-profile-web3-wallets.styles.ts | 2 +- 8 files changed, 250 insertions(+), 46 deletions(-) create mode 100644 packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 05fcb700e63..10bf7cbdbea 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -257,8 +257,16 @@ import { meta as userProfileSubscriptionSectionMeta, } from '../stories/user-profile-subscription-section.stories'; import { + AdminWallet as UserProfileWeb3WalletsSectionAdminWallet, + ConnectedWallets as UserProfileWeb3WalletsSectionConnectedWallets, + ConnectionError as UserProfileWeb3WalletsSectionConnectionError, + ConnectOnly as UserProfileWeb3WalletsSectionConnectOnly, Default as UserProfileWeb3WalletsSectionDefault, meta as userProfileWeb3WalletsSectionMeta, + PrimaryError as UserProfileWeb3WalletsSectionPrimaryError, + RemovalError as UserProfileWeb3WalletsSectionRemovalError, + RemovalPending as UserProfileWeb3WalletsSectionRemovalPending, + UnverifiedWallet as UserProfileWeb3WalletsSectionUnverifiedWallet, } from '../stories/user-profile-web3-wallets-section.stories'; import { Default as VisuallyHiddenDefault, @@ -526,6 +534,14 @@ const userProfileConnectedAccountsSectionModule: StoryModule = { const userProfileWeb3WalletsSectionModule: StoryModule = { meta: userProfileWeb3WalletsSectionMeta, Default: UserProfileWeb3WalletsSectionDefault, + AdminWallet: UserProfileWeb3WalletsSectionAdminWallet, + ConnectedWallets: UserProfileWeb3WalletsSectionConnectedWallets, + ConnectOnly: UserProfileWeb3WalletsSectionConnectOnly, + ConnectionError: UserProfileWeb3WalletsSectionConnectionError, + PrimaryError: UserProfileWeb3WalletsSectionPrimaryError, + RemovalPending: UserProfileWeb3WalletsSectionRemovalPending, + RemovalError: UserProfileWeb3WalletsSectionRemovalError, + UnverifiedWallet: UserProfileWeb3WalletsSectionUnverifiedWallet, }; const userProfileDeleteSectionModule: StoryModule = { meta: userProfileDeleteSectionMeta, diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts new file mode 100644 index 00000000000..a413bde8734 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -0,0 +1,111 @@ +import type { + UserProfileWeb3Provider, + UserProfileWeb3Wallet, +} from '@clerk/ui/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { useState } from 'react'; + +interface DemoWallet extends UserProfileWeb3Wallet { + providerId?: string; +} + +const providers: UserProfileWeb3Provider[] = [ + { id: 'metamask', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, + { id: 'coinbase-wallet', provider: 'Coinbase Wallet', iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg' }, +]; + +export const primaryWallet: DemoWallet = { + id: 'wallet_1', + providerId: 'metamask', + provider: 'MetaMask', + iconUrl: 'https://img.clerk.com/static/metamask.svg', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + isPrimary: true, + isVerified: true, +}; + +export const secondaryWallet: DemoWallet = { + id: 'wallet_2', + providerId: 'coinbase-wallet', + provider: 'Coinbase Wallet', + iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', + address: '0x1234567890abcdef1234567890abcdef12345678', + isVerified: true, +}; + +export function useWeb3WalletsFixture({ + initialWallets = [primaryWallet], + availableProviders = providers, + primaryError = false, + removalState, +}: { + initialWallets?: DemoWallet[]; + availableProviders?: UserProfileWeb3Provider[]; + primaryError?: boolean; + removalState?: 'pending' | 'error'; +} = {}) { + const [wallets, setWallets] = useState(initialWallets); + const [connectionProviders, setConnectionProviders] = useState(availableProviders); + const [primaryFailed, setPrimaryFailed] = useState(false); + + return { + wallets, + availableProviders: connectionProviders.filter( + provider => !wallets.some(wallet => wallet.providerId === provider.id && wallet.isVerified), + ), + onConnect: (id: string) => { + const provider = connectionProviders.find(item => item.id === id); + if (!provider) { + return; + } + setConnectionProviders(current => + current.map(item => (item.id === id ? { ...item, connectError: undefined } : item)), + ); + setWallets(current => { + if (current.some(wallet => wallet.providerId === id && !wallet.isVerified)) { + return current.map(wallet => (wallet.providerId === id ? { ...wallet, isVerified: true } : wallet)); + } + return [ + ...current, + { + provider: provider.provider, + iconUrl: provider.iconUrl, + id: `wallet_${id}`, + providerId: id, + address: '0x1234567890abcdef1234567890abcdef12345678', + isVerified: true, + isPrimary: !current.some(wallet => wallet.isPrimary), + }, + ]; + }); + }, + onSetPrimary: (id: string) => { + if (primaryError && !primaryFailed) { + setPrimaryFailed(true); + setWallets(current => + current.map(wallet => + wallet.id === id + ? { ...wallet, primaryError: 'Unable to set this wallet as primary. Please try again.' } + : wallet, + ), + ); + return; + } + setWallets(current => + current.map(wallet => ({ ...wallet, isPrimary: wallet.id === id, primaryError: undefined })), + ); + }, + onRemove: (id: string) => { + setWallets(current => { + if (removalState === 'pending') { + return current.map(wallet => (wallet.id === id ? { ...wallet, isRemoving: true } : wallet)); + } + if (removalState === 'error' && !current.find(wallet => wallet.id === id)?.removalError) { + return current.map(wallet => + wallet.id === id ? { ...wallet, removalError: 'Unable to remove wallet. Please try again.' } : wallet, + ); + } + return current.filter(wallet => wallet.id !== id); + }); + }, + }; +} diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx index c6ab12be20f..c1f115ffd96 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx @@ -2,8 +2,7 @@ import * as Stories from './user-profile-web3-wallets-section.stories'; # UserProfileWeb3WalletsSection -Supported Web3 wallet providers with per-provider connection actions, connected identities, verification state, -primary status, and wallet actions. +Wallets and available providers are supplied separately. These examples simulate state through props without connecting to browser extensions. Reload to reset them. Solana wallet selection is a separate flow. + +## Connected Wallets + +Only verified, non-primary wallets offer Set as primary. Removing a wallet makes its provider available to connect again. + + + +## Connect Only + + + +## Unverified Wallet + +An unverified wallet has a Remove action, and its provider remains available to connect. + + + +## Admin Wallet + +Wallets added by an administrator can have an address without a provider name or icon. + + + +## Connection Error + +Retry Connect to clear the error and connect the wallet. + + + +## Primary Error + +Choose Set as primary on Coinbase Wallet to show an error. Retry succeeds. + + + +## Removal Pending + +Choose Remove wallet, then confirm to hold the pending state. Cancel is disabled while removal is pending. + + + +## Removal Error + +The first removal attempt shows an error inside the dialog. Retry succeeds. + + diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index cde7c18ec68..eb8ef04cb25 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -1,13 +1,15 @@ import { UserProfileWeb3WalletsSectionView } from '@clerk/ui/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; -import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { primaryWallet, secondaryWallet, useWeb3WalletsFixture } from './fixtures/user-profile-web3-wallets'; + export { default as __source } from './user-profile-web3-wallets-section.stories?raw'; export const meta: StoryMeta = { group: 'User Profile', status: 'wip', + substatus: 'needs wire-up', title: 'UserProfileWeb3WalletsSection', label: 'Web3 wallets', navigation: { category: 'Sections' }, @@ -15,36 +17,61 @@ export const meta: StoryMeta = { }; export function Default() { - const [connected, setConnected] = useState(true); - const metamask = { - id: 'metamask', - provider: 'MetaMask', - iconUrl: 'https://img.clerk.com/static/metamask.svg', - }; - return ( - undefined} - onRemove={() => setConnected(false)} - /> - ); + const fixture = useWeb3WalletsFixture(); + return ; +} + +export function ConnectedWallets() { + const fixture = useWeb3WalletsFixture({ initialWallets: [primaryWallet, secondaryWallet] }); + return ; +} + +export function ConnectOnly() { + const fixture = useWeb3WalletsFixture({ initialWallets: [] }); + return ; +} + +export function UnverifiedWallet() { + const fixture = useWeb3WalletsFixture({ + initialWallets: [{ ...primaryWallet, isPrimary: false, isVerified: false }], + }); + return ; +} + +export function AdminWallet() { + const fixture = useWeb3WalletsFixture({ + initialWallets: [{ id: 'admin_wallet', address: primaryWallet.address, isVerified: true }], + availableProviders: [], + }); + return fixture.wallets.length > 0 ? : null; +} + +export function ConnectionError() { + const fixture = useWeb3WalletsFixture({ + initialWallets: [], + availableProviders: [ + { + id: 'metamask', + provider: 'MetaMask', + iconUrl: primaryWallet.iconUrl, + connectError: 'Wallet extension not found. Check your wallet and try again.', + }, + ], + }); + return ; +} + +export function PrimaryError() { + const fixture = useWeb3WalletsFixture({ initialWallets: [primaryWallet, secondaryWallet], primaryError: true }); + return ; +} + +export function RemovalPending() { + const fixture = useWeb3WalletsFixture({ removalState: 'pending' }); + return ; +} + +export function RemovalError() { + const fixture = useWeb3WalletsFixture({ removalState: 'error' }); + return ; } diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx index 44c04db57b6..28ad9bb066d 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { MosaicProvider } from '../../MosaicProvider'; +import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; const wallet = { id: 'wallet_1', provider: 'MetaMask', address: 'test', isVerified: true }; diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx index 235447e05e8..f26fcb4ae3b 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx @@ -1,8 +1,8 @@ -import { Banner } from '../components/banner'; -import { Button, SubmitButton } from '../components/button'; -import { Dialog } from '../components/dialog'; -import { Heading } from '../components/heading'; -import { Text } from '../components/text'; +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Dialog } from '../../components/dialog'; +import { Heading } from '../../components/heading'; +import { Text } from '../../components/text'; import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; export interface UserProfileRemoveWeb3WalletDialogProps { diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx index fd4d3c5e703..520ef2523a0 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx @@ -1,10 +1,10 @@ import * as stylex from '@stylexjs/stylex'; import { useState } from 'react'; -import { Badge } from '../components/badge'; -import { Button } from '../components/button'; -import { Icon, IconFrame } from '../components/icon'; -import { Section } from '../components/section'; +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { Icon, IconFrame } from '../../components/icon'; +import { Section } from '../../components/section'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { UserProfileRemoveWeb3WalletDialog } from './user-profile-remove-web3-wallet.dialog'; diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts index 8c785da208b..1cc5a4d1947 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars, space } from '../tokens.stylex'; +import { colorVars, radiusVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ connectRow: { justifyContent: 'center' }, From 8957629277be7b8242cb247f245e7a43651d046a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 17:30:43 -0600 Subject: [PATCH 4/6] test(ui): trim duplicate Web3 callback forwarding checks --- .../user-profile-profile-panel.view.test.tsx | 51 +++---------------- .../user-profile-web3-actions.test.tsx | 1 + ...profile-web3-wallets-section.view.test.tsx | 40 +++++---------- 3 files changed, 20 insertions(+), 72 deletions(-) diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 6e28c4949fc..13513e92276 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -249,11 +249,7 @@ describe('UserProfileProfilePanelView', () => { frames.forEach(frame => expect(frame.closest('.cl-section-media')).toHaveAttribute('data-size', 'lg')); }); - it('renders Web3 wallets and forwards wallet actions', async () => { - const onConnectWeb3Wallet = vi.fn(); - const onSetPrimaryWeb3Wallet = vi.fn(); - const onRemoveWeb3Wallet = vi.fn(); - const user = userEvent.setup(); + it('composes linked wallets and available providers', () => { renderView({ web3Wallets: [ { @@ -272,53 +268,18 @@ describe('UserProfileProfilePanelView', () => { }, ], availableWeb3Providers: [{ id: 'disconnected', provider: 'Coinbase Wallet' }], - onConnectWeb3Wallet, - onSetPrimaryWeb3Wallet, - onRemoveWeb3Wallet, + onConnectWeb3Wallet: vi.fn(), + onSetPrimaryWeb3Wallet: vi.fn(), + onRemoveWeb3Wallet: vi.fn(), }); expect(screen.getByRole('heading', { level: 4, name: 'Web3 wallets' })).toBeInTheDocument(); expect(screen.getByText('MetaMask')).toBeInTheDocument(); - expect( - screen.getByRole('region', { name: 'Web3 wallets' }).querySelector('.cl-section-media[data-size="lg"] img'), - ).toHaveAttribute('src', 'https://example.com/metamask.svg'); expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); expect(within(screen.getByRole('region', { name: 'Web3 wallets' })).getByText('Primary')).toBeInTheDocument(); - await user.click( - within(screen.getByRole('region', { name: 'Web3 wallets' })).getByRole('button', { - name: 'Connect Coinbase Wallet', - }), - ); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - const removeWallet = screen.getByRole('menuitem', { name: 'Remove wallet' }); - expect(removeWallet).toHaveAttribute('data-color', 'negative'); - await user.click(removeWallet); - expect(onRemoveWeb3Wallet).not.toHaveBeenCalled(); - await user.click( - within(screen.getByRole('alertdialog', { name: 'Remove wallet?' })).getByRole('button', { name: 'Remove' }), - ); - - expect(onConnectWeb3Wallet).toHaveBeenCalledWith('disconnected'); - expect(onSetPrimaryWeb3Wallet).toHaveBeenCalledWith('secondary'); - expect(onRemoveWeb3Wallet).toHaveBeenCalledWith('secondary'); - }); - - it('shows unverified Web3 wallets without a set-primary action', async () => { - const user = userEvent.setup(); - renderView({ - web3Wallets: [{ id: 'unverified', provider: 'WalletConnect', address: 'short', isVerified: false }], - onSetPrimaryWeb3Wallet: vi.fn(), - onRemoveWeb3Wallet: vi.fn(), - }); - - expect(screen.getByText('short')).toBeInTheDocument(); - expect(screen.getByText('Unverified')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Manage WalletConnect' })); - expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect Coinbase Wallet' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })).toBeVisible(); }); it('renders safely before profile data is available', () => { diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx index 28ad9bb066d..d7a1493b3c3 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx @@ -64,6 +64,7 @@ describe('Web3 wallet removal', () => { const { rerender } = renderWallets(onRemove); await openRemoval(user); const dialog = screen.getByRole('alertdialog'); + expect(onRemove).not.toHaveBeenCalled(); await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); rerender( diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx index 5132216e2ad..dd53eab1da4 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx @@ -13,7 +13,6 @@ describe('Web3 wallets section', () => { 'offers only applicable legacy wallet actions ($isPrimary, $isVerified)', async ({ isPrimary, isVerified, canSetPrimary }) => { const user = userEvent.setup(); - const onSetPrimary = vi.fn(); render( { iconUrl: '/metamask.svg', }, ]} - onSetPrimary={onSetPrimary} + onSetPrimary={vi.fn()} onRemove={vi.fn()} />, ); @@ -36,8 +35,7 @@ describe('Web3 wallets section', () => { expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeVisible(); expect(screen.getAllByRole('menuitem')).toHaveLength(canSetPrimary ? 2 : 1); if (canSetPrimary) { - await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); - expect(onSetPrimary).toHaveBeenCalledExactlyOnceWith('wallet_1'); + expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeVisible(); } else { expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); } @@ -70,25 +68,21 @@ describe('Web3 wallets section', () => { }, ); - it('shows connection errors beside the provider and forwards retry', async () => { - const user = userEvent.setup(); - const onConnect = vi.fn(); + it('shows connection errors while keeping Connect available', () => { render( , ); expect(screen.getByRole('alert')).toHaveTextContent('Wallet extension not found'); - await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); - expect(onConnect).toHaveBeenCalledExactlyOnceWith('metamask'); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); - it('shows the supplied primary error and forwards another set-primary attempt', async () => { + it('shows primary errors while keeping Set as primary available', async () => { const user = userEvent.setup(); - const onSetPrimary = vi.fn(); render( { primaryError: 'Unable to set primary', }, ]} - onSetPrimary={onSetPrimary} + onSetPrimary={vi.fn()} />, ); expect(screen.getByRole('alert')).toHaveTextContent('Unable to set primary'); await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); - expect(onSetPrimary).toHaveBeenCalledExactlyOnceWith('wallet_1'); + expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeEnabled(); }); - it('forwards provider IDs separately from wallet IDs without filtering unverified wallets', async () => { - const user = userEvent.setup(); - const onConnect = vi.fn(); - const onRemove = vi.fn(); + it('shows an unverified wallet alongside its available provider', () => { render( , ); expect(screen.getByText('Unverified')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); - expect(onConnect).toHaveBeenCalledExactlyOnceWith('metamask'); - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeVisible(); }); }); From 7ea6b6f3273ef99b4839fff8e31e969fa5bbc888 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 17:34:37 -0600 Subject: [PATCH 5/6] docs(swingset): simplify Web3 wallet examples --- packages/swingset/src/lib/registry.ts | 2 -- .../user-profile-web3-wallets-section.mdx | 8 +------- ...user-profile-web3-wallets-section.stories.tsx | 16 +++++++--------- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 10bf7cbdbea..96223541c14 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -257,7 +257,6 @@ import { meta as userProfileSubscriptionSectionMeta, } from '../stories/user-profile-subscription-section.stories'; import { - AdminWallet as UserProfileWeb3WalletsSectionAdminWallet, ConnectedWallets as UserProfileWeb3WalletsSectionConnectedWallets, ConnectionError as UserProfileWeb3WalletsSectionConnectionError, ConnectOnly as UserProfileWeb3WalletsSectionConnectOnly, @@ -534,7 +533,6 @@ const userProfileConnectedAccountsSectionModule: StoryModule = { const userProfileWeb3WalletsSectionModule: StoryModule = { meta: userProfileWeb3WalletsSectionMeta, Default: UserProfileWeb3WalletsSectionDefault, - AdminWallet: UserProfileWeb3WalletsSectionAdminWallet, ConnectedWallets: UserProfileWeb3WalletsSectionConnectedWallets, ConnectOnly: UserProfileWeb3WalletsSectionConnectOnly, ConnectionError: UserProfileWeb3WalletsSectionConnectionError, diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx index c1f115ffd96..b14b20c3e6a 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx @@ -30,16 +30,10 @@ Only verified, non-primary wallets offer Set as primary. Removing a wallet makes ## Unverified Wallet -An unverified wallet has a Remove action, and its provider remains available to connect. +An unverified wallet offers Remove. After removal, the provider can be connected again. -## Admin Wallet - -Wallets added by an administrator can have an address without a provider name or icon. - - - ## Connection Error Retry Connect to clear the error and connect the wallet. diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index eb8ef04cb25..cd446a95a9c 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -34,16 +34,14 @@ export function ConnectOnly() { export function UnverifiedWallet() { const fixture = useWeb3WalletsFixture({ initialWallets: [{ ...primaryWallet, isPrimary: false, isVerified: false }], + availableProviders: [{ id: 'metamask', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], }); - return ; -} - -export function AdminWallet() { - const fixture = useWeb3WalletsFixture({ - initialWallets: [{ id: 'admin_wallet', address: primaryWallet.address, isVerified: true }], - availableProviders: [], - }); - return fixture.wallets.length > 0 ? : null; + return ( + + ); } export function ConnectionError() { From 2d715f3eb35fdab783646c65c40115693469334a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 18:06:40 -0600 Subject: [PATCH 6/6] fix(ui): address Web3 wallet view feedback --- .../fixtures/user-profile-web3-wallets.ts | 10 +++-- ...profile-web3-wallets-section.view.test.tsx | 13 ++++++ .../user-profile-web3-wallet-row.view.tsx | 4 +- ...user-profile-web3-wallets-section.view.tsx | 44 ++++++++++--------- .../user-profile-web3-wallets.messages.ts | 3 +- 5 files changed, 47 insertions(+), 27 deletions(-) diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts index a413bde8734..75b19217a98 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -95,10 +95,14 @@ export function useWeb3WalletsFixture({ ); }, onRemove: (id: string) => { + if (removalState === 'pending') { + setWallets(current => current.map(wallet => (wallet.id === id ? { ...wallet, isRemoving: true } : wallet))); + setTimeout(() => { + setWallets(current => current.filter(wallet => wallet.id !== id)); + }, 1500); + return; + } setWallets(current => { - if (removalState === 'pending') { - return current.map(wallet => (wallet.id === id ? { ...wallet, isRemoving: true } : wallet)); - } if (removalState === 'error' && !current.find(wallet => wallet.id === id)?.removalError) { return current.map(wallet => wallet.id === id ? { ...wallet, removalError: 'Unable to remove wallet. Please try again.' } : wallet, diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx index dd53eab1da4..a9aa62fdccb 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx @@ -5,6 +5,19 @@ import { describe, expect, it, vi } from 'vitest'; import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; describe('Web3 wallets section', () => { + it.each([{ availableProviders: [] }, { availableProviders: [{ id: 'metamask', provider: 'MetaMask' }] }])( + 'hides the entire section without wallets or actionable providers (%j)', + ({ availableProviders }) => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }, + ); + it.each([ { isPrimary: true, isVerified: true, canSetPrimary: false }, { isPrimary: false, isVerified: true, canSetPrimary: true }, diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx index 520ef2523a0..ed2f66e9494 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallet-row.view.tsx @@ -82,7 +82,7 @@ export function UserProfileWeb3WalletRowView({ color='neutral' size='sm' variant='outline' - aria-label={`${m.connect} ${wallet.provider}`} + aria-label={m.connectLabel.replace('{provider}', wallet.provider ?? '')} onClick={() => onConnect(wallet.id)} > {m.connect} @@ -97,7 +97,7 @@ export function UserProfileWeb3WalletRowView({ {linkedWallet && onRemove && linkedWallet.canRemove !== false ? ( {m.title} - {wallets.length > 0 || (availableProviders.length > 0 && onConnect) ? ( - - {wallets.map(wallet => ( - - ))} - {onConnect - ? availableProviders.map(provider => ( - - )) - : null} - - ) : null} + + {wallets.map(wallet => ( + + ))} + {onConnect + ? availableProviders.map(provider => ( + + )) + : null} + ); } diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts index 3a583ff7e22..3ffe00aae49 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts @@ -1,7 +1,8 @@ export const userProfileWeb3WalletsMessages = { title: 'Web3 wallets', connect: 'Connect', - manage: 'Manage', + connectLabel: 'Connect {provider}', + manageLabel: 'Manage {wallet}', remove: 'Remove wallet', setPrimary: 'Set as primary', primary: 'Primary',