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/.changeset/honest-chairs-cheat.md b/.changeset/honest-chairs-cheat.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/honest-chairs-cheat.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 10f34cb0ff2..cdd8a9bbd1f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -207,10 +207,14 @@ import { Default as UserProfileAccountSectionDefault, EmailLinkResendFails as UserProfileAccountSectionEmailLinkResendFails, EmailLinkVerification as UserProfileAccountSectionEmailLinkVerification, + EmailRemovalError as UserProfileAccountSectionEmailRemovalError, + EmailRemovalPending as UserProfileAccountSectionEmailRemovalPending, EmailSsoConnectFails as UserProfileAccountSectionEmailSsoConnectFails, EmailSsoVerification as UserProfileAccountSectionEmailSsoVerification, meta as userProfileAccountSectionMeta, MultipleAccounts as UserProfileAccountSectionMultipleAccounts, + PhoneRemovalError as UserProfileAccountSectionPhoneRemovalError, + PhoneRemovalPending as UserProfileAccountSectionPhoneRemovalPending, } from '../stories/user-profile-account-section.stories'; import { Default as UserProfileActiveDevicesSectionDefault, @@ -529,6 +533,10 @@ const userProfileAccountSectionModule: StoryModule = { EmailLinkResendFails: UserProfileAccountSectionEmailLinkResendFails, EmailSsoVerification: UserProfileAccountSectionEmailSsoVerification, EmailSsoConnectFails: UserProfileAccountSectionEmailSsoConnectFails, + EmailRemovalPending: UserProfileAccountSectionEmailRemovalPending, + EmailRemovalError: UserProfileAccountSectionEmailRemovalError, + PhoneRemovalPending: UserProfileAccountSectionPhoneRemovalPending, + PhoneRemovalError: UserProfileAccountSectionPhoneRemovalError, }; const userProfileProfilePanelModule: StoryModule = { meta: userProfileProfilePanelMeta, 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/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/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index 21f95ba6de8..304aed8b005 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -22,6 +22,7 @@ import { useConnectedAccountsFixture } from './user-profile-connected-accounts'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditPasswordFixture } from './user-profile-edit-password'; import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; +import { useWeb3WalletsFixture } from './user-profile-web3-wallets'; export interface UserProfileFixtureOptions { /** Replaces the default OTP flow, e.g. for a custom dialog example. */ @@ -52,6 +53,7 @@ const initialAPIKeys: UserProfileAPIKey[] = [ */ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions = {}) { const connections = useConnectedAccountsFixture(); + const wallets = useWeb3WalletsFixture(); const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); const editPassword = useUserProfileEditPasswordFixture(); @@ -132,6 +134,11 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions onConnectAccount: connections.onConnect, onReconnectAccount: connections.onReconnect, onRemoveConnectedAccount: connections.onRemove, + web3Wallets: wallets.wallets, + availableWeb3Providers: wallets.availableProviders, + onConnectWeb3Wallet: wallets.onConnect, + onSetPrimaryWeb3Wallet: wallets.onSetPrimary, + onRemoveWeb3Wallet: wallets.onRemove, allowMultipleAccounts: true, hasImage: Boolean(imageUrl), imageUrl, diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index 74dfc443796..3110e103387 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -99,3 +99,27 @@ 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. + + + +## 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 c3febdbe96f..3455995cda2 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,19 @@ function AccountSection({ failWith, usernameFailWith, failEmailVerification = false, + emailRemovalState, + phoneRemovalState, }: { allowMultipleAccounts: boolean; failAt?: UserProfileAddPhoneDialogProps['step']; failWith?: UserProfileFormError; 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 }); const [emails, setEmails] = useState( @@ -84,9 +90,27 @@ 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))} + 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 })))} /> ); @@ -210,3 +234,39 @@ export function AddPhoneFails() { /> ); } + +export function EmailRemovalPending() { + return ( + + ); +} + +export function EmailRemovalError() { + return ( + + ); +} + +export function PhoneRemovalPending() { + return ( + + ); +} + +export function PhoneRemovalError() { + return ( + + ); +} 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. diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 000d01a0eb3..edad2788cb2 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -13,8 +13,8 @@ import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-ph import { useConnectedAccountsFixture } from './fixtures/user-profile-connected-accounts'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; +import { useWeb3WalletsFixture } from './fixtures/user-profile-web3-wallets'; -const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; export { default as __source } from './user-profile-profile-panel.stories?raw'; @@ -38,6 +38,7 @@ export function Default(_args: Record) { ]); const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl); const connections = useConnectedAccountsFixture(); + const wallets = useWeb3WalletsFixture(); const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); const emailFlow = createUserProfileAddEmailFixture({ @@ -54,19 +55,8 @@ export function Default(_args: Record) { connectedAccounts={connections.accounts} availableConnectionProviders={connections.availableProviders} onReconnectAccount={connections.onReconnect} - web3Wallets={[ - { - id: 'metamask', - address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', - provider: 'MetaMask', - iconUrl: providerIconUrl('metamask'), - isPrimary: true, - isVerified: true, - }, - ]} - availableWeb3Providers={[ - { id: 'coinbase-wallet', provider: 'Coinbase Wallet', iconUrl: providerIconUrl('coinbase_wallet') }, - ]} + web3Wallets={wallets.wallets} + availableWeb3Providers={wallets.availableProviders} hasImage={Boolean(imageUrl)} imageUrl={imageUrl} phones={phones} @@ -82,9 +72,9 @@ export function Default(_args: Record) { onRemoveProfilePicture={clearImage} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} - onConnectWeb3Wallet={() => undefined} - onRemoveWeb3Wallet={() => undefined} - onSetPrimaryWeb3Wallet={() => undefined} + onConnectWeb3Wallet={wallets.onConnect} + onRemoveWeb3Wallet={wallets.onRemove} + onSetPrimaryWeb3Wallet={wallets.onSetPrimary} onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} onVerifyEmail={() => undefined} 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-add-email.dialog.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx index 5f7892f3338..86eaa964d24 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx @@ -63,7 +63,8 @@ describe('UserProfileAddEmailDialog', () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(); - await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); + await user.click(screen.getByRole('textbox', { name: 'Verification code' })); if (method === 'typing') { await user.keyboard('12345'); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx index ceb1810f670..6b82e9d908f 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx @@ -30,7 +30,9 @@ describe('profile add email', () => { expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); await user.click(screen.getByRole('button', { name: 'Send code' })); - await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(codeInput).toHaveFocus()); + await user.keyboard('123456'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx index 8ad5297eb5e..579e56d6e3f 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx @@ -55,7 +55,8 @@ describe('UserProfileAddPhoneDialog', () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(); - await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); + await user.click(screen.getByRole('textbox', { name: 'Verification code' })); if (method === 'typing') { await user.keyboard('12345'); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx index 876f2bc2382..38918699fe8 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx @@ -30,7 +30,9 @@ describe('profile add phone', () => { expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); await user.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100'); await user.click(screen.getByRole('button', { name: 'Send code' })); - await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(codeInput).toHaveFocus()); + await user.keyboard('123456'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); 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..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,4 +1,5 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +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'; import { describe, expect, it, vi } from 'vitest'; @@ -8,7 +9,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 +54,98 @@ 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 = 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' })); + + 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-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/__tests__/user-profile-email-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-email-actions.test.tsx index 0a06959a57f..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,4 +1,5 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +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'; import { describe, expect, it, vi } from 'vitest'; @@ -32,11 +33,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 +64,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 +79,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 = createDeferredPromise(); + 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-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 aa15af906e5..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,4 +1,5 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +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'; @@ -37,6 +38,71 @@ 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 = createDeferredPromise(); + const onRemoveConnectedAccount = vi.fn(async () => { + await 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 the final wallet confirmation mounted until removal settles', async () => { + const user = userEvent.setup(); + 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, + }); + 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({ @@ -311,8 +377,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' })); @@ -400,7 +466,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', }), ); @@ -418,7 +484,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', }), ); @@ -434,7 +500,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/__tests__/user-profile-web3-actions.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-web3-actions.test.tsx index d7a1493b3c3..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,4 +1,5 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +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'; import { describe, expect, it, vi } from 'vitest'; @@ -8,7 +9,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( { renderWallets(onRemove); const trigger = screen.getByRole('button', { name: 'Manage MetaMask' }); trigger.focus(); - await user.keyboard('{Enter}{Enter}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + await user.keyboard('{Enter}'); + await screen.findByRole('menuitem', { name: 'Remove wallet' }); + await user.keyboard('{ArrowDown}{Enter}'); + expect(screen.getByRole('dialog')).toBeInTheDocument(); await user.keyboard('{Escape}'); await waitFor(() => expect(trigger).toHaveFocus()); expect(onRemove).not.toHaveBeenCalled(); @@ -53,45 +56,63 @@ 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 = createDeferredPromise(); + const onRemove = vi + .fn() + .mockImplementationOnce(async () => { + await 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..5c9e170838f 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'; @@ -70,13 +70,12 @@ 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'); - expect(dialog).toHaveTextContent(address); - const warning = within(dialog).queryByText('You will no longer be able to sign in using this web3 wallet.'); + const dialog = screen.getByRole('dialog'); + 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 3bcb900a1df..2097abcb1fd 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 @@ -58,11 +58,9 @@ export const userProfileAccountSectionMessages = { 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: - '{#strong}{emailAddress}{/strong} will be removed from your account. You won’t be able to use it to sign in.', + description: '{emailAddress} will be removed from your account. You won’t be able to use it to sign in.', confirm: 'Remove', cancel: 'Cancel', }, @@ -75,11 +73,9 @@ export const userProfileAccountSectionMessages = { 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: - '{#strong}{phoneNumber}{/strong} will be removed from your account. You won’t be able to use it to sign in.', + 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-add-email.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx index 686af4536d8..3d0e750ce9d 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-email.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 { Button, SubmitButton } from '../../../components/button'; @@ -7,7 +7,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 { Input } from '../../../components/input'; import { Otp } from '../../../components/otp'; import { fill, rich } from '../../../utils/messages'; @@ -32,15 +32,7 @@ export interface UserProfileAddEmailDialogProps { } export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) { - const emailFormId = useId(); - const verifyFormId = useId(); const emailRef = useRef(null); - const verifyRef = useRef(null); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - props.onSubmit(); - }; return ( : null} - emailRef.current ?? verifyRef.current?.querySelector('input:not([type="hidden"])') ?? true - } + initialFocus={props.step === 'email' ? emailRef : undefined} > ( <> - - {m.email.title} - {m.email.description} - - - } - > - - {m.email.label} - current.onEmailAddressChange(event.target.value)} - /> - {current.errorMessage ? {current.errorMessage} : null} - - - - - {m.email.submit} - - + - - - {m.verify.title} - - {fill(m.verify.description, { emailAddress: current.emailAddress })} - - - - } - > - - {m.verify.label} - - {current.errorMessage ? {current.errorMessage} : null} - - - - - - } - > - {m.verify.cancel} - - - {m.verify.submit} - - + + )} @@ -192,3 +86,161 @@ 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 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 914d56a4eec..f4fff168762 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 { fill, rich } from '../../../utils/messages'; @@ -33,15 +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 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} - - + + )} @@ -195,3 +87,165 @@ 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 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} + + + + ); +} 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 f0189ccc9e0..1991d2ce147 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 @@ -19,7 +19,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({ @@ -31,7 +30,6 @@ export function UserProfileContactListRowView({ onSetPrimary, onRemove, addAction, - renderActionDialog, }: UserProfileContactListRowViewProps) { const emptyDescription = m[kind].empty; @@ -103,9 +101,7 @@ export function UserProfileContactListRowView({ - {renderActionDialog?.(item)} - + /> ) : null} 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 ed2e7ccb213..7d2690c775c 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,10 @@ -import { useRef, useState } from 'react'; +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 { fill } from '../../../utils/messages'; import { userProfileAccountSectionMessages 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 +12,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 +60,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 +85,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 +112,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 ? ( + fill(m.email.removeDialog.description, { emailAddress: email.value })} + actionLabel={m.email.removeDialog.confirm} + cancelLabel={m.email.removeDialog.cancel} + onConfirm={email => onRemoveEmail(email.id)} + /> ) : null} ); 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 7727be213d8..e8654d56406 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,11 @@ -import { useRef, useState } from 'react'; +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 { fill } from '../../../utils/messages'; import { userProfileAccountSectionMessages 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 +13,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 +42,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 +67,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 +98,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 ? ( + + fill(m.phone.removeDialog.description, { phoneNumber: stringToFormattedPhoneString(phone.value) }) + } + actionLabel={m.phone.removeDialog.confirm} + cancelLabel={m.phone.removeDialog.cancel} + onConfirm={phone => onRemovePhone(phone.id)} + /> ) : null} ); 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 6857f2a8ec7..00000000000 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; -import type { ReactNode } from 'react'; - -import { Button } from '../../../components/button'; -import { Dialog } from '../../../components/dialog'; -import { Heading } from '../../../components/heading'; -import { Text } from '../../../components/text'; -import { rich } from '../../../utils/messages'; -import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; - -const components = { - strong: (children?: ReactNode) => {children}, -}; - -export interface UserProfileRemoveEmailDialogProps { - emailAddress: string; - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; -} - -export function UserProfileRemoveEmailDialog({ - emailAddress, - open, - onOpenChange, - onConfirm, -}: UserProfileRemoveEmailDialogProps) { - return ( - - - }>{m.email.removeDialog.title} - }> - {rich(m.email.removeDialog.description, { values: { emailAddress }, components })} - - - }>{m.email.removeDialog.cancel} - - - - - ); -} 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 2c496a1cbeb..00000000000 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; -import type { ReactNode } from 'react'; - -import { Button } from '../../../components/button'; -import { Dialog } from '../../../components/dialog'; -import { Heading } from '../../../components/heading'; -import { Text } from '../../../components/text'; -import { rich } from '../../../utils/messages'; -import { styles } from '../user-profile-profile-panel.styles'; -import { userProfileAccountSectionMessages as m } from './user-profile-account-section.messages'; - -const components = { - strong: (children?: ReactNode) => {children}, -}; - -export interface UserProfileRemovePhoneDialogProps { - phoneNumber: string; - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; -} - -export function UserProfileRemovePhoneDialog({ - phoneNumber, - open, - onOpenChange, - onConfirm, -}: UserProfileRemovePhoneDialogProps) { - return ( - - - }>{m.phone.removeDialog.title} - }> - {rich(m.phone.removeDialog.description, { values: { phoneNumber }, components })} - - - }>{m.phone.removeDialog.cancel} - - - - - ); -} 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 af8a25f61f7..765477b9280 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 @@ -1,5 +1,4 @@ import * as stylex from '@stylexjs/stylex'; -import type { ReactNode } from 'react'; import { Banner } from '../../../components/banner'; import { Button } from '../../../components/button'; @@ -8,15 +7,11 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Spinner } from '../../../components/spinner'; import { Text } from '../../../components/text'; -import { rich } from '../../../utils/messages'; +import { fill, rich } from '../../../utils/messages'; import { styles as profileStyles } from '../user-profile-profile-panel.styles'; import { userProfileVerifyEmailLinkMessages as m } from './user-profile-verify-email-link.messages'; import { styles } from './user-profile-verify-email-link.styles'; -const components = { - strong: (children?: ReactNode) => {children}, -}; - export interface UserProfileVerifyEmailLinkDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -70,7 +65,7 @@ export function UserProfileVerifyEmailLinkDialog({ {m.waiting}
- {rich(m.description, { values: { emailAddress }, components })} + {fill(m.description, { emailAddress })}
diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-connected-account.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-remove-connected-account.dialog.tsx deleted file mode 100644 index c1e0e50e685..00000000000 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-connected-account.dialog.tsx +++ /dev/null @@ -1,65 +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 { fill } from '../../utils/messages'; -import { userProfileConnectedAccountsMessages as m } from './user-profile-connected-accounts.messages'; - -export interface UserProfileRemoveConnectedAccountDialogProps { - provider: string; - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - isPending?: boolean; - errorMessage?: string; -} - -export function UserProfileRemoveConnectedAccountDialog({ - provider, - open, - onOpenChange, - onConfirm, - isPending, - errorMessage, -}: UserProfileRemoveConnectedAccountDialogProps) { - return ( - - - }>{m.removeDialog.title} - }> - {fill(m.removeDialog.description, { provider: provider })} - - {errorMessage ? ( - - {errorMessage} - - ) : null} - - } - > - {m.removeDialog.cancel} - - - {m.removeDialog.confirm} - - - - - ); -} 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 d9c149c6cb3..00000000000 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-remove-web3-wallet.dialog.tsx +++ /dev/null @@ -1,68 +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 { fill } from '../../utils/messages'; -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} - }> - {fill(m.removeDialog.description, { 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 cb23bbda5c9..e552a47b108 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'; @@ -8,7 +7,6 @@ import { Section } from '../../components/section'; import { fill } from '../../utils/messages'; 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'; @@ -22,9 +20,8 @@ export function UserProfileWeb3WalletRowView({ wallet: UserProfileWeb3Wallet | UserProfileWeb3Provider; onConnect?: (id: string) => void; onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void; + onRemove?: (wallet: UserProfileWeb3Wallet) => void; }) { - const [open, setOpen] = useState(false); const iconUrl = wallet.iconUrl?.trim(); const linkedWallet = 'address' in wallet ? wallet : undefined; const address = linkedWallet?.address; @@ -35,7 +32,7 @@ export function UserProfileWeb3WalletRowView({ 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) }); + actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(linkedWallet) }); } return ( @@ -99,19 +96,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..ce30a1fd448 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,4 +1,9 @@ +import { useMemo } from 'react'; + +import { truncateWithEndVisible } from '../../../utils/truncateTextWithEndVisible'; +import { Confirmation } from '../../blocks/confirmation'; import { Section } from '../../components/section'; +import { fill } from '../../utils/messages'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; import { userProfileWeb3WalletsMessages as m } from './user-profile-web3-wallets.messages'; @@ -17,8 +22,6 @@ export interface UserProfileWeb3Wallet { isPrimary?: boolean; isVerified: boolean; canRemove?: boolean; - isRemoving?: boolean; - removalError?: string; primaryError?: string; } @@ -27,7 +30,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 +40,51 @@ 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 fill(wallet.isVerified ? m.removeDialog.verifiedDescription : m.removeDialog.description, { + wallet: truncateWithEndVisible(wallet.address, 13, 4), + }); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts index 3ffe00aae49..451dd43ef11 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-web3-wallets.messages.ts @@ -10,9 +10,9 @@ export const userProfileWeb3WalletsMessages = { removeDialog: { title: 'Remove wallet?', description: '{wallet} will be removed from this account.', - signInWarning: 'You will no longer be able to sign in using this web3 wallet.', + verifiedDescription: + '{wallet} will be removed from this account. You will no longer be able to sign in using this web3 wallet.', confirm: 'Remove', - pending: 'Removing wallet', cancel: 'Cancel', }, };