From ef0bff4ffb2657e2d42de3b6e41217f73c06c06a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 00:35:11 -0600 Subject: [PATCH 1/8] refactor(ui): extract account contact rows --- .../user-profile-account-section.view.tsx | 205 +++--------------- .../user-profile-contact-list-row.view.tsx | 116 ++++++++++ .../user-profile-contact-row.view.tsx | 52 +++++ 3 files changed, 201 insertions(+), 172 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index e91604dfcf8..6959122daca 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,15 +1,12 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; -import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import type { UserProfileMenuAction } from '../user-profile-action-menu'; -import { UserProfileActionMenu } from '../user-profile-action-menu'; -import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; +import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; +import { UserProfileContactRowView } from './user-profile-contact-row.view'; import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileNameRowView } from './user-profile-name-row.view'; import { UserProfilePictureRowView } from './user-profile-picture-row.view'; @@ -120,7 +117,7 @@ export function UserProfileAccountSectionView({ onSubmit={onSubmitUsername} /> {!allowMultipleAccounts ? ( - ) : null} {!allowMultipleAccounts ? ( - {allowMultipleAccounts ? ( - + + + + + ) : null} {allowMultipleAccounts ? ( - + + + + + ) : null} ); } - -interface ContactSectionProps { - kind: 'email' | 'phone'; - label: string; - items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; - onAdd?: () => void; - onManage?: (id: string) => void; - onVerify?: (id: string) => void; - onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void; -} - -function ContactSection(props: ContactSectionProps) { - return ( - - - - - - ); -} - -function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) { - const item = items[0]; - const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; - const emptyDescription = m[kind].empty; - const actionLabel = item ? m[kind].update : m[kind].add; - - return ( - - - - {label} - {item ? ( - - {item.value} - {item.isDefault ? {m.primary} : null} - - ) : ( - {emptyDescription} - )} - - {onClick ? ( - - - - ) : null} - - - ); -} - -function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimary, onRemove }: ContactSectionProps) { - const emptyDescription = m[kind].empty; - - return ( - - - - {label} - - {onAdd ? ( - - - - ) : null} - - - {items.length === 0 ? ( - - - {emptyDescription} - - - ) : ( - items.map(item => { - const actions: UserProfileMenuAction[] = []; - const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); - - if (item.isVerified === false && onVerify) { - actions.push({ - label: item.isDefault ? m.completeVerification : m[kind].verify, - onClick: () => onVerify(item.id), - }); - } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { - actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); - } - - if (onRemove && item.canRemove !== false) { - actions.push({ - label: m[kind].remove, - color: 'negative', - onClick: () => onRemove(item.id), - }); - } - - if (!hasExplicitActions && onManage) { - actions.push({ label: m.manage, onClick: () => onManage(item.id) }); - } - - return ( - - - - {item.value} - {item.isDefault ? {m.primary} : null} - - - {actions.length > 0 ? ( - - - - ) : null} - - ); - }) - )} - - - ); -} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx new file mode 100644 index 00000000000..08489124bdc --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx @@ -0,0 +1,116 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import { Section } from '../../components/section'; +import type { UserProfileMenuAction } from '../user-profile-action-menu'; +import { UserProfileActionMenu } from '../user-profile-action-menu'; +import { styles } from '../user-profile-profile-panel.styles'; +import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +export interface UserProfileContactListRowViewProps { + kind: 'email' | 'phone'; + label: string; + items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; + onAdd?: () => void; + onManage?: (id: string) => void; + onVerify?: (id: string) => void; + onSetPrimary?: (id: string) => void; + onRemove?: (id: string) => void; +} + +export function UserProfileContactListRowView({ + kind, + label, + items, + onAdd, + onManage, + onVerify, + onSetPrimary, + onRemove, +}: UserProfileContactListRowViewProps) { + const emptyDescription = m[kind].empty; + + return ( + + + + {label} + + {onAdd ? ( + + + + ) : null} + + + {items.length === 0 ? ( + + + {emptyDescription} + + + ) : ( + items.map(item => { + const actions: UserProfileMenuAction[] = []; + const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); + + if (item.isVerified === false && onVerify) { + actions.push({ + label: item.isDefault ? m.completeVerification : m[kind].verify, + onClick: () => onVerify(item.id), + }); + } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); + } + + if (onRemove && item.canRemove !== false) { + actions.push({ + label: m[kind].remove, + color: 'negative', + onClick: () => onRemove(item.id), + }); + } + + if (!hasExplicitActions && onManage) { + actions.push({ label: m.manage, onClick: () => onManage(item.id) }); + } + + return ( + + + + {item.value} + {item.isDefault ? {m.primary} : null} + + + {actions.length > 0 ? ( + + + + ) : null} + + ); + }) + )} + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx new file mode 100644 index 00000000000..fd555555d20 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx @@ -0,0 +1,52 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { Section } from '../../components/section'; +import { styles } from '../user-profile-profile-panel.styles'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +export interface UserProfileContactRowViewProps { + kind: 'email' | 'phone'; + label: string; + items: Array<{ id: string; value: string; isDefault?: boolean }>; + onAdd?: () => void; + onManage?: (id: string) => void; +} + +export function UserProfileContactRowView({ kind, label, items, onAdd, onManage }: UserProfileContactRowViewProps) { + const item = items[0]; + const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; + const emptyDescription = m[kind].empty; + const actionLabel = item ? m[kind].update : m[kind].add; + + return ( + + + + {label} + {item ? ( + + {item.value} + {item.isDefault ? {m.primary} : null} + + ) : ( + {emptyDescription} + )} + + {onClick ? ( + + + + ) : null} + + + ); +} From c81da93e880ee63bfd2e08dda907d1dad2936277 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 00:37:09 -0600 Subject: [PATCH 2/8] feat(ui): add and verify profile phone numbers --- .changeset/heavy-pears-smile.md | 2 + packages/swingset/src/lib/registry.ts | 2 + .../fixtures/user-profile-add-phone.ts | 28 +++ .../src/stories/fixtures/user-profile.ts | 13 +- .../stories/user-profile-account-section.mdx | 9 + .../user-profile-account-section.stories.tsx | 32 +-- .../user-profile-profile-panel.stories.tsx | 16 +- ...ser-profile-add-phone.integration.test.tsx | 41 ++++ .../user-profile-add-phone.view.test.tsx | 179 ++++++++++++++++ .../user-profile-profile-panel.view.test.tsx | 30 ++- .../user-profile-account-section.view.tsx | 58 +++++- .../user-profile-add-phone.controller.test.ts | 153 ++++++++++++++ .../user-profile-add-phone.controller.ts | 159 ++++++++++++++ .../user-profile-add-phone.messages.ts | 21 ++ .../user-profile-add-phone.view.tsx | 195 ++++++++++++++++++ .../user-profile-contact-list-row.view.tsx | 7 +- .../user-profile-contact-row.view.tsx | 15 +- .../user-profile-profile-panel.view.tsx | 6 +- 18 files changed, 915 insertions(+), 51 deletions(-) create mode 100644 .changeset/heavy-pears-smile.md create mode 100644 packages/swingset/src/stories/fixtures/user-profile-add-phone.ts create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view.tsx diff --git a/.changeset/heavy-pears-smile.md b/.changeset/heavy-pears-smile.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/heavy-pears-smile.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ebe2f7b9fab..939241dfe69 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -188,6 +188,7 @@ import { Overlay as UserProfileOverlay, } from '../stories/user-profile.stories'; import { + AddPhoneFails as UserProfileAccountSectionAddPhoneFails, Default as UserProfileAccountSectionDefault, meta as userProfileAccountSectionMeta, MultipleAccounts as UserProfileAccountSectionMultipleAccounts, @@ -467,6 +468,7 @@ const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, MultipleAccounts: UserProfileAccountSectionMultipleAccounts, + AddPhoneFails: UserProfileAccountSectionAddPhoneFails, }; const userProfileProfilePanelModule: StoryModule = { meta: userProfileProfilePanelMeta, diff --git a/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts new file mode 100644 index 00000000000..b76b23f8f80 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts @@ -0,0 +1,28 @@ +import type { UserProfileAccountSectionViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; +import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view'; + +interface FixtureOptions { + failAt?: UserProfileAddPhoneViewProps['step']; + onVerified?: (phoneNumber: string) => void; +} + +export function createUserProfileAddPhoneFixture({ failAt, onVerified }: FixtureOptions = {}): Pick< + UserProfileAccountSectionViewProps, + 'onSendPhoneCode' | 'onVerifyPhoneCode' +> { + return { + onSendPhoneCode: async () => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'phone') { + throw new Error('We couldn’t send a code. Try again.'); + } + }, + onVerifyPhoneCode: async (phoneNumber, code) => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'verify' || code === '000000') { + throw new Error('That code is incorrect. Try again.'); + } + onVerified?.(phoneNumber); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index a11315b5831..f4411466b6d 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -13,6 +13,7 @@ import type { import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; +import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; @@ -121,15 +122,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions emails, phones, onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), - onAddPhone: () => - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]), + ...createUserProfileAddPhoneFixture({ + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + }), onDeleteAccount: () => Promise.resolve(), onManageEmail: () => undefined, onManagePhone: () => undefined, diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index e7a319005b4..532e38b6ce6 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -5,6 +5,9 @@ import * as Stories from './user-profile-account-section.stories'; Account details, profile image, email addresses, and phone numbers composed with `Section`. The `allowMultipleAccounts` flag controls whether contact methods appear inline or in dedicated sections. +In the multiple-account example, Add phone opens the flow using local state and simulated requests. +Entering or pasting six digits submits automatically. Use `000000` to see an incorrect-code error. + ## Single account + +## Add phone failure + +Add a phone number to see a failed send request while keeping the entered 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 200d1667d72..a8cb9aaa691 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -4,11 +4,13 @@ import type { UserProfilePhone, } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; +import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; @@ -25,10 +27,12 @@ export const meta: StoryMeta = { function AccountSection({ allowMultipleAccounts, + failAt, failWith, usernameFailWith, }: { allowMultipleAccounts: boolean; + failAt?: UserProfileAddPhoneViewProps['step']; failWith?: UserProfileFormError; usernameFailWith?: UserProfileFormError; }) { @@ -46,6 +50,10 @@ function AccountSection({ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); + const addPhone = createUserProfileAddPhoneFixture({ + failAt, + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + }); return ( - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]) - } + {...addPhone} + onProfilePictureChange={showFile} + onRemoveProfilePicture={clearImage} onManageEmail={() => undefined} onManagePhone={() => undefined} - onProfilePictureChange={showFile} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} - onRemoveProfilePicture={clearImage} /> ); } @@ -114,3 +113,12 @@ export function EditUsernameFails() { /> ); } + +export function AddPhoneFails() { + return ( + + ); +} 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 15f36e0ed77..e0ab322a5a8 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; @@ -75,16 +76,9 @@ export function Default(_args: Record) { { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, ]) } - onAddPhone={() => - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]) - } + {...createUserProfileAddPhoneFixture({ + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + })} onConnectAccount={() => undefined} onDeleteAccount={() => Promise.resolve()} onManageEmail={() => undefined} @@ -98,7 +92,7 @@ export function Default(_args: Record) { onRemoveWeb3Wallet={() => undefined} onSetPrimaryWeb3Wallet={() => undefined} onSetPrimaryEmail={() => undefined} - onSetPrimaryPhone={() => undefined} + onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} onVerifyEmail={() => undefined} onVerifyPhone={() => undefined} /> diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx new file mode 100644 index 00000000000..73433cbb910 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; + +describe('profile add phone', () => { + it.each([false, true])( + 'owns the dialog and returns focus with multiple accounts = %s', + async allowMultipleAccounts => { + const user = userEvent.setup(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + render( + + + , + ); + const trigger = screen.getByRole('button', { name: 'Add phone number' }); + await user.click(trigger); + 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 waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + await user.keyboard('123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); + await waitFor(() => expect(trigger).toHaveFocus()); + }, + ); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx new file mode 100644 index 00000000000..de9e06e7f80 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx @@ -0,0 +1,179 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileAddPhoneViewProps } from '../user-profile-account-section/user-profile-add-phone.view'; +import { UserProfileAddPhoneView } from '../user-profile-account-section/user-profile-add-phone.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileAddPhoneViewProps = { + open: true, + onOpenChange: vi.fn(), + step: 'phone', + phoneNumber: '+18018888181', + onPhoneNumberChange: vi.fn(), + code: '', + onCodeChange: vi.fn(), + onSubmit: vi.fn(), + onResend: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +function VerificationExample({ onSubmit }: Pick) { + const [code, setCode] = useState(''); + + return ( + + undefined} + step='verify' + phoneNumber='+18018888181' + onPhoneNumberChange={() => undefined} + code={code} + onCodeChange={setCode} + onSubmit={onSubmit} + onResend={() => undefined} + /> + + ); +} + +describe('UserProfileAddPhoneView', () => { + it.each(['typing', 'pasting'] as const)('automatically submits a complete code after %s', async method => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + + if (method === 'typing') { + await user.keyboard('12345'); + expect(onSubmit).not.toHaveBeenCalled(); + await user.keyboard('6'); + } else { + await user.paste('123456'); + } + + expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + }); + + it('focuses the phone field and submits through the form or Send code', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); + const phone = screen.getByRole('textbox', { name: 'Phone' }); + await waitFor(() => expect(phone).toHaveFocus()); + const phoneForm = phone.closest('form'); + if (!phoneForm) { + throw new Error('Phone form missing'); + } + expect(phoneForm).toHaveClass('cl-card-content'); + phoneForm.requestSubmit(); + expect(props.onSubmit).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Send code' })); + + expect(props.onSubmit).toHaveBeenCalledTimes(2); + }); + + it('moves to verification inside the same dialog and submits the code', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView(); + const dialog = screen.getByRole('dialog'); + + rerender( + + + , + ); + + expect(screen.getByRole('dialog', { name: 'Verify your phone number' })).toBe(dialog); + expect(screen.getByText('Enter the code sent to +1 (801) 888-8181')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Phone' })).not.toBeInTheDocument(); + const firstSlot = screen.getByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(firstSlot).toHaveFocus()); + const verifyForm = firstSlot.closest('form'); + if (!verifyForm) { + throw new Error('Verification form missing'); + } + expect(verifyForm).toHaveClass('cl-card-content'); + verifyForm.requestSubmit(); + expect(props.onSubmit).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); + + expect(props.onSubmit).toHaveBeenCalledTimes(2); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('blocks submission and resend while verification is pending', async () => { + const user = userEvent.setup(); + const { props } = renderView({ step: 'verify', code: '123456', isPending: true }); + + for (const slot of screen.getAllByRole('textbox')) { + expect(slot).toBeDisabled(); + } + const verify = screen.getByRole('button', { name: 'Verify', exact: true }); + expect(verify).toHaveAttribute('aria-busy', 'true'); + await user.click(verify); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); + + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.onResend).not.toHaveBeenCalled(); + }); + + it.each(['phone', 'verify'] as const)('associates a %s error with its input', step => { + renderView({ step, errorMessage: 'Please try again.' }); + + const field = screen.getByRole('textbox', { name: step === 'phone' ? 'Phone' : 'Verification code' }); + expect(field).toHaveAttribute('aria-invalid', 'true'); + const describedControl = step === 'verify' ? screen.getByRole('group', { name: 'Verification code' }) : field; + expect(describedControl).toHaveAccessibleDescription('Please try again.'); + }); + + it('allows resending only after the countdown and the current request finish', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ step: 'verify', resendSeconds: 12 }); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (12)' })); + expect(props.onResend).not.toHaveBeenCalled(); + + rerender( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + + rerender( + + + , + ); + expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 55235bb68be..957cdb28363 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -29,6 +29,19 @@ function renderView(overrides: Partial = {}) { } describe('UserProfileProfilePanelView', () => { + it.each([false, true])('formats normalized phone numbers with multiple accounts set to %s', allowMultipleAccounts => { + renderView({ + allowMultipleAccounts, + phones: [{ id: 'phone_added', value: '+18015558181' }], + onManagePhone: vi.fn(), + }); + + expect(screen.getByText('+1 (801) 555-8181')).toBeInTheDocument(); + if (allowMultipleAccounts) { + expect(screen.getByRole('button', { name: 'Manage +1 (801) 555-8181' })).toBeInTheDocument(); + } + }); + it('composes the profile content without profile navigation', () => { renderView({ onProfilePictureChange: vi.fn(), @@ -49,7 +62,7 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); expect(screen.getByText('item1@clerk.dev').closest('.cl-section-item')).toHaveTextContent('Primary'); - expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); + expect(screen.getByText('+1 (801) 888-8181')).toBeInTheDocument(); expect(screen.getByText('Profile picture')).toHaveClass('cl-section-label'); expect(screen.getByText('Recommend size 1:1, up to 10MB.')).toHaveClass('cl-section-description'); expect(screen.getByText('Email')).toHaveClass('cl-section-label'); @@ -118,7 +131,8 @@ describe('UserProfileProfilePanelView', () => { renderView({ emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], onAddEmail: vi.fn(), - onAddPhone: vi.fn(), + onSendPhoneCode: () => Promise.resolve(), + onVerifyPhoneCode: () => Promise.resolve(), }); const accountSection = screen.getByRole('region', { name: 'Account' }); @@ -128,7 +142,7 @@ describe('UserProfileProfilePanelView', () => { expect(accountSection).not.toContainElement(emailSection); expect(accountSection).not.toContainElement(phoneSection); expect(emailSection).toHaveTextContent('item1@clerk.dev'); - expect(phoneSection).toHaveTextContent('+1 801-888-8181'); + expect(phoneSection).toHaveTextContent('+1 (801) 888-8181'); expect(within(emailSection).getByRole('button', { name: 'Add email' })).toHaveTextContent('Add'); expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toHaveTextContent('Add'); }); @@ -144,7 +158,7 @@ describe('UserProfileProfilePanelView', () => { const accountSection = screen.getByRole('region', { name: 'Account' }); expect(accountSection).toHaveTextContent('item1@clerk.dev'); - expect(accountSection).toHaveTextContent('+1 801-888-8181'); + expect(accountSection).toHaveTextContent('+1 (801) 888-8181'); expect(within(accountSection).getByRole('button', { name: 'Update email' })).toBeInTheDocument(); expect(within(accountSection).getByRole('button', { name: 'Update phone number' })).toBeInTheDocument(); expect(screen.queryByRole('region', { name: 'Email' })).not.toBeInTheDocument(); @@ -171,7 +185,7 @@ describe('UserProfileProfilePanelView', () => { }); it('renders an actionable empty state when no phone number exists', () => { - renderView({ phones: [], onAddPhone: vi.fn() }); + renderView({ phones: [], onSendPhoneCode: () => Promise.resolve(), onVerifyPhoneCode: () => Promise.resolve() }); const phoneSection = screen.getByRole('region', { name: 'Phone' }); const emptyState = within(phoneSection).getByText('No phone numbers added'); @@ -414,15 +428,15 @@ describe('UserProfileProfilePanelView', () => { await user.click(screen.getByRole('menuitem', { name: 'Verify' })); expect(onVerifyEmail).toHaveBeenCalledWith('email_unverified'); - await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Verify phone number' })); expect(onVerifyPhone).toHaveBeenCalledWith('phone_unverified'); - await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); expect(onRemovePhone).toHaveBeenCalledWith('phone_unverified'); - await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0101' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0101' })); await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); expect(onSetPrimaryPhone).toHaveBeenCalledWith('phone_secondary'); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 6959122daca..91fc9013b19 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,10 +1,16 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; +import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; +import type { UserProfileAddPhoneControllerOptions } from './user-profile-add-phone.controller'; +import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; +import { UserProfileAddPhoneView } from './user-profile-add-phone.view'; import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; import { UserProfileContactRowView } from './user-profile-contact-row.view'; import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; @@ -56,7 +62,8 @@ export interface UserProfileAccountSectionViewProps { onVerifyEmail?: (id: string) => void; onSetPrimaryEmail?: (id: string) => void; onRemoveEmail?: (id: string) => void; - onAddPhone?: () => void; + onSendPhoneCode?: (phoneNumber: string) => Promise; + onVerifyPhoneCode?: (phoneNumber: string, code: string) => Promise; onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; onSetPrimaryPhone?: (id: string) => void; @@ -85,12 +92,25 @@ export function UserProfileAccountSectionView({ onVerifyEmail, onSetPrimaryEmail, onRemoveEmail, - onAddPhone, + onSendPhoneCode, + onVerifyPhoneCode, onManagePhone, onVerifyPhone, onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const addPhoneAction = + onSendPhoneCode && onVerifyPhoneCode ? ( + + ) : undefined; + const formattedPhones = phones.map(phone => ({ + ...phone, + value: stringToFormattedPhoneString(phone.value), + })); + return (
@@ -127,10 +147,10 @@ export function UserProfileAccountSectionView({ ) : null} {!allowMultipleAccounts ? ( ) : null} @@ -156,10 +176,10 @@ export function UserProfileAccountSectionView({ ); } + +function AddPhone({ options, compact }: { options: UserProfileAddPhoneControllerOptions; compact: boolean }) { + const controller = useUserProfileAddPhoneController(options); + return ( + + {compact ? ( + + ) : null} + {compact ? m.add : m.phone.add} + + } + /> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts new file mode 100644 index 00000000000..571fb00a042 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts @@ -0,0 +1,153 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; + +describe('useUserProfileAddPhoneController', () => { + afterEach(() => vi.useRealTimers()); + + it('keeps the resend countdown running while verification is pending', async () => { + vi.useFakeTimers(); + const verification = Promise.withResolvers(); + const { result } = renderHook(() => + useUserProfileAddPhoneController({ + onSend: () => Promise.resolve(), + onVerify: () => verification.promise, + }), + ); + act(() => result.current.onOpenChange(true)); + await act(async () => { + result.current.onSubmit(); + await Promise.resolve(); + }); + act(() => result.current.onSubmit('123456')); + for (let second = 0; second < 12; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } + expect(result.current.resendSeconds).toBe(0); + await act(async () => { + verification.reject(new Error('Incorrect code')); + await Promise.resolve(); + }); + expect(result.current.errorMessage).toBe('Incorrect code'); + expect(result.current.resendSeconds).toBe(0); + }); + + it('starts with the supplied phone number', () => { + const { result } = renderHook(() => + useUserProfileAddPhoneController({ + initialPhoneNumber: '+18015558181', + onSend: () => Promise.resolve(), + onVerify: () => Promise.resolve(), + }), + ); + act(() => result.current.onOpenChange(true)); + expect(result.current.phoneNumber).toBe('+18015558181'); + }); + + it('ignores cancellation and duplicate submissions while sending, then resets on reopen', async () => { + const request = Promise.withResolvers(); + const onSend = vi.fn(() => request.promise); + const { result } = renderHook(() => + useUserProfileAddPhoneController({ onSend, onVerify: () => Promise.resolve() }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onPhoneNumberChange('+18015550100')); + act(() => { + result.current.onSubmit(); + result.current.onSubmit(); + result.current.onPhoneNumberChange('+18015550200'); + result.current.onOpenChange(false); + }); + expect(result.current.open).toBe(true); + expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); + await act(async () => { + request.resolve(); + await request.promise; + }); + act(() => result.current.onCodeChange('123')); + act(() => result.current.onOpenChange(false)); + expect(result.current.open).toBe(false); + act(() => result.current.onOpenChange(true)); + expect(result.current.step).toBe('phone'); + expect(result.current.code).toBe(''); + expect(result.current.resendSeconds).toBe(0); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('waits before resending, blocks overlapping requests, and restarts the countdown', async () => { + vi.useFakeTimers(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useUserProfileAddPhoneController({ onSend, onVerify })); + act(() => result.current.onOpenChange(true)); + await act(async () => { + result.current.onSubmit(); + await Promise.resolve(); + }); + expect(result.current.resendSeconds).toBe(12); + act(() => result.current.onResend()); + expect(onSend).toHaveBeenCalledTimes(1); + for (let second = 0; second < 12; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } + expect(result.current.resendSeconds).toBe(0); + act(() => result.current.onCodeChange('123')); + await act(async () => { + result.current.onResend(); + result.current.onResend(); + result.current.onSubmit('123456'); + result.current.onOpenChange(false); + await Promise.resolve(); + }); + expect(onSend).toHaveBeenCalledTimes(2); + expect(onVerify).not.toHaveBeenCalled(); + expect(result.current.open).toBe(true); + expect(result.current.code).toBe(''); + expect(result.current.resendSeconds).toBe(12); + }); + it.each(['phone', 'verify'] as const)('keeps the %s input after failure and allows retrying', async step => { + const operation = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValue(undefined); + const { result } = renderHook(() => + useUserProfileAddPhoneController({ + onSend: step === 'phone' ? operation : () => Promise.resolve(), + onVerify: step === 'verify' ? operation : () => Promise.resolve(), + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onPhoneNumberChange('+18015550100')); + act(() => result.current.onSubmit()); + if (step === 'verify') { + await waitFor(() => expect(result.current.step).toBe('verify')); + act(() => result.current.onSubmit('000000')); + } + await waitFor(() => expect(result.current.errorMessage).toBe('Try again')); + expect(result.current.isPending).toBe(false); + expect(result.current.step).toBe(step); + expect(result.current.phoneNumber).toBe('+18015550100'); + if (step === 'verify') { + expect(result.current.code).toBe('000000'); + } + act(() => result.current.onSubmit()); + await waitFor(() => expect(operation).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.errorMessage).toBeUndefined()); + }); + it('sends a code, verifies the submitted code, and closes on success', async () => { + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useUserProfileAddPhoneController({ onSend, onVerify })); + + expect(result.current.open).toBe(false); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onPhoneNumberChange('+18015550100')); + act(() => result.current.onSubmit()); + expect(result.current.isPending).toBe(true); + expect(result.current.open).toBe(true); + await waitFor(() => expect(result.current.step).toBe('verify')); + expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); + + act(() => result.current.onSubmit('123456')); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts new file mode 100644 index 00000000000..2b42b6be63e --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts @@ -0,0 +1,159 @@ +import { useEffect } from 'react'; + +import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/useMachine'; +import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages'; +import type { UserProfileAddPhoneViewProps } from './user-profile-add-phone.view'; + +export interface UserProfileAddPhoneControllerOptions { + initialPhoneNumber?: string; + onSend: (phoneNumber: string) => Promise; + onVerify: (phoneNumber: string, code: string) => Promise; +} + +interface Context extends UserProfileAddPhoneControllerOptions { + phoneNumber: string; + code: string; + errorMessage: string | undefined; + resendSeconds: number; +} + +type Event = + | { type: 'OPEN' } + | { type: 'CANCEL' } + | { type: 'RESEND' } + | { type: 'TICK' } + | { type: 'TYPE_PHONE'; value: string } + | { type: 'TYPE_CODE'; value: string } + | { type: 'SUBMIT'; code?: string }; + +const { createMachine, assign, fromPromise } = setup(); + +function missingDependency(): Promise { + return Promise.reject(new Error('Add phone callbacks are missing')); +} + +function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : m.error; +} + +const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; + +const machine = createMachine({ + id: 'addPhone', + initial: 'idle', + context: { + onSend: missingDependency, + onVerify: missingDependency, + phoneNumber: '', + code: '', + errorMessage: undefined, + resendSeconds: 0, + }, + states: { + idle: { + on: { + OPEN: { + target: 'phone', + actions: assign(context => ({ + phoneNumber: context.initialPhoneNumber ?? '', + code: '', + errorMessage: undefined, + resendSeconds: 0, + })), + }, + }, + }, + phone: { + on: { + CANCEL: 'idle', + TYPE_PHONE: { actions: assign((_, event) => ({ phoneNumber: event.value, errorMessage: undefined })) }, + SUBMIT: { target: 'sending', actions: assign(() => ({ errorMessage: undefined })) }, + }, + }, + sending: { + invoke: fromPromise(context => context.onSend(context.phoneNumber), { + onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + onError: { + target: 'phone', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + verify: { + on: { + CANCEL: 'idle', + TICK: tick, + RESEND: { + target: 'resending', + guard: context => context.resendSeconds === 0, + actions: assign(() => ({ errorMessage: undefined })), + }, + TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, errorMessage: undefined })) }, + SUBMIT: { + target: 'verifying', + actions: assign((context, event) => ({ code: event.code ?? context.code, errorMessage: undefined })), + }, + }, + }, + resending: { + invoke: fromPromise(context => context.onSend(context.phoneNumber), { + onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + onError: { + target: 'verify', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + verifying: { + on: { TICK: tick }, + invoke: fromPromise(context => context.onVerify(context.phoneNumber, context.code), { + onDone: 'idle', + onError: { + target: 'verify', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + }, +}); + +export function useUserProfileAddPhoneController( + options: UserProfileAddPhoneControllerOptions, +): UserProfileAddPhoneViewProps { + const [snapshot, send] = useMachine(machine, { context: options }); + const { resendSeconds } = snapshot.context; + const open = snapshot.value !== 'idle'; + useEffect(() => { + if (!open || resendSeconds === 0) { + return; + } + const timer = setTimeout(() => send({ type: 'TICK' }), 1000); + return () => clearTimeout(timer); + }, [open, resendSeconds, send]); + + return { + resendSeconds, + isResending: snapshot.value === 'resending', + open, + step: + snapshot.value === 'verify' || snapshot.value === 'verifying' || snapshot.value === 'resending' + ? 'verify' + : 'phone', + phoneNumber: snapshot.context.phoneNumber, + code: snapshot.context.code, + errorMessage: snapshot.context.errorMessage, + isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', + onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), + onPhoneNumberChange: value => send({ type: 'TYPE_PHONE', value }), + onCodeChange: value => send({ type: 'TYPE_CODE', value }), + onSubmit: code => send({ type: 'SUBMIT', code }), + onResend: () => send({ type: 'RESEND' }), + }; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts new file mode 100644 index 00000000000..d9883904dff --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts @@ -0,0 +1,21 @@ +export const userProfileAddPhoneMessages = { + error: 'Something went wrong. Please try again.', + phone: { + title: 'Add phone number', + description: 'We’ll send you a text to verify this phone number. Message and data rates may apply.', + label: 'Phone', + submit: 'Send code', + pending: 'Sending code', + }, + verify: { + title: 'Verify your phone number', + description: 'Enter the code sent to {phoneNumber}', + label: 'Verification code', + submit: 'Verify', + pending: 'Verifying', + cancel: 'Cancel', + resend: 'Didn’t receive a code? Resend', + resending: 'Sending a new code…', + resendCountdown: 'Didn’t receive a code? Resend ({seconds})', + }, +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view.tsx new file mode 100644 index 00000000000..e57fe54a5d7 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view.tsx @@ -0,0 +1,195 @@ +import type { FormEvent } from 'react'; +import { useEffect, useId, useRef } from 'react'; + +import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; +import { Button, SubmitButton } from '../../components/button'; +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 { Otp } from '../../components/otp'; +import { PhoneInput } from '../../components/phone-input'; +import { fill } from './user-profile-account-section.messages'; +import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages'; + +export interface UserProfileAddPhoneViewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + step: 'phone' | 'verify'; + phoneNumber: string; + onPhoneNumberChange: (value: string) => void; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +export function UserProfileAddPhoneView(props: UserProfileAddPhoneViewProps) { + const phoneFormId = useId(); + const verifyFormId = useId(); + const phoneRef = useRef(null); + const verifyRef = useRef(null); + + useEffect(() => { + if (props.open && props.step === 'verify') { + verifyRef.current?.querySelector('input:not([type="hidden"])')?.focus(); + } + }, [props.open, props.step]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!props.isPending && !props.isResending) { + props.onSubmit(); + } + }; + + return ( + + {props.trigger ? : null} + + phoneRef.current ?? verifyRef.current?.querySelector('input:not([type="hidden"])') ?? true + } + > + + + {current => ( + <> + + + {m.phone.title} + {m.phone.description} + + + } + > + + {m.phone.label} + + {current.errorMessage ? {current.errorMessage} : null} + + + + + {m.phone.submit} + + + + + + {m.verify.title} + + {fill(m.verify.description, { phoneNumber: stringToFormattedPhoneString(current.phoneNumber) })} + + + + } + > + + {m.verify.label} + { + if (!current.isPending && !current.isResending) { + current.onSubmit(code); + } + }} + /> + {current.errorMessage ? {current.errorMessage} : null} + + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + + )} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx index 08489124bdc..a7fdd67dcc5 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx @@ -1,4 +1,5 @@ import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; @@ -10,6 +11,7 @@ import { styles } from '../user-profile-profile-panel.styles'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; export interface UserProfileContactListRowViewProps { + addAction?: ReactNode; kind: 'email' | 'phone'; label: string; items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; @@ -29,6 +31,7 @@ export function UserProfileContactListRowView({ onVerify, onSetPrimary, onRemove, + addAction, }: UserProfileContactListRowViewProps) { const emptyDescription = m[kind].empty; @@ -38,7 +41,9 @@ export function UserProfileContactListRowView({ {label} - {onAdd ? ( + {addAction ? ( + {addAction} + ) : onAdd ? (
); } From 36dd4e11fd1d7ba403ce8a93bea93462e659752f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 00:39:59 -0600 Subject: [PATCH 4/8] feat(ui): confirm phone number removal --- .../user-profile-phone-actions.test.tsx | 155 +++++++++++++++++- .../user-profile-profile-panel.view.test.tsx | 6 + .../user-profile-account-section.messages.ts | 7 + .../user-profile-account-section.view.tsx | 73 ++++++++- .../user-profile-contact-list-row.view.tsx | 6 +- .../user-profile-remove-phone.view.tsx | 53 ++++++ .../user-profile/user-profile-action-menu.tsx | 13 +- .../user-profile-profile-panel.styles.ts | 5 +- 8 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.view.tsx diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx index 56a52f11535..1f2c4a7deff 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -23,6 +23,46 @@ function renderPhone(overrides: Partial = {} } describe('phone actions', () => { + it('ignores backdrop clicks and allows Escape to cancel removal', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + 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 backdrop = document.querySelector('.cl-dialog-backdrop'); + if (!backdrop) { + throw new Error('Expected a dialog backdrop'); + } + await user.click(backdrop); + expect(dialog).toBeInTheDocument(); + await user.keyboard('{Escape}'); + await waitFor(() => expect(dialog).not.toBeInTheDocument()); + expect(onRemovePhone).not.toHaveBeenCalled(); + }); + + it('closes confirmation before deletion finishes and prevents duplicate requests', async () => { + const user = userEvent.setup(); + let finish = () => {}; + const pending = new Promise(resolve => { + finish = resolve; + }); + const onRemovePhone = vi.fn(() => pending); + 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 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(onRemovePhone).toHaveBeenCalledOnce(); + finish(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + it('hides set primary while an update is pending', async () => { const user = userEvent.setup(); let finish = () => {}; @@ -82,6 +122,94 @@ describe('phone actions', () => { expect(screen.queryByRole('button', { name: 'Manage +1 (801) 555-0100' })).not.toBeInTheDocument(); }); + it('cancels removal without calling the mutation', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + 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()); + expect(onRemovePhone).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })).toHaveFocus(); + }); + + it('returns focus to the phone menu after opening with the keyboard and canceling with Escape', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + renderPhone({ onRemovePhone }); + const trigger = screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' }); + + trigger.focus(); + await user.keyboard('{Enter}'); + await user.keyboard('{Enter}'); + expect(screen.getByRole('alertdialog', { name: 'Remove phone number?' })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onRemovePhone).not.toHaveBeenCalled(); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('returns focus to Add phone number when the removed phone disappears', async () => { + const user = userEvent.setup(); + function Example() { + const [phones, setPhones] = useState([{ id: 'phone_1', value: '+18015550100', isVerified: true }]); + return ( + + Promise.resolve()} + onVerifyPhoneCode={() => Promise.resolve()} + onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} + /> + + ); + } + 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 waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(screen.queryByRole('button', { name: 'Manage +1 (801) 555-0100' })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add phone number' })).toHaveFocus()); + }); + + it('shows a failed removal in the account section and allows retry from the menu', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi + .fn() + .mockRejectedValueOnce(new Error('Cannot remove this phone.')) + .mockResolvedValue(undefined); + 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' })); + 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()); + expect(onRemovePhone).toHaveBeenCalledTimes(2); + }); + + it('does not offer removal when it is forbidden', async () => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', isVerified: true, canRemove: false }], + onSetPrimaryPhone: vi.fn(), + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Remove phone number' })).not.toBeInTheDocument(); + }); it('shows a primary update error without opening a dialog', async () => { const user = userEvent.setup(); const onSetPrimaryPhone = vi.fn().mockRejectedValue(new Error('Unable to update primary phone.')); @@ -92,4 +220,29 @@ describe('phone actions', () => { expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary phone.'); expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); }); + it('requires confirmation before removing a phone number', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + render( + + + , + ); + 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?' }); + 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()); + }); }); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 957cdb28363..3db4afc416a 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -434,6 +434,12 @@ describe('UserProfileProfilePanelView', () => { 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(); + await user.click( + within(screen.getByRole('alertdialog', { name: 'Remove phone number?' })).getByRole('button', { + name: 'Remove', + }), + ); expect(onRemovePhone).toHaveBeenCalledWith('phone_unverified'); await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0101' })); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 7cf9d8f4788..5a2e623702d 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -69,6 +69,13 @@ 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/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index ab923555d5a..ae83e54b73c 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,5 +1,6 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; +import type { Ref } from 'react'; import { useRef, useState } from 'react'; import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; @@ -18,6 +19,7 @@ import { UserProfileContactRowView } from './user-profile-contact-row.view'; import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileNameRowView } from './user-profile-name-row.view'; import { UserProfilePictureRowView } from './user-profile-picture-row.view'; +import { UserProfileRemovePhoneView } from './user-profile-remove-phone.view'; import { UserProfileUsernameRowView } from './user-profile-username-row.view'; export interface UserProfileEmail { @@ -69,7 +71,7 @@ export interface UserProfileAccountSectionViewProps { onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; onSetPrimaryPhone?: (id: string) => void | Promise; - onRemovePhone?: (id: string) => void; + onRemovePhone?: (id: string) => void | Promise; } export function UserProfileAccountSectionView({ @@ -101,13 +103,19 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const addPhoneTriggerRef = useRef(null); const addPhoneAction = onSendPhoneCode && onVerifyPhoneCode ? ( ) : undefined; + const confirmedRemoval = useRef(false); + const [phoneToRemove, setPhoneToRemove] = useState(); + const [removeError, setRemoveError] = useState(); + const removing = useRef(false); const [isSettingPrimary, setIsSettingPrimary] = useState(false); const [primaryError, setPrimaryError] = useState(); const settingPrimary = useRef(false); @@ -130,6 +138,31 @@ export function UserProfileAccountSectionView({ } }; + const removePhone = (id: string) => { + const phone = phones.find(phone => phone.id === id); + if (!phone || phone.canRemove === false || !onRemovePhone || removing.current) { + return; + } + confirmedRemoval.current = false; + setPhoneToRemove(phone); + setRemoveError(undefined); + }; + + const confirmRemovePhone = async () => { + if (!phoneToRemove || !onRemovePhone || removing.current) { + return; + } + removing.current = true; + confirmedRemoval.current = true; + setPhoneToRemove(undefined); + try { + await onRemovePhone(phoneToRemove.id); + } catch (error) { + setRemoveError(error instanceof Error ? error.message : m.phone.removeError); + } finally { + removing.current = false; + } + }; const formattedPhones = phones.map(phone => ({ ...phone, value: stringToFormattedPhoneString(phone.value), @@ -205,9 +238,26 @@ export function UserProfileAccountSectionView({ label={m.phone.label} addAction={addPhoneAction} onManage={isSettingPrimary ? undefined : onManagePhone} - onRemove={onRemovePhone} + onRemove={onRemovePhone ? removePhone : undefined} onSetPrimary={onSetPrimaryPhone && !isSettingPrimary ? id => void setPrimaryPhone(id) : undefined} onVerify={onVerifyPhone} + renderActionDialog={ + onRemovePhone + ? phone => ( + { + if (!open) { + setPhoneToRemove(undefined); + } + }} + onConfirm={() => void confirmRemovePhone()} + finalFocus={() => (confirmedRemoval.current ? addPhoneTriggerRef.current : undefined)} + /> + ) + : undefined + } /> @@ -220,11 +270,27 @@ export function UserProfileAccountSectionView({ {primaryError} ) : null} + {removeError ? ( + + {removeError} + + ) : null} ); } -function AddPhone({ options, compact }: { options: UserProfileAddPhoneControllerOptions; compact: boolean }) { +function AddPhone({ + options, + compact, + triggerRef, +}: { + options: UserProfileAddPhoneControllerOptions; + compact: boolean; + triggerRef?: Ref; +}) { const controller = useUserProfileAddPhoneController(options); return ( void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void; + renderActionDialog?: (item: { id: string; value: string }) => ReactNode; } export function UserProfileContactListRowView({ @@ -32,6 +33,7 @@ export function UserProfileContactListRowView({ onSetPrimary, onRemove, addAction, + renderActionDialog, }: UserProfileContactListRowViewProps) { const emptyDescription = m[kind].empty; @@ -108,7 +110,9 @@ export function UserProfileContactListRowView({ + > + {renderActionDialog?.(item)} + ) : null} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.view.tsx new file mode 100644 index 00000000000..74dd2be3840 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.view.tsx @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../../components/button'; +import type { DialogFocusTarget } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Heading } from '../../components/heading'; +import { Text } from '../../components/text'; +import { styles } from '../user-profile-profile-panel.styles'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +export interface UserProfileRemovePhoneViewProps { + phoneNumber: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + finalFocus?: DialogFocusTarget; +} + +export function UserProfileRemovePhoneView({ + phoneNumber, + open, + onOpenChange, + onConfirm, + finalFocus, +}: UserProfileRemovePhoneViewProps) { + const [beforePhone, afterPhone] = m.phone.removeDialog.description.split('{phoneNumber}'); + + return ( + + + }>{m.phone.removeDialog.title} + }> + {beforePhone} + {phoneNumber} + {afterPhone} + + + }>{m.phone.removeDialog.cancel} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx index 288bd38c2ba..c2f4041c6b6 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + import { Icon } from '../components/icon'; import { Menu } from '../components/menu'; import type { IconName } from '../icons/registry'; @@ -9,7 +11,15 @@ export interface UserProfileMenuAction { onClick: () => void; } -export function UserProfileActionMenu({ label, actions }: { label: string; actions: UserProfileMenuAction[] }) { +export function UserProfileActionMenu({ + label, + actions, + children, +}: { + label: string; + actions: UserProfileMenuAction[]; + children?: ReactNode; +}) { if (actions.length === 0) { return null; } @@ -34,6 +44,7 @@ export function UserProfileActionMenu({ label, actions }: { label: string; actio ))} + {children} ); } diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index 8a3cc17a27c..fd3e6967fb3 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -1,8 +1,11 @@ import * as stylex from '@stylexjs/stylex'; -import { space } from '../tokens.stylex'; +import { fontWeightVars, space } from '../tokens.stylex'; export const styles = stylex.create({ + confirmPhoneNumber: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, contactValue: { gap: space['2'], alignItems: 'center', From 483ddf38a9b134851839bc6fc93616325d3b1c15 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 00:44:13 -0600 Subject: [PATCH 5/8] refactor(ui): move phone behavior into the phone row --- .../user-profile-account-section.types.ts | 8 + .../user-profile-account-section.view.tsx | 190 ++--------------- .../user-profile-phone-row.view.tsx | 197 ++++++++++++++++++ 3 files changed, 223 insertions(+), 172 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts index ba5d37b4a05..421f0c68606 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts @@ -24,3 +24,11 @@ export class UserProfileSaveError extends Error this.fields = fields; } } + +export interface UserProfilePhone { + id: string; + value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index ae83e54b73c..ae37e4229c5 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,36 +1,21 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import type { Ref } from 'react'; -import { useRef, useState } from 'react'; -import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; -import { Button } from '../../components/button'; -import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import { Text } from '../../components/text'; import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import { styles } from './user-profile-account-section.styles'; -import type { UserProfileNameAttribute } from './user-profile-account-section.types'; -import type { UserProfileAddPhoneControllerOptions } from './user-profile-add-phone.controller'; -import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; -import { UserProfileAddPhoneView } from './user-profile-add-phone.view'; +import type { UserProfileNameAttribute, UserProfilePhone } from './user-profile-account-section.types'; import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; import { UserProfileContactRowView } from './user-profile-contact-row.view'; import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileNameRowView } from './user-profile-name-row.view'; +import { UserProfilePhoneRowView } from './user-profile-phone-row.view'; import { UserProfilePictureRowView } from './user-profile-picture-row.view'; -import { UserProfileRemovePhoneView } from './user-profile-remove-phone.view'; import { UserProfileUsernameRowView } from './user-profile-username-row.view'; -export interface UserProfileEmail { - id: string; - value: string; - isDefault?: boolean; - isVerified?: boolean; - canRemove?: boolean; -} +export type { UserProfilePhone } from './user-profile-account-section.types'; -export interface UserProfilePhone { +export interface UserProfileEmail { id: string; value: string; isDefault?: boolean; @@ -103,70 +88,18 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { - const addPhoneTriggerRef = useRef(null); - const addPhoneAction = - onSendPhoneCode && onVerifyPhoneCode ? ( - - ) : undefined; - const confirmedRemoval = useRef(false); - const [phoneToRemove, setPhoneToRemove] = useState(); - const [removeError, setRemoveError] = useState(); - const removing = useRef(false); - const [isSettingPrimary, setIsSettingPrimary] = useState(false); - const [primaryError, setPrimaryError] = useState(); - const settingPrimary = useRef(false); - - const setPrimaryPhone = async (id: string) => { - const phone = phones.find(phone => phone.id === id); - if (!onSetPrimaryPhone || !phone?.isVerified || phone.isDefault || settingPrimary.current) { - return; - } - settingPrimary.current = true; - setIsSettingPrimary(true); - setPrimaryError(undefined); - try { - await onSetPrimaryPhone(id); - } catch (error) { - setPrimaryError(error instanceof Error ? error.message : m.phone.primaryError); - } finally { - settingPrimary.current = false; - setIsSettingPrimary(false); - } - }; - - const removePhone = (id: string) => { - const phone = phones.find(phone => phone.id === id); - if (!phone || phone.canRemove === false || !onRemovePhone || removing.current) { - return; - } - confirmedRemoval.current = false; - setPhoneToRemove(phone); - setRemoveError(undefined); - }; - - const confirmRemovePhone = async () => { - if (!phoneToRemove || !onRemovePhone || removing.current) { - return; - } - removing.current = true; - confirmedRemoval.current = true; - setPhoneToRemove(undefined); - try { - await onRemovePhone(phoneToRemove.id); - } catch (error) { - setRemoveError(error instanceof Error ? error.message : m.phone.removeError); - } finally { - removing.current = false; - } - }; - const formattedPhones = phones.map(phone => ({ - ...phone, - value: stringToFormattedPhoneString(phone.value), - })); + const phoneRow = ( + + ); return (
@@ -202,15 +135,7 @@ export function UserProfileAccountSectionView({ onManage={onManageEmail} /> ) : null} - {!allowMultipleAccounts ? ( - - ) : null} + {!allowMultipleAccounts ? phoneRow : null} {allowMultipleAccounts ? ( @@ -231,88 +156,9 @@ export function UserProfileAccountSectionView({ ) : null} {allowMultipleAccounts ? ( - - void setPrimaryPhone(id) : undefined} - onVerify={onVerifyPhone} - renderActionDialog={ - onRemovePhone - ? phone => ( - { - if (!open) { - setPhoneToRemove(undefined); - } - }} - onConfirm={() => void confirmRemovePhone()} - finalFocus={() => (confirmedRemoval.current ? addPhoneTriggerRef.current : undefined)} - /> - ) - : undefined - } - /> - + {phoneRow} ) : null} - {primaryError ? ( - - {primaryError} - - ) : null} - {removeError ? ( - - {removeError} - - ) : null}
); } - -function AddPhone({ - options, - compact, - triggerRef, -}: { - options: UserProfileAddPhoneControllerOptions; - compact: boolean; - triggerRef?: Ref; -}) { - const controller = useUserProfileAddPhoneController(options); - return ( - - {compact ? ( - - ) : null} - {compact ? m.add : m.phone.add} - - } - /> - ); -} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx new file mode 100644 index 00000000000..88444c7ffe4 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx @@ -0,0 +1,197 @@ +import type { Ref } from 'react'; +import { useRef, useState } from 'react'; + +import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import { Text } from '../../components/text'; +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'; +import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; +import { UserProfileAddPhoneView } from './user-profile-add-phone.view'; +import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; +import { UserProfileContactRowView } from './user-profile-contact-row.view'; +import { UserProfileRemovePhoneView } from './user-profile-remove-phone.view'; + +export interface UserProfilePhoneRowViewProps { + phones: UserProfilePhone[]; + allowMultipleAccounts?: boolean; + onSendPhoneCode?: (phoneNumber: string) => Promise; + onVerifyPhoneCode?: (phoneNumber: string, code: string) => Promise; + onManagePhone?: (id: string) => void; + onVerifyPhone?: (id: string) => void; + onSetPrimaryPhone?: (id: string) => void | Promise; + onRemovePhone?: (id: string) => void | Promise; +} + +export function UserProfilePhoneRowView({ + phones, + allowMultipleAccounts = false, + onSendPhoneCode, + onVerifyPhoneCode, + onManagePhone, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, +}: UserProfilePhoneRowViewProps) { + const addPhoneTriggerRef = useRef(null); + const addPhoneAction = + onSendPhoneCode && onVerifyPhoneCode ? ( + + ) : undefined; + const confirmedRemoval = useRef(false); + const [phoneToRemove, setPhoneToRemove] = useState(); + const [removeError, setRemoveError] = useState(); + const removing = useRef(false); + const [isSettingPrimary, setIsSettingPrimary] = useState(false); + const [primaryError, setPrimaryError] = useState(); + const settingPrimary = useRef(false); + + const setPrimaryPhone = async (id: string) => { + const phone = phones.find(phone => phone.id === id); + if (!onSetPrimaryPhone || !phone?.isVerified || phone.isDefault || settingPrimary.current) { + return; + } + settingPrimary.current = true; + setIsSettingPrimary(true); + setPrimaryError(undefined); + try { + await onSetPrimaryPhone(id); + } catch (error) { + setPrimaryError(error instanceof Error ? error.message : m.phone.primaryError); + } finally { + settingPrimary.current = false; + setIsSettingPrimary(false); + } + }; + + const removePhone = (id: string) => { + const phone = phones.find(phone => phone.id === id); + if (!phone || phone.canRemove === false || !onRemovePhone || removing.current) { + return; + } + confirmedRemoval.current = false; + setPhoneToRemove(phone); + setRemoveError(undefined); + }; + + const confirmRemovePhone = async () => { + if (!phoneToRemove || !onRemovePhone || removing.current) { + return; + } + removing.current = true; + confirmedRemoval.current = true; + setPhoneToRemove(undefined); + try { + await onRemovePhone(phoneToRemove.id); + } catch (error) { + setRemoveError(error instanceof Error ? error.message : m.phone.removeError); + } finally { + removing.current = false; + } + }; + const formattedPhones = phones.map(phone => ({ + ...phone, + value: stringToFormattedPhoneString(phone.value), + })); + + if (!allowMultipleAccounts) { + return ( + + ); + } + + return ( + <> + void setPrimaryPhone(id) : undefined} + onVerify={onVerifyPhone} + renderActionDialog={ + onRemovePhone + ? phone => ( + { + if (!open) { + setPhoneToRemove(undefined); + } + }} + onConfirm={() => void confirmRemovePhone()} + finalFocus={() => (confirmedRemoval.current ? addPhoneTriggerRef.current : undefined)} + /> + ) + : undefined + } + /> + {primaryError ? ( + + {primaryError} + + ) : null} + {removeError ? ( + + {removeError} + + ) : null} + + ); +} + +function AddPhone({ + options, + compact, + triggerRef, +}: { + options: UserProfileAddPhoneControllerOptions; + compact: boolean; + triggerRef?: Ref; +}) { + const controller = useUserProfileAddPhoneController(options); + return ( + + {compact ? ( + + ) : null} + {compact ? m.add : m.phone.add} + + } + /> + ); +} From beaa83b0c4caece23b896e1468e8e31ea4b09957 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 01:01:07 -0600 Subject: [PATCH 6/8] fix(ui): remove forced focus during phone verification transition --- .../__tests__/user-profile-add-phone.integration.test.tsx | 3 +-- .../__tests__/user-profile-add-phone.view.test.tsx | 1 - .../user-profile-add-phone.view.tsx | 8 +------- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx index 73433cbb910..0cc7edb1827 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx @@ -30,8 +30,7 @@ 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 waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); - await user.keyboard('123456'); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '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/user-profile/__tests__/user-profile-add-phone.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx index de9e06e7f80..2225e67cb51 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx @@ -107,7 +107,6 @@ describe('UserProfileAddPhoneView', () => { expect(screen.getByText('Enter the code sent to +1 (801) 888-8181')).toBeInTheDocument(); expect(screen.queryByRole('textbox', { name: 'Phone' })).not.toBeInTheDocument(); const firstSlot = screen.getByRole('textbox', { name: 'Verification code' }); - await waitFor(() => expect(firstSlot).toHaveFocus()); const verifyForm = firstSlot.closest('form'); if (!verifyForm) { throw new Error('Verification form missing'); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view.tsx index e57fe54a5d7..edaf65c054e 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view.tsx @@ -1,5 +1,5 @@ import type { FormEvent } from 'react'; -import { useEffect, useId, useRef } from 'react'; +import { useId, useRef } from 'react'; import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; import { Button, SubmitButton } from '../../components/button'; @@ -36,12 +36,6 @@ export function UserProfileAddPhoneView(props: UserProfileAddPhoneViewProps) { const phoneRef = useRef(null); const verifyRef = useRef(null); - useEffect(() => { - if (props.open && props.step === 'verify') { - verifyRef.current?.querySelector('input:not([type="hidden"])')?.focus(); - } - }, [props.open, props.step]); - const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (!props.isPending && !props.isResending) { From 4a8e560ba5ff5b3d6f96f9c560313fe71c7de8f1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 01:09:29 -0600 Subject: [PATCH 7/8] refactor(ui): name phone removal component as a dialog --- .../user-profile-phone-row.view.tsx | 4 ++-- ...-phone.view.tsx => user-profile-remove-phone.dialog.tsx} | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename packages/ui/src/mosaic/user-profile/user-profile-account-section/{user-profile-remove-phone.view.tsx => user-profile-remove-phone.dialog.tsx} (91%) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx index 88444c7ffe4..dfc29a4230c 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx @@ -12,7 +12,7 @@ import { useUserProfileAddPhoneController } from './user-profile-add-phone.contr import { UserProfileAddPhoneView } from './user-profile-add-phone.view'; import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; import { UserProfileContactRowView } from './user-profile-contact-row.view'; -import { UserProfileRemovePhoneView } from './user-profile-remove-phone.view'; +import { UserProfileRemovePhoneDialog } from './user-profile-remove-phone.dialog'; export interface UserProfilePhoneRowViewProps { phones: UserProfilePhone[]; @@ -126,7 +126,7 @@ export function UserProfilePhoneRowView({ renderActionDialog={ onRemovePhone ? phone => ( - { diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx similarity index 91% rename from packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.view.tsx rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx index 74dd2be3840..809555a88e8 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx @@ -8,7 +8,7 @@ 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 UserProfileRemovePhoneViewProps { +export interface UserProfileRemovePhoneDialogProps { phoneNumber: string; open: boolean; onOpenChange: (open: boolean) => void; @@ -16,13 +16,13 @@ export interface UserProfileRemovePhoneViewProps { finalFocus?: DialogFocusTarget; } -export function UserProfileRemovePhoneView({ +export function UserProfileRemovePhoneDialog({ phoneNumber, open, onOpenChange, onConfirm, finalFocus, -}: UserProfileRemovePhoneViewProps) { +}: UserProfileRemovePhoneDialogProps) { const [beforePhone, afterPhone] = m.phone.removeDialog.description.split('{phoneNumber}'); return ( From 21f9639e4b52fe915cb30c088e0d91b641cf1160 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 01:15:31 -0600 Subject: [PATCH 8/8] refactor(ui): name add phone component as a dialog --- .../fixtures/user-profile-add-phone.ts | 4 ++-- .../user-profile-account-section.stories.tsx | 4 ++-- ...=> user-profile-add-phone.dialog.test.tsx} | 22 +++++++++---------- .../user-profile-add-phone.controller.ts | 4 ++-- ....tsx => user-profile-add-phone.dialog.tsx} | 4 ++-- .../user-profile-phone-row.view.tsx | 4 ++-- 6 files changed, 21 insertions(+), 21 deletions(-) rename packages/ui/src/mosaic/user-profile/__tests__/{user-profile-add-phone.view.test.tsx => user-profile-add-phone.dialog.test.tsx} (90%) rename packages/ui/src/mosaic/user-profile/user-profile-account-section/{user-profile-add-phone.view.tsx => user-profile-add-phone.dialog.tsx} (98%) diff --git a/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts index b76b23f8f80..c2908c6716e 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts @@ -1,8 +1,8 @@ import type { UserProfileAccountSectionViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; -import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view'; +import type { UserProfileAddPhoneDialogProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; interface FixtureOptions { - failAt?: UserProfileAddPhoneViewProps['step']; + failAt?: UserProfileAddPhoneDialogProps['step']; onVerified?: (phoneNumber: string) => void; } 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 992746be566..86c5288a77c 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -4,7 +4,7 @@ import type { UserProfilePhone, } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; -import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.view'; +import type { UserProfileAddPhoneDialogProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -32,7 +32,7 @@ function AccountSection({ usernameFailWith, }: { allowMultipleAccounts: boolean; - failAt?: UserProfileAddPhoneViewProps['step']; + failAt?: UserProfileAddPhoneDialogProps['step']; failWith?: UserProfileFormError; usernameFailWith?: UserProfileFormError; }) { diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx similarity index 90% rename from packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx rename to packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx index 2225e67cb51..85924c65bb3 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx @@ -4,11 +4,11 @@ import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../MosaicProvider'; -import type { UserProfileAddPhoneViewProps } from '../user-profile-account-section/user-profile-add-phone.view'; -import { UserProfileAddPhoneView } from '../user-profile-account-section/user-profile-add-phone.view'; +import type { UserProfileAddPhoneDialogProps } from '../user-profile-account-section/user-profile-add-phone.dialog'; +import { UserProfileAddPhoneDialog } from '../user-profile-account-section/user-profile-add-phone.dialog'; -function renderView(overrides: Partial = {}) { - const props: UserProfileAddPhoneViewProps = { +function renderView(overrides: Partial = {}) { + const props: UserProfileAddPhoneDialogProps = { open: true, onOpenChange: vi.fn(), step: 'phone', @@ -24,18 +24,18 @@ function renderView(overrides: Partial = {}) { props, ...render( - + , ), }; } -function VerificationExample({ onSubmit }: Pick) { +function VerificationExample({ onSubmit }: Pick) { const [code, setCode] = useState(''); return ( - undefined} step='verify' @@ -50,7 +50,7 @@ function VerificationExample({ onSubmit }: Pick { +describe('UserProfileAddPhoneDialog', () => { it.each(['typing', 'pasting'] as const)('automatically submits a complete code after %s', async method => { const user = userEvent.setup(); const onSubmit = vi.fn(); @@ -95,7 +95,7 @@ describe('UserProfileAddPhoneView', () => { rerender( - { rerender( - @@ -166,7 +166,7 @@ describe('UserProfileAddPhoneView', () => { rerender( - void; trigger?: DialogTriggerProps['render']; @@ -30,7 +30,7 @@ export interface UserProfileAddPhoneViewProps { resendSeconds?: number; } -export function UserProfileAddPhoneView(props: UserProfileAddPhoneViewProps) { +export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) { const phoneFormId = useId(); const verifyFormId = useId(); const phoneRef = useRef(null); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx index dfc29a4230c..0137c60b092 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx @@ -9,7 +9,7 @@ import { userProfileAccountSectionBase as m } from './user-profile-account-secti import type { UserProfilePhone } from './user-profile-account-section.types'; import type { UserProfileAddPhoneControllerOptions } from './user-profile-add-phone.controller'; import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; -import { UserProfileAddPhoneView } from './user-profile-add-phone.view'; +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'; @@ -172,7 +172,7 @@ function AddPhone({ }) { const controller = useUserProfileAddPhoneController(options); return ( -