From 69b9420bd6e93434f78785fb76ec61345e447da6 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 15:55:46 -0600 Subject: [PATCH 01/14] refactor(ui): share connected account removal confirmation --- .changeset/connected-account-confirmation.md | 2 + ...rofile-connected-accounts-actions.test.tsx | 110 ++++++++++++++---- ...e-connected-accounts-section.view.test.tsx | 48 ++------ .../user-profile-profile-panel.view.test.tsx | 36 +++++- ...ser-profile-connected-account-row.view.tsx | 24 +--- ...rofile-connected-accounts-section.view.tsx | 66 ++++++----- .../user-profile-profile-panel.view.tsx | 18 ++- ...rofile-remove-connected-account.dialog.tsx | 64 ---------- 8 files changed, 183 insertions(+), 185 deletions(-) create mode 100644 .changeset/connected-account-confirmation.md delete mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-remove-connected-account.dialog.tsx diff --git a/.changeset/connected-account-confirmation.md b/.changeset/connected-account-confirmation.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/connected-account-confirmation.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx index d9ef673f720..92fa48b4e93 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { act, 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'; @@ -8,7 +8,7 @@ import { UserProfileConnectedAccountsSectionView } from '../user-profile-connect const account = { id: 'github', provider: 'GitHub', identifier: 'test' }; -function renderAccounts(onRemove: (id: string) => void) { +function renderAccounts(onRemove: (id: string) => void | Promise) { return render( { const trigger = screen.getByRole('button', { name: 'Manage GitHub' }); trigger.focus(); await user.keyboard('{Enter}{Enter}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); await user.keyboard('{Escape}'); await waitFor(() => expect(trigger).toHaveFocus()); expect(onRemove).not.toHaveBeenCalled(); @@ -53,34 +53,96 @@ describe('connected account removal', () => { } render(); await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage GitHub' })).not.toBeInTheDocument()); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.queryByText('Connected accounts')).not.toBeInTheDocument(); }); it('shows a removal failure and allows retrying', async () => { const user = userEvent.setup(); - const onRemove = vi.fn(); - function Example() { - const [removalError, setRemovalError] = useState(); - return ( - - { - onRemove(id); - setRemovalError(onRemove.mock.calls.length === 1 ? 'Unable to disconnect' : undefined); - }} - /> - - ); - } - render(); + const onRemove = vi.fn().mockRejectedValueOnce(new Error('Unable to disconnect')).mockResolvedValue(undefined); + renderAccounts(onRemove); await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); expect(await screen.findByRole('alert')).toHaveTextContent('Unable to disconnect'); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); expect(onRemove).toHaveBeenCalledTimes(2); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('keeps the selected account pending until its removal finishes', async () => { + const user = userEvent.setup(); + const removal = Promise.withResolvers(); + const onRemove = vi.fn(() => removal.promise); + renderAccounts(onRemove); + await openRemoval(user); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); + + expect(onRemove).toHaveBeenCalledExactlyOnceWith('github'); + expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })).toHaveAttribute( + 'aria-busy', + 'true', + ); + await act(async () => { + removal.resolve(); + await removal.promise; + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('opens the same confirmation for the chosen account after cancelling another', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + render( + + + , + ); + await openRemoval(user); + expect(screen.getByRole('dialog')).toHaveAccessibleDescription(/GitHub will be removed/); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Manage GitHub' })).toHaveFocus(); + await user.click(screen.getByRole('button', { name: 'Manage Google' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove' })); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(screen.getByRole('dialog')).toHaveAccessibleDescription(/Google will be removed/); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); + expect(onRemove).toHaveBeenCalledExactlyOnceWith('google'); + }); + + it('keeps removal callbacks isolated between section instances', async () => { + const user = userEvent.setup(); + const removeGitHub = vi.fn(); + const removeGoogle = vi.fn(); + render( + + + + , + ); + await openRemoval(user); + expect(screen.getByRole('dialog')).toHaveAccessibleDescription(/GitHub will be removed/); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(removeGitHub).toHaveBeenCalledExactlyOnceWith('github'); + expect(removeGoogle).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Manage Google' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove' })); + expect(screen.getByRole('dialog')).toHaveAccessibleDescription(/Google will be removed/); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); + expect(removeGoogle).toHaveBeenCalledExactlyOnceWith('google'); + expect(removeGitHub).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx index f7cd162a1ec..e74f66bb7b0 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -43,50 +43,18 @@ describe('connected accounts section', () => { expect(screen.queryByRole('menu')).not.toBeInTheDocument(); }); - it('keeps confirmation open for pending and error props and forwards retry', async () => { + it('does not offer removal for a protected account', async () => { const user = userEvent.setup(); - const onRemove = vi.fn(); - const { rerender } = render( + render( , ); await user.click(screen.getByRole('button', { name: 'Manage Google' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove' })); - const dialog = screen.getByRole('alertdialog'); - expect(dialog).toHaveTextContent('dependent features'); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('account_1'); - rerender( - , - ); - expect(within(dialog).getByRole('button', { name: 'Remove', exact: true })).toHaveAttribute( - 'aria-disabled', - 'true', - ); - expect(within(dialog).getByRole('progressbar')).toBeInTheDocument(); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledTimes(1); - expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled(); - await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); - expect(dialog).toBeInTheDocument(); - rerender( - , - ); - expect(within(dialog).getByRole('alert')).toHaveTextContent('Try again'); - expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeEnabled(); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledTimes(2); - await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Manage Google' })).toHaveFocus(); + expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled(); + expect(screen.queryByRole('menuitem', { name: 'Remove' })).not.toBeInTheDocument(); }); it('renders reconnect and verification errors without a generic manage action', async () => { 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 aa15af906e5..1d5fc5faa2b 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 @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -37,6 +37,38 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('region', { name: 'Connected accounts' })).not.toBeInTheDocument(); }); + it('keeps the final account confirmation mounted until removal settles', async () => { + const user = userEvent.setup(); + const removal = Promise.withResolvers(); + const onRemoveConnectedAccount = vi.fn(() => removal.promise); + const { rerender } = renderView({ + connectedAccounts: [{ id: 'github', provider: 'GitHub' }], + onRemoveConnectedAccount, + }); + await user.click(screen.getByRole('button', { name: 'Manage GitHub' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); + + rerender( + + + , + ); + expect(screen.queryByRole('heading', { name: 'Connected accounts' })).not.toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Remove connected account' })).toBeInTheDocument(); + + await act(async () => { + removal.resolve(); + await removal.promise; + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.getByRole('heading', { name: 'Account', level: 3 })).toBeVisible(); + }); + it('keeps available providers visible without connected accounts', () => { const onConnectAccount = vi.fn(); renderView({ @@ -434,7 +466,7 @@ describe('UserProfileProfilePanelView', () => { await user.click(removeConnectedAccount); expect(onRemoveConnectedAccount).not.toHaveBeenCalled(); await user.click( - within(screen.getByRole('alertdialog', { name: 'Remove connected account' })).getByRole('button', { + within(screen.getByRole('dialog', { name: 'Remove connected account' })).getByRole('button', { name: 'Remove', }), ); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-connected-account-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-account-row.view.tsx index e2b1b7152a4..e6c434ffcc2 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-connected-account-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-account-row.view.tsx @@ -1,5 +1,4 @@ import * as stylex from '@stylexjs/stylex'; -import { useState } from 'react'; import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; @@ -10,27 +9,25 @@ import { UserProfileActionMenu } from './user-profile-action-menu'; import { userProfileConnectedAccountsMessages as m } from './user-profile-connected-accounts.messages'; import { styles } from './user-profile-connected-accounts.styles'; import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.view'; -import { UserProfileRemoveConnectedAccountDialog } from './user-profile-remove-connected-account.dialog'; export function UserProfileConnectedAccountRowView({ account, onConnect, onReconnect, - onRemove, + onRequestRemove, }: { account: UserProfileConnectedAccount; onConnect?: (id: string) => void; onReconnect?: (id: string) => void; - onRemove?: (id: string) => void; + onRequestRemove?: (account: UserProfileConnectedAccount) => void; }) { - const [open, setOpen] = useState(false); const iconUrl = account.iconUrl?.trim(); const actions: UserProfileMenuAction[] = []; if (account.status === 'reconnect' && onReconnect) { actions.push({ label: m.reconnect, onClick: () => onReconnect(account.id) }); } - if (onRemove && account.canRemove !== false) { - actions.push({ label: m.remove, color: 'negative', onClick: () => setOpen(true) }); + if (onRequestRemove && account.canRemove !== false) { + actions.push({ label: m.remove, color: 'negative', onClick: () => onRequestRemove(account) }); } return ( @@ -90,18 +87,7 @@ export function UserProfileConnectedAccountRowView({ - {onRemove && account.canRemove !== false ? ( - onRemove(account.id)} - isPending={account.isRemoving} - errorMessage={account.removalError} - /> - ) : null} - + /> ) : null} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts-section.view.tsx index 5a951ec4fe0..5060e40cde0 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts-section.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-connected-accounts-section.view.tsx @@ -1,3 +1,6 @@ +import { useMemo } from 'react'; + +import { Confirmation } from '../../blocks/confirmation'; import { Section } from '../../components/section'; import { UserProfileConnectedAccountRowView } from './user-profile-connected-account-row.view'; import { userProfileConnectedAccountsMessages as m } from './user-profile-connected-accounts.messages'; @@ -15,8 +18,6 @@ export interface UserProfileConnectedAccount extends UserProfileConnectionProvid status?: 'connected' | 'reconnect' | 'error'; verificationError?: string; reconnectError?: string; - isRemoving?: boolean; - removalError?: string; } export interface UserProfileConnectedAccountsSectionViewProps { @@ -24,7 +25,7 @@ export interface UserProfileConnectedAccountsSectionViewProps { availableProviders?: UserProfileConnectionProvider[]; onConnect?: (id: string) => void; onReconnect?: (id: string) => void; - onRemove?: (id: string) => void; + onRemove?: (id: string) => void | Promise; } export function UserProfileConnectedAccountsSectionView({ @@ -34,32 +35,45 @@ export function UserProfileConnectedAccountsSectionView({ onReconnect, onRemove, }: UserProfileConnectedAccountsSectionViewProps) { - if (accounts.length === 0 && (availableProviders.length === 0 || !onConnect)) { - return null; - } + const removeAccount = useMemo(() => Confirmation.createHandle(), []); + const hasRows = accounts.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); return ( - - {m.title} - - {accounts.map(account => ( - - ))} - {onConnect - ? availableProviders.map(provider => ( + <> + {hasRows ? ( + + {m.title} + + {accounts.map(account => ( removeAccount.open(account) : undefined} /> - )) - : null} - - + ))} + {onConnect + ? availableProviders.map(provider => ( + + )) + : null} + + + ) : null} + {onRemove ? ( + m.removeDialog.description.replace('{provider}', account.provider)} + actionLabel={m.removeDialog.confirm} + cancelLabel={m.removeDialog.cancel} + onConfirm={account => onRemove(account.id)} + /> + ) : null} + ); } 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 b2aefd190c7..c2c12d3b30a 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 @@ -33,7 +33,7 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect web3Wallets?: UserProfileWeb3Wallet[]; availableWeb3Providers?: UserProfileWeb3Provider[]; onConnectAccount?: (id: string) => void; - onRemoveConnectedAccount?: (id: string) => void; + onRemoveConnectedAccount?: (id: string) => void | Promise; onConnectWeb3Wallet?: (id: string) => void; onSetPrimaryWeb3Wallet?: (id: string) => void; onRemoveWeb3Wallet?: (id: string) => void; @@ -118,15 +118,13 @@ export function UserProfileProfilePanelView({ onSubmitName={onSubmitName} onSubmitUsername={onSubmitUsername} /> - {connectedAccounts.length > 0 || (availableConnectionProviders.length > 0 && onConnectAccount) ? ( - - ) : null} + {web3Wallets.length > 0 || (availableWeb3Providers.length > 0 && onConnectWeb3Wallet) ? ( void; - onConfirm: () => void; - isPending?: boolean; - errorMessage?: string; -} - -export function UserProfileRemoveConnectedAccountDialog({ - provider, - open, - onOpenChange, - onConfirm, - isPending, - errorMessage, -}: UserProfileRemoveConnectedAccountDialogProps) { - return ( - - - }>{m.removeDialog.title} - }> - {m.removeDialog.description.replace('{provider}', provider)} - - {errorMessage ? ( - - {errorMessage} - - ) : null} - - } - > - {m.removeDialog.cancel} - - - {m.removeDialog.confirm} - - - - - ); -} From 9d08e78a2ed666125e6ff42278bd5356de6a6a00 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 15:56:03 -0600 Subject: [PATCH 02/14] docs(swingset): demonstrate async connected account removal --- .../user-profile-connected-accounts.ts | 24 +++++++++---------- ...ser-profile-connected-accounts-section.mdx | 4 ++-- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts index b2ddebf7df9..9f5218c2ec4 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts @@ -24,6 +24,7 @@ export function useConnectedAccountsFixture({ providers?: UserProfileConnectionProvider[]; removalState?: 'pending' | 'error'; } = {}) { + const [hasRemovalFailed, setHasRemovalFailed] = useState(false); const [accounts, setAccounts] = useState(initialAccounts); const availableProviders = providers.filter(provider => !accounts.some(account => account.id === provider.id)); @@ -47,18 +48,15 @@ export function useConnectedAccountsFixture({ : account, ), ), - onRemove: (id: string) => - setAccounts(current => { - const account = current.find(item => item.id === id); - if (removalState === 'pending') { - return current.map(item => (item.id === id ? { ...item, isRemoving: true } : item)); - } - if (removalState === 'error' && account && !account.removalError) { - return current.map(item => - item.id === id ? { ...item, removalError: 'Unable to remove this account. Please try again.' } : item, - ); - } - return current.filter(item => item.id !== id); - }), + onRemove: async (id: string) => { + if (removalState === 'pending') { + await new Promise(resolve => setTimeout(resolve, 1500)); + } + if (removalState === 'error' && !hasRemovalFailed) { + setHasRemovalFailed(true); + throw new Error('Unable to remove this account. Please try again.'); + } + setAccounts(current => current.filter(item => item.id !== id)); + }, }; } diff --git a/packages/swingset/src/stories/user-profile-connected-accounts-section.mdx b/packages/swingset/src/stories/user-profile-connected-accounts-section.mdx index ea938dfcc4f..12857299028 100644 --- a/packages/swingset/src/stories/user-profile-connected-accounts-section.mdx +++ b/packages/swingset/src/stories/user-profile-connected-accounts-section.mdx @@ -2,7 +2,7 @@ import * as Stories from './user-profile-connected-accounts-section.stories'; # UserProfileConnectedAccountsSection -Linked accounts and available providers are supplied separately. These examples simulate state through props; they do not start OAuth or mutate a user. Reload to reset interactive examples. +Linked accounts and available providers are supplied separately. Removal uses one shared confirmation per section, which awaits the removal callback and displays failures. These examples simulate actions; they do not start OAuth or mutate a user. Reload to reset interactive examples. @@ -30,7 +30,7 @@ Recoverable verification errors and additional scope requirements show a Disconn ## Removal Pending -Choose Remove from the row menu, then confirm to show the pending state. +Choose Remove from the row menu, then confirm. The dialog shows pending for a short delay, then the account becomes available to connect again. From 12b69b558a61feb1c9f93f88f0f6f72904578e57 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 16:26:40 -0600 Subject: [PATCH 03/14] refactor(ui): share email removal confirmation --- .../stories/user-profile-account-section.mdx | 12 +++ .../user-profile-account-section.stories.tsx | 32 +++++++- .../user-profile-email-actions.test.tsx | 35 +++++++-- .../user-profile-profile-panel.view.test.tsx | 6 +- .../user-profile-email-row.view.tsx | 74 +++++++------------ .../user-profile-remove-email.dialog.tsx | 50 ------------- 6 files changed, 102 insertions(+), 107 deletions(-) delete mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index 74dfc443796..d6b17b91e25 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -99,3 +99,15 @@ simulates completion and closes the dialog. Connect to see the supplied error message. The user can retry or cancel. + +## Email removal pending + +Confirm removal to see a short pending state before the email disappears. + + + +## Email removal error + +The first attempt fails inside the confirmation. Retry succeeds. + + diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index c3febdbe96f..0133233e0e9 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -38,13 +38,16 @@ function AccountSection({ failWith, usernameFailWith, failEmailVerification = false, + emailRemovalState, }: { allowMultipleAccounts: boolean; failAt?: UserProfileAddPhoneDialogProps['step']; failWith?: UserProfileFormError; usernameFailWith?: UserProfileFormError; failEmailVerification?: boolean; + emailRemovalState?: 'pending' | 'error'; }) { + const [emailRemovalFailed, setEmailRemovalFailed] = useState(false); const editName = useUserProfileEditNameFixture({ failWith }); const editUsername = useUserProfileEditUsernameFixture({ failWith: usernameFailWith }); const [emails, setEmails] = useState( @@ -84,7 +87,16 @@ function AccountSection({ onRemoveProfilePicture={clearImage} onManageEmail={() => undefined} onManagePhone={() => undefined} - onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + onRemoveEmail={async id => { + if (emailRemovalState === 'pending') { + await new Promise(resolve => setTimeout(resolve, 1500)); + } + if (emailRemovalState === 'error' && !emailRemovalFailed) { + setEmailRemovalFailed(true); + throw new Error('Unable to remove this email address. Try again.'); + } + setEmails(current => current.filter(email => email.id !== id)); + }} onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} @@ -210,3 +222,21 @@ export function AddPhoneFails() { /> ); } + +export function EmailRemovalPending() { + return ( + + ); +} + +export function EmailRemovalError() { + return ( + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-email-actions.test.tsx index 0a06959a57f..b2a5f5a8996 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-email-actions.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { act, 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'; @@ -32,11 +32,11 @@ describe('email actions', () => { trigger.focus(); await user.keyboard('{Enter}'); await user.keyboard('{Enter}'); - expect(screen.getByRole('alertdialog', { name: 'Remove email address?' })).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Remove email address?' })).toBeInTheDocument(); await user.keyboard('{Escape}'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(onRemoveEmail).not.toHaveBeenCalled(); await waitFor(() => expect(trigger).toHaveFocus()); }); @@ -63,9 +63,9 @@ describe('email actions', () => { render(); await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); await user.click(screen.getByRole('menuitem', { name: 'Remove email' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(screen.queryByRole('button', { name: 'Manage test@example.com' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add email' })).toBeEnabled(); }); @@ -78,6 +78,29 @@ describe('email actions', () => { await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); expect(onSetPrimaryEmail).toHaveBeenCalledExactlyOnceWith('email_1'); expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary email.'); - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('keeps removal pending and lets the user retry a failure in the dialog', async () => { + const user = userEvent.setup(); + const removal = Promise.withResolvers(); + const onRemoveEmail = vi.fn().mockReturnValueOnce(removal.promise).mockResolvedValue(undefined); + renderEmail({ onRemoveEmail }); + await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove email' })); + const dialog = screen.getByRole('dialog', { name: 'Remove email address?' }); + expect(dialog).toHaveAccessibleDescription(/test@example.com/); + await user.click(within(dialog).getByRole('button', { name: 'Remove' })); + expect(within(dialog).getByRole('button', { name: 'Remove' })).toHaveAttribute('aria-busy', 'true'); + + await act(async () => { + removal.reject(new Error('Unable to remove email.')); + await removal.promise.catch(() => undefined); + }); + expect(within(dialog).getByRole('alert')).toHaveTextContent('Unable to remove email.'); + await user.click(within(dialog).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onRemoveEmail).toHaveBeenNthCalledWith(1, 'email_1'); + expect(onRemoveEmail).toHaveBeenNthCalledWith(2, 'email_1'); }); }); 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 1d5fc5faa2b..d9ea9e80816 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 @@ -343,8 +343,8 @@ describe('UserProfileProfilePanelView', () => { expect(onRemoveEmail).not.toHaveBeenCalled(); await user.click(screen.getByRole('menuitem', { name: 'Remove email' })); expect(onRemoveEmail).not.toHaveBeenCalled(); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); // Last: the edit-name dialog is modal, so the rest of the panel goes inert once it opens. await user.click(screen.getByRole('button', { name: 'Edit name' })); @@ -432,7 +432,7 @@ describe('UserProfileProfilePanelView', () => { await user.click(removeEmail); expect(onRemoveEmail).not.toHaveBeenCalled(); await user.click( - within(screen.getByRole('alertdialog', { name: 'Remove email address?' })).getByRole('button', { + within(screen.getByRole('dialog', { name: 'Remove email address?' })).getByRole('button', { name: 'Remove', }), ); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx index 048f04f0e69..bbb5accff2b 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx @@ -1,8 +1,11 @@ -import { useRef, useState } from 'react'; +import * as stylex from '@stylexjs/stylex'; +import { useMemo, useRef, useState } from 'react'; +import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Text } from '../../../components/text'; +import { styles } from '../user-profile-profile-panel.styles'; import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileEmail } from './user-profile-account-section.types'; import type { UserProfileAddEmailControllerOptions } from './user-profile-add-email.controller'; @@ -10,7 +13,6 @@ import { useUserProfileAddEmailController } from './user-profile-add-email.contr import { UserProfileAddEmailDialog } from './user-profile-add-email.dialog'; import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; import { UserProfileContactRowView } from './user-profile-contact-row.view'; -import { UserProfileRemoveEmailDialog } from './user-profile-remove-email.dialog'; export interface UserProfileEmailRowViewProps { emails: UserProfileEmail[]; @@ -59,9 +61,7 @@ export function UserProfileEmailRowView({ {allowMultipleAccounts ? m.add : m.email.add} ) : undefined; - const [emailToRemove, setEmailToRemove] = useState(); - const [removeError, setRemoveError] = useState(); - const removing = useRef(false); + const removeEmailConfirmation = useMemo(() => Confirmation.createHandle(), []); const [isSettingPrimary, setIsSettingPrimary] = useState(false); const [primaryError, setPrimaryError] = useState(); const settingPrimary = useRef(false); @@ -86,25 +86,8 @@ export function UserProfileEmailRowView({ const removeEmail = (id: string) => { const email = emails.find(email => email.id === id); - if (!email || email.canRemove === false || !onRemoveEmail || removing.current) { - return; - } - setEmailToRemove(email); - setRemoveError(undefined); - }; - - const confirmRemoveEmail = async () => { - if (!emailToRemove || !onRemoveEmail || removing.current) { - return; - } - removing.current = true; - setEmailToRemove(undefined); - try { - await onRemoveEmail(emailToRemove.id); - } catch (error) { - setRemoveError(error instanceof Error ? error.message : m.email.removeError); - } finally { - removing.current = false; + if (email && email.canRemove !== false && onRemoveEmail) { + removeEmailConfirmation.open(email); } }; @@ -130,22 +113,6 @@ export function UserProfileEmailRowView({ onRemove={onRemoveEmail ? removeEmail : undefined} onSetPrimary={onSetPrimaryEmail && !isSettingPrimary ? id => void setPrimaryEmail(id) : undefined} onVerify={onVerifyEmail} - renderActionDialog={ - onRemoveEmail - ? email => ( - { - if (!open) { - setEmailToRemove(undefined); - } - }} - onConfirm={() => void confirmRemoveEmail()} - /> - ) - : undefined - } /> {primaryError ? ( ) : null} - {removeError ? ( - - {removeError} - + {onRemoveEmail ? ( + onRemoveEmail(email.id)} + /> ) : null} ); @@ -192,3 +161,14 @@ function AddEmail({ options, compact }: { options: UserProfileAddEmailController /> ); } + +function describeEmailRemoval(email: UserProfileEmail) { + const [beforeEmail, afterEmail] = m.email.removeDialog.description.split('{emailAddress}'); + return ( + <> + {beforeEmail} + {email.value} + {afterEmail} + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx deleted file mode 100644 index 7fcb9cb9080..00000000000 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { Button } from '../../../components/button'; -import { Dialog } from '../../../components/dialog'; -import { Heading } from '../../../components/heading'; -import { Text } from '../../../components/text'; -import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; - -export interface UserProfileRemoveEmailDialogProps { - emailAddress: string; - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; -} - -export function UserProfileRemoveEmailDialog({ - emailAddress, - open, - onOpenChange, - onConfirm, -}: UserProfileRemoveEmailDialogProps) { - const [beforeEmail, afterEmail] = m.email.removeDialog.description.split('{emailAddress}'); - - return ( - - - }>{m.email.removeDialog.title} - }> - {beforeEmail} - {emailAddress} - {afterEmail} - - - }>{m.email.removeDialog.cancel} - - - - - ); -} From aba01f6bf7911af6c08106c85f927b1e395c3bdc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 16:31:39 -0600 Subject: [PATCH 04/14] refactor(ui): share phone removal confirmation --- .../stories/user-profile-account-section.mdx | 12 +++ .../user-profile-account-section.stories.tsx | 32 +++++++- .../user-profile-phone-actions.test.tsx | 47 ++++++------ .../user-profile-profile-panel.view.test.tsx | 2 +- .../user-profile-phone-row.view.tsx | 74 +++++++------------ .../user-profile-remove-phone.dialog.tsx | 50 ------------- 6 files changed, 92 insertions(+), 125 deletions(-) delete mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index d6b17b91e25..3110e103387 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -111,3 +111,15 @@ Confirm removal to see a short pending state before the email disappears. The first attempt fails inside the confirmation. Retry succeeds. + +## Phone removal pending + +Remove a phone number to see the confirmation remain open until removal completes. + + + +## Phone removal error + +The first removal fails. Retry in the confirmation to remove the phone number. + + diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 0133233e0e9..3455995cda2 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -39,6 +39,7 @@ function AccountSection({ usernameFailWith, failEmailVerification = false, emailRemovalState, + phoneRemovalState, }: { allowMultipleAccounts: boolean; failAt?: UserProfileAddPhoneDialogProps['step']; @@ -46,7 +47,9 @@ function AccountSection({ usernameFailWith?: UserProfileFormError; failEmailVerification?: boolean; emailRemovalState?: 'pending' | 'error'; + phoneRemovalState?: 'pending' | 'error'; }) { + const [phoneRemovalFailed, setPhoneRemovalFailed] = useState(false); const [emailRemovalFailed, setEmailRemovalFailed] = useState(false); const editName = useUserProfileEditNameFixture({ failWith }); const editUsername = useUserProfileEditUsernameFixture({ failWith: usernameFailWith }); @@ -98,7 +101,16 @@ function AccountSection({ setEmails(current => current.filter(email => email.id !== id)); }} onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} - onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} + onRemovePhone={async id => { + if (phoneRemovalState === 'pending') { + await new Promise(resolve => setTimeout(resolve, 1500)); + } + if (phoneRemovalState === 'error' && !phoneRemovalFailed) { + setPhoneRemovalFailed(true); + throw new Error('Unable to remove this phone number. Try again.'); + } + setPhones(current => current.filter(phone => phone.id !== id)); + }} onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} /> ); @@ -240,3 +252,21 @@ export function EmailRemovalError() { /> ); } + +export function PhoneRemovalPending() { + return ( + + ); +} + +export function PhoneRemovalError() { + return ( + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-phone-actions.test.tsx index 97b4dbcf3ef..bf4bfe1a806 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-phone-actions.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -29,7 +29,7 @@ describe('phone actions', () => { renderPhone({ onRemovePhone }); await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); - const dialog = screen.getByRole('alertdialog', { name: 'Remove phone number?' }); + const dialog = screen.getByRole('dialog', { name: 'Remove phone number?' }); const backdrop = document.querySelector('.cl-dialog-backdrop'); if (!backdrop) { throw new Error('Expected a dialog backdrop'); @@ -41,7 +41,7 @@ describe('phone actions', () => { expect(onRemovePhone).not.toHaveBeenCalled(); }); - it('closes confirmation before deletion finishes and prevents duplicate requests', async () => { + it('keeps confirmation open until deletion finishes and prevents duplicate requests', async () => { const user = userEvent.setup(); let finish = () => {}; const pending = new Promise(resolve => { @@ -51,16 +51,15 @@ describe('phone actions', () => { renderPhone({ onRemovePhone }); await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); - const dialog = screen.getByRole('alertdialog'); + const dialog = screen.getByRole('dialog'); const remove = within(dialog).getByRole('button', { name: 'Remove' }); await user.click(remove); - await waitFor(() => expect(dialog).not.toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(dialog).toBeInTheDocument(); + expect(remove).toHaveAttribute('aria-busy', 'true'); + await user.click(remove); expect(onRemovePhone).toHaveBeenCalledOnce(); finish(); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); it('hides set primary while an update is pending', async () => { @@ -118,7 +117,7 @@ describe('phone actions', () => { await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); expect(screen.getByText('Primary')).toBeInTheDocument(); - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Manage +1 (801) 555-0100' })).not.toBeInTheDocument(); }); @@ -128,8 +127,8 @@ describe('phone actions', () => { renderPhone({ onRemovePhone }); await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(onRemovePhone).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })).toHaveFocus(); }); @@ -143,11 +142,11 @@ describe('phone actions', () => { trigger.focus(); await user.keyboard('{Enter}'); await user.keyboard('{Enter}'); - expect(screen.getByRole('alertdialog', { name: 'Remove phone number?' })).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Remove phone number?' })).toBeInTheDocument(); await user.keyboard('{Escape}'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(onRemovePhone).not.toHaveBeenCalled(); await waitFor(() => expect(trigger).toHaveFocus()); }); @@ -174,14 +173,14 @@ describe('phone actions', () => { render(); await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(screen.queryByRole('button', { name: 'Manage +1 (801) 555-0100' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add phone number' })).toBeEnabled(); }); - it('shows a failed removal in the account section and allows retry from the menu', async () => { + it('shows a failed removal in the dialog and allows retry', async () => { const user = userEvent.setup(); const onRemovePhone = vi .fn() @@ -190,13 +189,10 @@ describe('phone actions', () => { renderPhone({ onRemovePhone }); await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); expect(await screen.findByRole('alert')).toHaveTextContent('Cannot remove this phone.'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(onRemovePhone).toHaveBeenCalledTimes(2); }); @@ -218,7 +214,7 @@ describe('phone actions', () => { await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); expect(onSetPrimaryPhone).toHaveBeenCalledExactlyOnceWith('phone_1'); expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary phone.'); - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('requires confirmation before removing a phone number', async () => { const user = userEvent.setup(); @@ -238,11 +234,10 @@ describe('phone actions', () => { await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); expect(onRemovePhone).not.toHaveBeenCalled(); - const dialog = screen.getByRole('alertdialog', { name: 'Remove phone number?' }); + const dialog = screen.getByRole('dialog', { name: 'Remove phone number?' }); expect(dialog).toHaveTextContent('+1 (801) 555-0100'); - expect(within(dialog).queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); await user.click(within(dialog).getByRole('button', { name: 'Remove' })); expect(onRemovePhone).toHaveBeenCalledExactlyOnceWith('phone_1'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); }); 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 d9ea9e80816..ddf93a54818 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 @@ -450,7 +450,7 @@ describe('UserProfileProfilePanelView', () => { await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); expect(onRemovePhone).not.toHaveBeenCalled(); await user.click( - within(screen.getByRole('alertdialog', { name: 'Remove phone number?' })).getByRole('button', { + within(screen.getByRole('dialog', { name: 'Remove phone number?' })).getByRole('button', { name: 'Remove', }), ); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx index 213b5c6a410..e6f18415ef5 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx @@ -1,9 +1,12 @@ -import { useRef, useState } from 'react'; +import * as stylex from '@stylexjs/stylex'; +import { useMemo, useRef, useState } from 'react'; import { stringToFormattedPhoneString } from '../../../../utils/phoneUtils'; +import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Text } from '../../../components/text'; +import { styles } from '../user-profile-profile-panel.styles'; import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfilePhone } from './user-profile-account-section.types'; import type { UserProfileAddPhoneControllerOptions } from './user-profile-add-phone.controller'; @@ -11,7 +14,6 @@ import { useUserProfileAddPhoneController } from './user-profile-add-phone.contr import { UserProfileAddPhoneDialog } from './user-profile-add-phone.dialog'; import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; import { UserProfileContactRowView } from './user-profile-contact-row.view'; -import { UserProfileRemovePhoneDialog } from './user-profile-remove-phone.dialog'; export interface UserProfilePhoneRowViewProps { phones: UserProfilePhone[]; @@ -41,9 +43,7 @@ export function UserProfilePhoneRowView({ compact={allowMultipleAccounts} /> ) : undefined; - const [phoneToRemove, setPhoneToRemove] = useState(); - const [removeError, setRemoveError] = useState(); - const removing = useRef(false); + const removePhoneConfirmation = useMemo(() => Confirmation.createHandle(), []); const [isSettingPrimary, setIsSettingPrimary] = useState(false); const [primaryError, setPrimaryError] = useState(); const settingPrimary = useRef(false); @@ -68,25 +68,8 @@ export function UserProfilePhoneRowView({ const removePhone = (id: string) => { const phone = phones.find(phone => phone.id === id); - if (!phone || phone.canRemove === false || !onRemovePhone || removing.current) { - return; - } - setPhoneToRemove(phone); - setRemoveError(undefined); - }; - - const confirmRemovePhone = async () => { - if (!phoneToRemove || !onRemovePhone || removing.current) { - return; - } - removing.current = true; - setPhoneToRemove(undefined); - try { - await onRemovePhone(phoneToRemove.id); - } catch (error) { - setRemoveError(error instanceof Error ? error.message : m.phone.removeError); - } finally { - removing.current = false; + if (phone && phone.canRemove !== false && onRemovePhone) { + removePhoneConfirmation.open(phone); } }; const formattedPhones = phones.map(phone => ({ @@ -116,22 +99,6 @@ export function UserProfilePhoneRowView({ onRemove={onRemovePhone ? removePhone : undefined} onSetPrimary={onSetPrimaryPhone && !isSettingPrimary ? id => void setPrimaryPhone(id) : undefined} onVerify={onVerifyPhone} - renderActionDialog={ - onRemovePhone - ? phone => ( - { - if (!open) { - setPhoneToRemove(undefined); - } - }} - onConfirm={() => void confirmRemovePhone()} - /> - ) - : undefined - } /> {primaryError ? ( ) : null} - {removeError ? ( - - {removeError} - + {onRemovePhone ? ( + onRemovePhone(phone.id)} + /> ) : null} ); @@ -178,3 +147,14 @@ function AddPhone({ options, compact }: { options: UserProfileAddPhoneController /> ); } + +function describePhoneRemoval(phone: UserProfilePhone) { + const [beforePhone, afterPhone] = m.phone.removeDialog.description.split('{phoneNumber}'); + return ( + <> + {beforePhone} + {stringToFormattedPhoneString(phone.value)} + {afterPhone} + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx deleted file mode 100644 index e7c58ee551c..00000000000 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { Button } from '../../../components/button'; -import { Dialog } from '../../../components/dialog'; -import { Heading } from '../../../components/heading'; -import { Text } from '../../../components/text'; -import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; - -export interface UserProfileRemovePhoneDialogProps { - phoneNumber: string; - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; -} - -export function UserProfileRemovePhoneDialog({ - phoneNumber, - open, - onOpenChange, - onConfirm, -}: UserProfileRemovePhoneDialogProps) { - const [beforePhone, afterPhone] = m.phone.removeDialog.description.split('{phoneNumber}'); - - return ( - - - }>{m.phone.removeDialog.title} - }> - {beforePhone} - {phoneNumber} - {afterPhone} - - - }>{m.phone.removeDialog.cancel} - - - - - ); -} From 4fa3abed29cd0dd14d18d1ab5b08b37ef4c7f724 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 16:36:53 -0600 Subject: [PATCH 05/14] refactor(ui): share Web3 wallet removal confirmation --- .../fixtures/user-profile-web3-wallets.ts | 22 ++--- .../user-profile-web3-wallets-section.mdx | 5 +- .../user-profile-profile-panel.view.test.tsx | 29 +++++++ .../user-profile-web3-actions.test.tsx | 74 ++++++++++------- ...profile-web3-wallets-section.view.test.tsx | 2 +- .../user-profile-profile-panel.view.tsx | 18 ++--- ...user-profile-remove-web3-wallet.dialog.tsx | 67 --------------- .../user-profile-web3-wallet-row.view.tsx | 25 ++---- ...user-profile-web3-wallets-section.view.tsx | 81 +++++++++++++------ 9 files changed, 153 insertions(+), 170 deletions(-) delete mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx 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 75b19217a98..c1d169d6ff6 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -45,6 +45,7 @@ export function useWeb3WalletsFixture({ } = {}) { const [wallets, setWallets] = useState(initialWallets); const [connectionProviders, setConnectionProviders] = useState(availableProviders); + const [removalFailed, setRemovalFailed] = useState(false); const [primaryFailed, setPrimaryFailed] = useState(false); return { @@ -94,22 +95,15 @@ export function useWeb3WalletsFixture({ current.map(wallet => ({ ...wallet, isPrimary: wallet.id === id, primaryError: undefined })), ); }, - onRemove: (id: string) => { + onRemove: async (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; + await new Promise(resolve => setTimeout(resolve, 1500)); } - setWallets(current => { - 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); - }); + if (removalState === 'error' && !removalFailed) { + setRemovalFailed(true); + throw new Error('Unable to remove wallet. Please try again.'); + } + setWallets(current => 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 b14b20c3e6a..e9b9486872d 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.mdx @@ -13,8 +13,7 @@ Wallets and available providers are supplied separately. These examples simulate { name: 'Button', href: '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/components/button', layer: 'Components' }, { name: 'Icon', href: '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/components/icon', layer: 'Components' }, { name: 'Menu', href: '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/components/menu', layer: 'Components' }, - { name: 'Dialog', href: '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/components/dialog', layer: 'Components' }, - { name: 'Banner', href: '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/components/banner', layer: 'Components' }, + { name: 'Confirmation', href: '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/blocks/confirmation', layer: 'Blocks' }, ]} /> @@ -48,7 +47,7 @@ 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. +Choose Remove wallet, then confirm. The confirmation stays open until removal completes after a short delay. 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 ddf93a54818..652f62db636 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 @@ -69,6 +69,35 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('heading', { name: 'Account', level: 3 })).toBeVisible(); }); + it('keeps the final wallet confirmation mounted until removal settles', async () => { + const user = userEvent.setup(); + const removal = Promise.withResolvers(); + const onRemoveWeb3Wallet = vi.fn(() => removal.promise); + const { rerender } = renderView({ + web3Wallets: [{ id: 'wallet_1', provider: 'MetaMask', address: '0x1234', isVerified: true }], + onRemoveWeb3Wallet, + }); + await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); + rerender( + + + , + ); + expect(screen.queryByRole('heading', { name: 'Web3 wallets' })).not.toBeInTheDocument(); + expect(screen.getByRole('dialog')).toHaveTextContent('0x1234'); + await act(async () => { + removal.resolve(); + await removal.promise; + }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + it('keeps available providers visible without connected accounts', () => { const onConnectAccount = vi.fn(); renderView({ 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 d7a1493b3c3..32f7ffab114 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 @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { act, 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'; @@ -8,7 +8,7 @@ import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets- const wallet = { id: 'wallet_1', provider: 'MetaMask', address: 'test', isVerified: true }; -function renderWallets(onRemove: (id: string) => void) { +function renderWallets(onRemove: (id: string) => void | Promise) { return render( { const trigger = screen.getByRole('button', { name: 'Manage MetaMask' }); trigger.focus(); await user.keyboard('{Enter}{Enter}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); await user.keyboard('{Escape}'); await waitFor(() => expect(trigger).toHaveFocus()); expect(onRemove).not.toHaveBeenCalled(); @@ -53,45 +53,61 @@ describe('Web3 wallet removal', () => { } render(); await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage MetaMask' })).not.toBeInTheDocument()); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); - it('keeps confirmation open while pending and allows retrying an error supplied by the caller', async () => { + it('keeps confirmation open while pending and allows retrying a rejected removal', async () => { const user = userEvent.setup(); - const onRemove = vi.fn(); - const { rerender } = renderWallets(onRemove); + const removal = Promise.withResolvers(); + const onRemove = vi + .fn() + .mockImplementationOnce(() => removal.promise) + .mockResolvedValue(undefined); + 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 })); + const dialog = screen.getByRole('dialog'); + const remove = within(dialog).getByRole('button', { name: 'Remove', exact: true }); + await user.click(remove); 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(remove).toHaveAttribute('aria-busy', 'true'); + await user.click(remove); + expect(onRemove).toHaveBeenCalledOnce(); + await act(async () => { + removal.reject(new Error('Unable to remove wallet')); + await removal.promise.catch(() => undefined); + }); + expect(within(dialog).getByRole('alert')).toHaveTextContent('Unable to remove wallet'); + await user.click(remove); + expect(onRemove).toHaveBeenNthCalledWith(2, 'wallet_1'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('uses the newly selected wallet and its warning after canceling another removal', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + render( , ); - expect(within(dialog).getByRole('alert')).toHaveTextContent('Unable to remove wallet'); + await openRemoval(user); + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveTextContent('second-wallet'); + expect(dialog).not.toHaveTextContent('You will no longer be able to sign in using this web3 wallet.'); await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledTimes(2); - await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_2'); }); }); 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 a9aa62fdccb..7f93b39c2b2 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 @@ -70,7 +70,7 @@ describe('Web3 wallets section', () => { 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'); + const dialog = screen.getByRole('dialog'); expect(dialog).toHaveTextContent(address); const warning = within(dialog).queryByText('You will no longer be able to sign in using this web3 wallet.'); if (isVerified) { 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 c2c12d3b30a..3d79c599f22 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 @@ -36,7 +36,7 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect onRemoveConnectedAccount?: (id: string) => void | Promise; onConnectWeb3Wallet?: (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; } @@ -125,15 +125,13 @@ export function UserProfileProfilePanelView({ onConnect={onConnectAccount} onRemove={onRemoveConnectedAccount} /> - {web3Wallets.length > 0 || (availableWeb3Providers.length > 0 && onConnectWeb3Wallet) ? ( - - ) : null} + {onDeleteAccount ? : null} 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 deleted file mode 100644 index f26fcb4ae3b..00000000000 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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 { - address: string; - isVerified: boolean; - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - isPending?: boolean; - errorMessage?: string; -} - -export function UserProfileRemoveWeb3WalletDialog({ - address, - isVerified, - open, - onOpenChange, - onConfirm, - isPending, - errorMessage, -}: UserProfileRemoveWeb3WalletDialogProps) { - return ( - - - }>{m.removeDialog.title} - }> - {m.removeDialog.description.replace('{wallet}', address)} - - {isVerified ? {m.removeDialog.signInWarning} : null} - {errorMessage ? ( - - {errorMessage} - - ) : null} - - } - > - {m.removeDialog.cancel} - - - {m.removeDialog.confirm} - - - - - ); -} 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 ed2f66e9494..8fff76f95ea 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,5 +1,4 @@ import * as stylex from '@stylexjs/stylex'; -import { useState } from 'react'; import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; @@ -7,7 +6,6 @@ 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'; import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; import { styles } from './user-profile-web3-wallets.styles'; import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; @@ -16,14 +14,13 @@ export function UserProfileWeb3WalletRowView({ wallet, onConnect, onSetPrimary, - onRemove, + onRequestRemove, }: { wallet: UserProfileWeb3Wallet | UserProfileWeb3Provider; onConnect?: (id: string) => void; onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void; + onRequestRemove?: (wallet: UserProfileWeb3Wallet) => void; }) { - const [open, setOpen] = useState(false); const iconUrl = wallet.iconUrl?.trim(); const linkedWallet = 'address' in wallet ? wallet : undefined; const address = linkedWallet?.address; @@ -33,8 +30,8 @@ export function UserProfileWeb3WalletRowView({ if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary) { actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(wallet.id) }); } - if (linkedWallet && onRemove && linkedWallet.canRemove !== false) { - actions.push({ label: m.remove, color: 'negative', onClick: () => setOpen(true) }); + if (linkedWallet && onRequestRemove && linkedWallet.canRemove !== false) { + actions.push({ label: m.remove, color: 'negative', onClick: () => onRequestRemove(linkedWallet) }); } return ( @@ -98,19 +95,7 @@ export function UserProfileWeb3WalletRowView({ - {linkedWallet && onRemove && linkedWallet.canRemove !== false ? ( - onRemove(wallet.id)} - isPending={linkedWallet.isRemoving} - errorMessage={linkedWallet.removalError} - /> - ) : null} - + /> ) : 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 84d2671f18a..b0bb355f712 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,3 +1,6 @@ +import { useMemo } from 'react'; + +import { Confirmation } from '../../blocks/confirmation'; import { Section } from '../../components/section'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; @@ -17,8 +20,6 @@ export interface UserProfileWeb3Wallet { isPrimary?: boolean; isVerified: boolean; canRemove?: boolean; - isRemoving?: boolean; - removalError?: string; primaryError?: string; } @@ -27,7 +28,7 @@ export interface UserProfileWeb3WalletsSectionViewProps { availableProviders?: UserProfileWeb3Provider[]; onConnect?: (id: string) => void; onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void; + onRemove?: (id: string) => void | Promise; } export function UserProfileWeb3WalletsSectionView({ @@ -37,32 +38,60 @@ export function UserProfileWeb3WalletsSectionView({ onSetPrimary, onRemove, }: UserProfileWeb3WalletsSectionViewProps) { - if (wallets.length === 0 && (availableProviders.length === 0 || !onConnect)) { - return null; - } + const removeWallet = useMemo(() => Confirmation.createHandle(), []); + const hasRows = wallets.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); return ( - - {m.title} - - {wallets.map(wallet => ( - - ))} - {onConnect - ? availableProviders.map(provider => ( + <> + {hasRows ? ( + + {m.title} + + {wallets.map(wallet => ( removeWallet.open(wallet) : undefined} /> - )) - : null} - - + ))} + {onConnect + ? availableProviders.map(provider => ( + + )) + : null} + + + ) : null} + {onRemove ? ( + onRemove(wallet.id)} + /> + ) : null} + + ); +} + +function describeWalletRemoval(wallet: UserProfileWeb3Wallet) { + return ( + <> + {m.removeDialog.description.replace('{wallet}', wallet.address)} + {wallet.isVerified ? ( + <> +
+
+ {m.removeDialog.signInWarning} + + ) : null} + ); } From bb941f7d12f7385c2a63d098050e93f6478ac8c6 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 16:37:57 -0600 Subject: [PATCH 06/14] refactor(ui): remove unused contact dialog render hook --- .../__tests__/user-profile-contact-list-row.view.test.tsx | 3 --- .../user-profile-contact-list-row.view.tsx | 6 +----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx index 96b6cff6372..ba9098b25f9 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx @@ -9,7 +9,6 @@ describe('UserProfileContactListRowView', () => { const onVerify = vi.fn(); const onSetPrimary = vi.fn(); const onRemove = vi.fn(); - const renderActionDialog = vi.fn(() => null); const item = { id: 'contact_1', value: 'Contact', isDefault: true, isVerified: true, canRemove: false }; render( { onVerify={onVerify} onSetPrimary={onSetPrimary} onRemove={onRemove} - renderActionDialog={renderActionDialog} />, ); expect(screen.queryByRole('button', { name: 'Manage Contact' })).not.toBeInTheDocument(); - expect(renderActionDialog).not.toHaveBeenCalled(); }); it('offers removal when it applies', async () => { diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx index ae602a53a95..573a06da5fe 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx @@ -18,7 +18,6 @@ export interface UserProfileContactListRowViewProps { onVerify?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void; - renderActionDialog?: (item: { id: string; value: string }) => ReactNode; } export function UserProfileContactListRowView({ @@ -30,7 +29,6 @@ export function UserProfileContactListRowView({ onSetPrimary, onRemove, addAction, - renderActionDialog, }: UserProfileContactListRowViewProps) { const emptyDescription = m[kind].empty; @@ -102,9 +100,7 @@ export function UserProfileContactListRowView({ - {renderActionDialog?.(item)} - + /> ) : null} From 82cba68ef3cacf4d8f1999da883546a4575277eb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 17:00:45 -0600 Subject: [PATCH 07/14] refactor(ui): simplify user profile dialog descriptions --- .../user-profile-web3-wallets-section.view.test.tsx | 8 ++++---- .../user-profile-account-section.messages.ts | 9 --------- .../user-profile-email-row.view.tsx | 7 +------ .../user-profile-phone-row.view.tsx | 8 ++------ .../user-profile-verify-email-link.dialog.tsx | 5 +---- .../user-profile-verify-email-link.messages.ts | 1 - .../user-profile-verify-email-link.styles.ts | 5 +---- .../user-profile/user-profile-profile-panel.styles.ts | 8 +------- .../user-profile-web3-wallets-section.view.tsx | 2 +- 9 files changed, 11 insertions(+), 42 deletions(-) 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 7f93b39c2b2..1fd402c0d14 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 @@ -1,4 +1,4 @@ -import { render, screen, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -72,11 +72,11 @@ describe('Web3 wallets section', () => { await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); const dialog = screen.getByRole('dialog'); expect(dialog).toHaveTextContent(address); - const warning = within(dialog).queryByText('You will no longer be able to sign in using this web3 wallet.'); + const warning = 'You will no longer be able to sign in using this web3 wallet.'; if (isVerified) { - expect(warning).toBeVisible(); + expect(dialog).toHaveTextContent(warning); } else { - expect(warning).not.toBeInTheDocument(); + expect(dialog).not.toHaveTextContent(warning); } }, ); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 6d9ceab55dc..bf5cbab2f94 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -1,9 +1,4 @@ /** - * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so - * localizing this component is a matter of registering the namespace and swapping the reads for - * `useMessages('userProfileAccountSection', userProfileAccountSectionBase)`, not of hunting the - * literals down first. - * * A parameterized message is its template, the way `params()` takes it. `fill` below resolves them * until that layer lands. * @@ -61,10 +56,8 @@ export const userProfileAccountSectionBase = { verify: 'Verify', remove: 'Remove email', primaryError: 'Unable to set the primary email address. Try again.', - removeError: 'Unable to remove this email address. Try again.', removeDialog: { title: 'Remove email address?', - description: '{emailAddress} will be removed from your account. You won’t be able to use it to sign in.', confirm: 'Remove', cancel: 'Cancel', }, @@ -77,10 +70,8 @@ export const userProfileAccountSectionBase = { verify: 'Verify phone number', remove: 'Remove phone number', primaryError: 'Unable to set the primary phone number. Try again.', - removeError: 'Unable to remove this phone number. Try again.', removeDialog: { title: 'Remove phone number?', - description: '{phoneNumber} will be removed from your account. You won’t be able to use it to sign in.', confirm: 'Remove', cancel: 'Cancel', }, diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx index bbb5accff2b..36629288ada 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx @@ -1,11 +1,9 @@ -import * as stylex from '@stylexjs/stylex'; import { useMemo, useRef, useState } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Text } from '../../../components/text'; -import { styles } from '../user-profile-profile-panel.styles'; import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileEmail } from './user-profile-account-section.types'; import type { UserProfileAddEmailControllerOptions } from './user-profile-add-email.controller'; @@ -163,12 +161,9 @@ function AddEmail({ options, compact }: { options: UserProfileAddEmailController } function describeEmailRemoval(email: UserProfileEmail) { - const [beforeEmail, afterEmail] = m.email.removeDialog.description.split('{emailAddress}'); return ( <> - {beforeEmail} - {email.value} - {afterEmail} + {email.value} will be removed from your account. You won’t be able to use it to sign in. ); } diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx index e6f18415ef5..6b67896e98e 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx @@ -1,4 +1,3 @@ -import * as stylex from '@stylexjs/stylex'; import { useMemo, useRef, useState } from 'react'; import { stringToFormattedPhoneString } from '../../../../utils/phoneUtils'; @@ -6,7 +5,6 @@ import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; import { Icon } from '../../../components/icon'; import { Text } from '../../../components/text'; -import { styles } from '../user-profile-profile-panel.styles'; import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfilePhone } from './user-profile-account-section.types'; import type { UserProfileAddPhoneControllerOptions } from './user-profile-add-phone.controller'; @@ -149,12 +147,10 @@ function AddPhone({ options, compact }: { options: UserProfileAddPhoneController } function describePhoneRemoval(phone: UserProfilePhone) { - const [beforePhone, afterPhone] = m.phone.removeDialog.description.split('{phoneNumber}'); return ( <> - {beforePhone} - {stringToFormattedPhoneString(phone.value)} - {afterPhone} + {stringToFormattedPhoneString(phone.value)} will be removed from your account. You won’t be able + to use it to sign in. ); } diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx index 4638e63c331..b52d9c15337 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx @@ -32,7 +32,6 @@ export function UserProfileVerifyEmailLinkDialog({ resendSeconds = 0, errorMessage, }: UserProfileVerifyEmailLinkDialogProps) { - const [beforeEmail, afterEmail] = m.description.split('{emailAddress}'); const [beforeSeconds, afterSeconds] = m.resendCountdown.split('{seconds}'); return ( @@ -68,9 +67,7 @@ export function UserProfileVerifyEmailLinkDialog({
- {beforeEmail} - {emailAddress} - {afterEmail} + A verification link was sent to {emailAddress}
- - A verification link was sent to {emailAddress} - + {fill(m.description, { emailAddress })} - - - - - } - > - {m.verify.cancel} - - - {m.verify.submit} - - + + )} @@ -191,3 +86,162 @@ export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) ); } + +interface EnterEmailStepProps { + inputRef: Ref; + emailAddress: string; + onEmailAddressChange: (value: string) => void; + onSubmit: () => void; + isPending?: boolean; + errorMessage?: string; +} + +function EnterEmailStep(props: EnterEmailStepProps) { + const emailFormId = useId(); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + props.onSubmit(); + }; + + return ( + <> + + {m.email.title} + {m.email.description} + + + } + > + + {m.email.label} + props.onEmailAddressChange(event.target.value)} + /> + {props.errorMessage ? {props.errorMessage} : null} + + + + + {m.email.submit} + + + + ); +} + +interface VerifyEmailStepProps { + emailAddress: string; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +function VerifyEmailStep(props: VerifyEmailStepProps) { + const verifyFormId = useId(); + const [beforeSeconds, afterSeconds] = m.verify.resendCountdown.split('{seconds}'); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + props.onSubmit(); + }; + + return ( + <> + + {m.verify.title} + {fill(m.verify.description, { emailAddress: props.emailAddress })} + + + } + > + + {m.verify.label} + ()} + name='code' + value={props.code} + onValueChange={props.onCodeChange} + onComplete={props.onSubmit} + /> + {props.errorMessage ? {props.errorMessage} : null} + + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx index 49079616771..00842155302 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import type { FormEvent } from 'react'; +import type { FormEvent, Ref } from 'react'; import { useId, useRef } from 'react'; import { stringToFormattedPhoneString } from '../../../../utils/phoneUtils'; @@ -8,7 +8,7 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import { Flow } from '../../../components/flow'; +import { Flow, useFlowAutoFocus } from '../../../components/flow'; import { Otp } from '../../../components/otp'; import { PhoneInput } from '../../../components/phone-input'; import { styles } from '../user-profile-profile-panel.styles'; @@ -33,16 +33,7 @@ export interface UserProfileAddPhoneDialogProps { } export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) { - const phoneFormId = useId(); - const verifyFormId = useId(); const phoneRef = useRef(null); - const verifyRef = useRef(null); - const [beforeSeconds, afterSeconds] = m.verify.resendCountdown.split('{seconds}'); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - props.onSubmit(); - }; return ( : null} - phoneRef.current ?? verifyRef.current?.querySelector('input:not([type="hidden"])') ?? true - } + initialFocus={props.step === 'phone' ? phoneRef : undefined} > ( <> - - {m.phone.title} - {m.phone.description} - - - } - > - - {m.phone.label} - - - {current.errorMessage} - - - - - - {m.phone.submit} - - + - - - {m.verify.title} - - {fill(m.verify.description, { phoneNumber: stringToFormattedPhoneString(current.phoneNumber) })} - - - - } - > - - {m.verify.label} - - - {current.errorMessage} - - - - - - - } - > - {m.verify.cancel} - - - {m.verify.submit} - - + + )} @@ -194,3 +87,166 @@ export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) ); } + +interface EnterPhoneStepProps { + inputRef: Ref; + phoneNumber: string; + onPhoneNumberChange: (value: string) => void; + onSubmit: () => void; + isPending?: boolean; + errorMessage?: string; +} + +function EnterPhoneStep(props: EnterPhoneStepProps) { + const phoneFormId = useId(); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + props.onSubmit(); + }; + + return ( + <> + + {m.phone.title} + {m.phone.description} + + + } + > + + {m.phone.label} + + + {props.errorMessage} + + + + + + {m.phone.submit} + + + + ); +} + +interface VerifyPhoneStepProps { + phoneNumber: string; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +function VerifyPhoneStep(props: VerifyPhoneStepProps) { + const verifyFormId = useId(); + const [beforeSeconds, afterSeconds] = m.verify.resendCountdown.split('{seconds}'); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + props.onSubmit(); + }; + + return ( + <> + + {m.verify.title} + + {fill(m.verify.description, { phoneNumber: stringToFormattedPhoneString(props.phoneNumber) })} + + + + } + > + + {m.verify.label} + ()} + name='code' + value={props.code} + onValueChange={props.onCodeChange} + onComplete={props.onSubmit} + /> + + {props.errorMessage} + + + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + ); +} From bdf1911c040af14d29c42c2e36f23cf7a2685620 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 16 Sep 2026 11:53:48 -0600 Subject: [PATCH 14/14] test(ui): use compatible deferred promises in profile tests --- ...user-profile-connected-accounts-actions.test.tsx | 7 +++++-- .../__tests__/user-profile-email-actions.test.tsx | 3 ++- .../user-profile-profile-panel.view.test.tsx | 13 +++++++++---- .../__tests__/user-profile-web3-actions.test.tsx | 7 +++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx index 92fa48b4e93..cbd874b5d69 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; @@ -73,8 +74,10 @@ describe('connected account removal', () => { it('keeps the selected account pending until its removal finishes', async () => { const user = userEvent.setup(); - const removal = Promise.withResolvers(); - const onRemove = vi.fn(() => removal.promise); + const removal = createDeferredPromise(); + const onRemove = vi.fn(async () => { + await removal.promise; + }); renderAccounts(onRemove); await openRemoval(user); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove' })); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-email-actions.test.tsx index b2a5f5a8996..f3dff5404b8 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-email-actions.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; @@ -83,7 +84,7 @@ describe('email actions', () => { it('keeps removal pending and lets the user retry a failure in the dialog', async () => { const user = userEvent.setup(); - const removal = Promise.withResolvers(); + const removal = createDeferredPromise(); const onRemoveEmail = vi.fn().mockReturnValueOnce(removal.promise).mockResolvedValue(undefined); renderEmail({ onRemoveEmail }); await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); 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 652f62db636..81807b005e0 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 @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -39,8 +40,10 @@ describe('UserProfileProfilePanelView', () => { it('keeps the final account confirmation mounted until removal settles', async () => { const user = userEvent.setup(); - const removal = Promise.withResolvers(); - const onRemoveConnectedAccount = vi.fn(() => removal.promise); + const removal = createDeferredPromise(); + const onRemoveConnectedAccount = vi.fn(async () => { + await removal.promise; + }); const { rerender } = renderView({ connectedAccounts: [{ id: 'github', provider: 'GitHub' }], onRemoveConnectedAccount, @@ -71,8 +74,10 @@ describe('UserProfileProfilePanelView', () => { it('keeps the final wallet confirmation mounted until removal settles', async () => { const user = userEvent.setup(); - const removal = Promise.withResolvers(); - const onRemoveWeb3Wallet = vi.fn(() => removal.promise); + const removal = createDeferredPromise(); + const onRemoveWeb3Wallet = vi.fn(async () => { + await removal.promise; + }); const { rerender } = renderView({ web3Wallets: [{ id: 'wallet_1', provider: 'MetaMask', address: '0x1234', isVerified: true }], onRemoveWeb3Wallet, 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 1d59ea4ab2e..6c0638ff416 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 @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; @@ -62,10 +63,12 @@ describe('Web3 wallet removal', () => { it('keeps confirmation open while pending and allows retrying a rejected removal', async () => { const user = userEvent.setup(); - const removal = Promise.withResolvers(); + const removal = createDeferredPromise(); const onRemove = vi .fn() - .mockImplementationOnce(() => removal.promise) + .mockImplementationOnce(async () => { + await removal.promise; + }) .mockResolvedValue(undefined); renderWallets(onRemove); await openRemoval(user);