From c7f3ac5b8b7ec000587d93939e78613270c6ac94 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 10 Sep 2026 14:41:09 -0600 Subject: [PATCH 01/13] feat(ui): add phone number entry and verification dialog --- .changeset/heavy-pears-smile.md | 2 + .../user-profile-add-phone.view.test.tsx | 169 ++++++++++++++++ .../user-profile-add-phone.messages.ts | 20 ++ .../user-profile-add-phone.view.tsx | 180 ++++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 .changeset/heavy-pears-smile.md 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-add-phone.messages.ts create mode 100644 packages/ui/src/mosaic/user-profile/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/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..77bfb7fe8ee --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx @@ -0,0 +1,169 @@ +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-add-phone.view'; +import { UserProfileAddPhoneView } from '../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 with Enter 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()); + await user.type(phone, '{Enter}'); + 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()); + await user.type(firstSlot, '{Enter}'); + 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/user-profile-add-phone.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-add-phone.messages.ts new file mode 100644 index 00000000000..86717739f8d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-add-phone.messages.ts @@ -0,0 +1,20 @@ +export const userProfileAddPhoneMessages = { + 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: (phoneNumber: string) => `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: (seconds: number) => `Didn’t receive a code? Resend (${seconds})`, + }, +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-phone.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-add-phone.view.tsx new file mode 100644 index 00000000000..4bf6ba728fd --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-add-phone.view.tsx @@ -0,0 +1,180 @@ +import type { FormEvent } from 'react'; +import { useEffect, 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 { 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 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} + + {m.verify.description(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} + + +
+
+ + )} +
+
+
+
+ ); +} From bd071115bec7fc06cd072dfeacf0c47f396c3328 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 10 Sep 2026 14:41:26 -0600 Subject: [PATCH 02/13] fix(ui): format phone numbers in the account section --- .../user-profile-profile-panel.view.test.tsx | 25 ++++++++++++++----- .../user-profile-account-section.view.tsx | 9 +++++-- 2 files changed, 26 insertions(+), 8 deletions(-) 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 328812f019a..8cc6b6c1bff 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(), onNameChange: vi.fn(), onUsernameChange: vi.fn() }); @@ -45,7 +58,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'); @@ -147,7 +160,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'); }); @@ -163,7 +176,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(); @@ -398,15 +411,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.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index aeedf789db6..cc08be77842 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -3,6 +3,7 @@ import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; import { useState } from 'react'; +import { stringToFormattedPhoneString } from '../../utils/phoneUtils'; import { Avatar } from '../components/avatar'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; @@ -92,6 +93,10 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const formattedPhones = phones.map(phone => ({ + ...phone, + value: stringToFormattedPhoneString(phone.value), + })); const initials = name .split(/\s+/) .map(part => part[0]) @@ -196,7 +201,7 @@ export function UserProfileAccountSectionView({ ) : null} {!allowMultipleAccounts ? ( Date: Thu, 10 Sep 2026 14:41:45 -0600 Subject: [PATCH 03/13] feat(swingset): demonstrate add phone in the account section --- packages/swingset/src/lib/registry.ts | 2 + .../fixtures/user-profile-add-phone.ts | 92 +++++++++++++++++++ .../stories/user-profile-account-section.mdx | 9 ++ .../user-profile-account-section.stories.tsx | 84 ++++++++++------- 4 files changed, 153 insertions(+), 34 deletions(-) create mode 100644 packages/swingset/src/stories/fixtures/user-profile-add-phone.ts 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..697137da056 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts @@ -0,0 +1,92 @@ +import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.view'; +import { useEffect, useState } from 'react'; + +type Step = UserProfileAddPhoneViewProps['step']; + +interface FixtureOptions { + failAt?: Step; + onVerified?: (phoneNumber: string) => void; +} + +export function useUserProfileAddPhoneFixture({ failAt, onVerified }: FixtureOptions = {}) { + const [open, setOpen] = useState(false); + const [step, setStep] = useState('phone'); + const [phoneNumber, setPhoneNumber] = useState('+18015558181'); + const [code, setCode] = useState(''); + const [isPending, setIsPending] = useState(false); + const [isResending, setIsResending] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const [resendSeconds, setResendSeconds] = useState(0); + + useEffect(() => { + if (!open || resendSeconds === 0) { + return; + } + const timer = setTimeout(() => setResendSeconds(seconds => Math.max(0, seconds - 1)), 1000); + return () => clearTimeout(timer); + }, [open, resendSeconds]); + + return { + open, + step, + phoneNumber, + code, + isPending, + isResending, + errorMessage, + resendSeconds, + onOpenChange: (nextOpen: boolean) => { + if (isPending || isResending) { + return; + } + if (nextOpen) { + setStep('phone'); + setCode(''); + setErrorMessage(undefined); + setResendSeconds(0); + } + setOpen(nextOpen); + }, + onPhoneNumberChange: (value: string) => { + setPhoneNumber(value); + setErrorMessage(undefined); + }, + onCodeChange: (value: string) => { + setCode(value); + setErrorMessage(undefined); + }, + onSubmit: async (submittedCode = code) => { + if (isPending || isResending) { + return; + } + setIsPending(true); + setErrorMessage(undefined); + await new Promise(resolve => setTimeout(resolve, 700)); + setIsPending(false); + if (failAt === step || (step === 'verify' && submittedCode === '000000')) { + setErrorMessage( + step === 'phone' ? 'We couldn’t send a code. Try again.' : 'That code is incorrect. Try again.', + ); + return; + } + if (step === 'phone') { + setStep('verify'); + setResendSeconds(12); + } else { + onVerified?.(phoneNumber); + setOpen(false); + } + }, + onResend: async () => { + if (isPending || isResending || resendSeconds > 0) { + return; + } + setIsResending(true); + setErrorMessage(undefined); + await new Promise(resolve => setTimeout(resolve, 700)); + setIsResending(false); + setCode(''); + setResendSeconds(12); + }, + }; +} 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 88229333df9..29c05460a9e 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -3,11 +3,14 @@ import type { UserProfilePhone, } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view'; +import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.view'; +import { UserProfileAddPhoneView } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.view'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { useUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; export { default as __source } from './user-profile-account-section.stories?raw'; @@ -20,7 +23,13 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx', }; -function AccountSection({ allowMultipleAccounts }: { allowMultipleAccounts: boolean }) { +function AccountSection({ + allowMultipleAccounts, + failAt, +}: { + allowMultipleAccounts: boolean; + failAt?: UserProfileAddPhoneViewProps['step']; +}) { const [emails, setEmails] = useState( allowMultipleAccounts ? [ @@ -33,41 +42,39 @@ function AccountSection({ allowMultipleAccounts }: { allowMultipleAccounts: bool { 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 = useUserProfileAddPhoneFixture({ + failAt, + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + }); return ( - - setEmails(current => [ - ...current, - { 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, - }, - ]) - } - 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} - onNameChange={() => undefined} - onUsernameChange={() => undefined} - /> + <> + + setEmails(current => [ + ...current, + { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, + ]) + } + onAddPhone={() => addPhone.onOpenChange(true)} + onProfilePictureChange={showFile} + onRemoveProfilePicture={clearImage} + onManageEmail={() => undefined} + onManagePhone={() => undefined} + onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} + onNameChange={() => undefined} + onUsernameChange={() => undefined} + /> + + ); } @@ -78,3 +85,12 @@ export function Default() { export function MultipleAccounts() { return ; } + +export function AddPhoneFails() { + return ( + + ); +} From 3dd4212f6a2570fb13862e6ddadc643779b889ce Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 00:51:50 -0600 Subject: [PATCH 04/13] feat(ui): support setting a primary phone number --- .../user-profile-account-section.stories.tsx | 2 + .../user-profile-profile-panel.stories.tsx | 2 +- .../user-profile-phone-actions.test.tsx | 95 +++++++++++++++++++ .../user-profile-account-section.view.tsx | 39 +++++++- 4 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx 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 29c05460a9e..d8186ecc042 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -40,6 +40,7 @@ function AccountSection({ ); const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ...(allowMultipleAccounts ? [{ id: 'phone_2', value: '+18015550100', isVerified: true }] : []), ]); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); const addPhone = useUserProfileAddPhoneFixture({ @@ -70,6 +71,7 @@ function AccountSection({ onManagePhone={() => undefined} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} + onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} onNameChange={() => undefined} onUsernameChange={() => undefined} /> 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 334506009b2..10089f9f7e6 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -94,7 +94,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} onNameChange={() => undefined} 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 new file mode 100644 index 00000000000..2a1c1a3ae4c --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -0,0 +1,95 @@ +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 { UserProfileAccountSectionViewProps } from '../user-profile-account-section.view'; +import { UserProfileAccountSectionView } from '../user-profile-account-section.view'; + +function renderPhone(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('phone actions', () => { + it('hides set primary while an update is pending', async () => { + const user = userEvent.setup(); + let finish = () => {}; + const pending = new Promise(resolve => { + finish = resolve; + }); + const onSetPrimaryPhone = vi.fn(() => pending); + renderPhone({ onSetPrimaryPhone, onRemovePhone: vi.fn() }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + expect(onSetPrimaryPhone).toHaveBeenCalledOnce(); + finish(); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument()); + }); + it.each([{ isDefault: true, isVerified: true }, { isDefault: false, isVerified: false }, { isDefault: false }])( + 'hides set primary for an ineligible phone: %j', + async flags => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', ...flags }], + onSetPrimaryPhone: vi.fn(), + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + }, + ); + + it('updates the primary badge immediately without confirmation', async () => { + const user = userEvent.setup(); + function Example() { + const [phones, setPhones] = useState([ + { id: 'phone_1', value: '+18015550100', isVerified: true, isDefault: false }, + ]); + return ( + + + setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))) + } + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(screen.getByText('Primary')).toBeInTheDocument(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage +1 (801) 555-0100' })).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.')); + renderPhone({ onSetPrimaryPhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryPhone).toHaveBeenCalledExactlyOnceWith('phone_1'); + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary phone.'); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index cc08be77842..5747099a1a0 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -1,7 +1,7 @@ import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { stringToFormattedPhoneString } from '../../utils/phoneUtils'; import { Avatar } from '../components/avatar'; @@ -9,6 +9,7 @@ import { Badge } from '../components/badge'; import { Button } from '../components/button'; import { Icon } from '../components/icon'; import { Section } from '../components/section'; +import { Text } from '../components/text'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; @@ -65,7 +66,7 @@ export interface UserProfileAccountSectionViewProps { onAddPhone?: () => void; onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; - onSetPrimaryPhone?: (id: string) => void; + onSetPrimaryPhone?: (id: string) => void | Promise; onRemovePhone?: (id: string) => void; } @@ -93,6 +94,28 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + 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 : 'Unable to set the primary phone number. Try again.'); + } finally { + settingPrimary.current = false; + setIsSettingPrimary(false); + } + }; + const formattedPhones = phones.map(phone => ({ ...phone, value: stringToFormattedPhoneString(phone.value), @@ -228,12 +251,20 @@ export function UserProfileAccountSectionView({ kind='phone' label={m.phone.label} onAdd={onAddPhone} - onManage={onManagePhone} + onManage={isSettingPrimary ? undefined : onManagePhone} onRemove={onRemovePhone} - onSetPrimary={onSetPrimaryPhone} + onSetPrimary={onSetPrimaryPhone && !isSettingPrimary ? id => void setPrimaryPhone(id) : undefined} onVerify={onVerifyPhone} /> ) : null} + {primaryError ? ( + + {primaryError} + + ) : null} ); } From b02f42e8d8bb0b3f34a628ea8f1341cd696b0ccb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 00:52:36 -0600 Subject: [PATCH 05/13] feat(ui): confirm phone number removal --- .../user-profile-phone-actions.test.tsx | 108 +++++++++++++++++- .../user-profile-profile-panel.view.test.tsx | 6 + .../user-profile-account-section.view.tsx | 69 ++++++++++- .../user-profile-profile-panel.styles.ts | 5 +- 4 files changed, 182 insertions(+), 6 deletions(-) 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 2a1c1a3ae4c..eab13d621a1 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,47 @@ 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('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 +173,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 8cc6b6c1bff..f8d9e17c82f 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 @@ -417,6 +417,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.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index 5747099a1a0..9598e7bab2e 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -1,12 +1,13 @@ import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import { useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { stringToFormattedPhoneString } from '../../utils/phoneUtils'; import { Avatar } from '../components/avatar'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; +import { createConfirmHandle, Dialog } from '../components/dialog'; import { Icon } from '../components/icon'; import { Section } from '../components/section'; import { Text } from '../components/text'; @@ -67,7 +68,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({ @@ -94,6 +95,11 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const sectionRef = useRef(null); + const removeConfirm = useMemo(() => createConfirmHandle(), []); + 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); @@ -116,6 +122,35 @@ export function UserProfileAccountSectionView({ } }; + const removePhone = async (id: string) => { + const phone = phones.find(phone => phone.id === id); + if (!phone || phone.canRemove === false || !onRemovePhone || removing.current) { + return; + } + removing.current = true; + setPhoneToRemove(phone); + setRemoveError(undefined); + try { + const confirmed = await removeConfirm.show({ + title: 'Remove phone number?', + description: ( + <> + {stringToFormattedPhoneString(phone.value)}{' '} + will be removed from your account. You won’t be able to use it to sign in. + + ), + actionLabel: 'Remove', + destructive: true, + }); + if (confirmed) { + await onRemovePhone(id); + } + } catch (error) { + setRemoveError(error instanceof Error ? error.message : 'Unable to remove this phone number. Try again.'); + } finally { + removing.current = false; + } + }; const formattedPhones = phones.map(phone => ({ ...phone, value: stringToFormattedPhoneString(phone.value), @@ -134,7 +169,12 @@ export function UserProfileAccountSectionView({ } + render={ +
+ } onReject={rejections => { setRejection(rejections[0]?.reason ?? null); onProfilePictureReject?.(rejections); @@ -252,7 +292,7 @@ export function UserProfileAccountSectionView({ label={m.phone.label} onAdd={onAddPhone} onManage={isSettingPrimary ? undefined : onManagePhone} - onRemove={onRemovePhone} + onRemove={onRemovePhone ? id => void removePhone(id) : undefined} onSetPrimary={onSetPrimaryPhone && !isSettingPrimary ? id => void setPrimaryPhone(id) : undefined} onVerify={onVerifyPhone} /> @@ -265,6 +305,27 @@ export function UserProfileAccountSectionView({ {primaryError} ) : null} + {removeError ? ( + + {removeError} + + ) : null} + { + const buttons = Array.from(sectionRef.current?.querySelectorAll('button') ?? []); + const label = phoneToRemove ? `Manage ${stringToFormattedPhoneString(phoneToRemove.value)}` : ''; + return ( + buttons.find(button => button.getAttribute('aria-label') === label) ?? + buttons.find(button => button.getAttribute('aria-label') === 'Add phone number') ?? + buttons[0] ?? + false + ); + }} + /> ); } 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 86786a93239e5c334d3a326aa440edee04b0071b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 09:53:40 -0600 Subject: [PATCH 06/13] refactor(ui): align phone form with card composition --- .../user-profile-add-phone.view.test.tsx | 17 +- .../user-profile-add-phone.view.tsx | 178 ++++++++++-------- 2 files changed, 110 insertions(+), 85 deletions(-) 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 77bfb7fe8ee..7cb1e2661b6 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 @@ -68,14 +68,20 @@ describe('UserProfileAddPhoneView', () => { expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); }); - it('focuses the phone field and submits with Enter or Send code', async () => { + 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()); - await user.type(phone, '{Enter}'); + const phoneForm = phone.closest('form'); + if (!phoneForm) { + throw new Error('Phone form missing'); + } + expect(phoneForm).toHaveClass('cl-card-content'); + // user-event only finds descendant submit buttons, not buttons linked by form ID. + phoneForm.requestSubmit(); expect(props.onSubmit).toHaveBeenCalledOnce(); await user.click(screen.getByRole('button', { name: 'Send code' })); @@ -103,7 +109,12 @@ describe('UserProfileAddPhoneView', () => { expect(screen.queryByRole('textbox', { name: 'Phone' })).not.toBeInTheDocument(); const firstSlot = screen.getByRole('textbox', { name: 'Verification code' }); await waitFor(() => expect(firstSlot).toHaveFocus()); - await user.type(firstSlot, '{Enter}'); + 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 })); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-phone.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-add-phone.view.tsx index 4bf6ba728fd..ec74edbdfd2 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-add-phone.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-add-phone.view.tsx @@ -1,5 +1,5 @@ import type { FormEvent } from 'react'; -import { useEffect, useRef } from 'react'; +import { useEffect, useId, useRef } from 'react'; import { stringToFormattedPhoneString } from '../../utils/phoneUtils'; import { Button, SubmitButton } from '../components/button'; @@ -30,6 +30,8 @@ export interface UserProfileAddPhoneViewProps { } export function UserProfileAddPhoneView(props: UserProfileAddPhoneViewProps) { + const phoneFormId = useId(); + const verifyFormId = useId(); const phoneRef = useRef(null); const verifyRef = useRef(null); @@ -74,33 +76,39 @@ export function UserProfileAddPhoneView(props: UserProfileAddPhoneViewProps) { {m.phone.title} {m.phone.description} -
- - - {m.phone.label} - - {current.errorMessage ? {current.errorMessage} : null} - - - - - {m.phone.submit} - - -
+ + } + > + + {m.phone.label} + + {current.errorMessage ? {current.errorMessage} : null} + + + + + {m.phone.submit} + + -
- - + } + > + + {m.verify.label} + { + if (!current.isPending && !current.isResending) { + current.onSubmit(code); + } + }} + /> + {current.errorMessage ? {current.errorMessage} : null} + + + + + 0} - onClick={current.onResend} - > - {current.isResending - ? m.verify.resending - : (current.resendSeconds ?? 0) > 0 - ? m.verify.resendCountdown(current.resendSeconds ?? 0) - : m.verify.resend} - - - - - - } - > - {m.verify.cancel} - - - {m.verify.submit} - - - + fullWidth + /> + } + > + {m.verify.cancel} + + + {m.verify.submit} + +
)} From 9ee489e2c799e1dd498bbeb09eb0a9002e0e91d0 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 11:53:32 -0600 Subject: [PATCH 07/13] chore(ui): remove redundant phone test comment --- .../user-profile/__tests__/user-profile-add-phone.view.test.tsx | 1 - 1 file changed, 1 deletion(-) 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 7cb1e2661b6..2e1c4bc95b0 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 @@ -80,7 +80,6 @@ describe('UserProfileAddPhoneView', () => { throw new Error('Phone form missing'); } expect(phoneForm).toHaveClass('cl-card-content'); - // user-event only finds descendant submit buttons, not buttons linked by form ID. phoneForm.requestSubmit(); expect(props.onSubmit).toHaveBeenCalledOnce(); From 9df3d23df9092e4a123b76e5578ec9d0e69479b5 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 12:08:25 -0600 Subject: [PATCH 08/13] refactor(ui): add phone flow controller --- .../fixtures/user-profile-add-phone.ts | 92 ++-------- .../user-profile-add-phone.controller.test.ts | 153 +++++++++++++++++ .../user-profile-add-phone.controller.ts | 158 ++++++++++++++++++ 3 files changed, 324 insertions(+), 79 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-add-phone.controller.test.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-add-phone.controller.ts 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 697137da056..4162c7d2d73 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts @@ -1,92 +1,26 @@ +import { useUserProfileAddPhoneController } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.controller'; import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.view'; -import { useEffect, useState } from 'react'; - -type Step = UserProfileAddPhoneViewProps['step']; interface FixtureOptions { - failAt?: Step; + failAt?: UserProfileAddPhoneViewProps['step']; onVerified?: (phoneNumber: string) => void; } export function useUserProfileAddPhoneFixture({ failAt, onVerified }: FixtureOptions = {}) { - const [open, setOpen] = useState(false); - const [step, setStep] = useState('phone'); - const [phoneNumber, setPhoneNumber] = useState('+18015558181'); - const [code, setCode] = useState(''); - const [isPending, setIsPending] = useState(false); - const [isResending, setIsResending] = useState(false); - const [errorMessage, setErrorMessage] = useState(); - const [resendSeconds, setResendSeconds] = useState(0); - - useEffect(() => { - if (!open || resendSeconds === 0) { - return; - } - const timer = setTimeout(() => setResendSeconds(seconds => Math.max(0, seconds - 1)), 1000); - return () => clearTimeout(timer); - }, [open, resendSeconds]); - - return { - open, - step, - phoneNumber, - code, - isPending, - isResending, - errorMessage, - resendSeconds, - onOpenChange: (nextOpen: boolean) => { - if (isPending || isResending) { - return; - } - if (nextOpen) { - setStep('phone'); - setCode(''); - setErrorMessage(undefined); - setResendSeconds(0); - } - setOpen(nextOpen); - }, - onPhoneNumberChange: (value: string) => { - setPhoneNumber(value); - setErrorMessage(undefined); - }, - onCodeChange: (value: string) => { - setCode(value); - setErrorMessage(undefined); - }, - onSubmit: async (submittedCode = code) => { - if (isPending || isResending) { - return; - } - setIsPending(true); - setErrorMessage(undefined); + return useUserProfileAddPhoneController({ + initialPhoneNumber: '+18015558181', + onSend: async () => { await new Promise(resolve => setTimeout(resolve, 700)); - setIsPending(false); - if (failAt === step || (step === 'verify' && submittedCode === '000000')) { - setErrorMessage( - step === 'phone' ? 'We couldn’t send a code. Try again.' : 'That code is incorrect. Try again.', - ); - return; - } - if (step === 'phone') { - setStep('verify'); - setResendSeconds(12); - } else { - onVerified?.(phoneNumber); - setOpen(false); + if (failAt === 'phone') { + throw new Error('We couldn’t send a code. Try again.'); } }, - onResend: async () => { - if (isPending || isResending || resendSeconds > 0) { - return; - } - setIsResending(true); - setErrorMessage(undefined); + onVerify: async (phoneNumber, code) => { await new Promise(resolve => setTimeout(resolve, 700)); - setIsResending(false); - setCode(''); - setResendSeconds(12); + if (failAt === 'verify' || code === '000000') { + throw new Error('That code is incorrect. Try again.'); + } + onVerified?.(phoneNumber); }, - }; + }); } diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-phone.controller.test.ts b/packages/ui/src/mosaic/user-profile/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-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-add-phone.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-add-phone.controller.ts new file mode 100644 index 00000000000..fa94fbf50a6 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-add-phone.controller.ts @@ -0,0 +1,158 @@ +import { useEffect } from 'react'; + +import { setup } from '../machine/setup'; +import { useMachine } from '../machine/useMachine'; +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 : 'Something went wrong. Please try again.'; +} + +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' }), + }; +} From ed3cdd7b0c66cbedc09562613f73302b7e25f1ec Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 12:15:26 -0600 Subject: [PATCH 09/13] refactor(ui): mount add phone flow in account section --- .../fixtures/user-profile-add-phone.ts | 11 ++-- .../user-profile-account-section.stories.tsx | 56 ++++++++--------- ...ser-profile-add-phone.integration.test.tsx | 39 ++++++++++++ .../user-profile-account-section.view.tsx | 63 +++++++++++++++++-- .../user-profile-profile-panel.view.tsx | 2 + 5 files changed, 133 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx 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 4162c7d2d73..4f8ea4f1155 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts @@ -1,4 +1,4 @@ -import { useUserProfileAddPhoneController } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.controller'; +import type { UserProfileAddPhoneControllerOptions } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.controller'; import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.view'; interface FixtureOptions { @@ -6,8 +6,11 @@ interface FixtureOptions { onVerified?: (phoneNumber: string) => void; } -export function useUserProfileAddPhoneFixture({ failAt, onVerified }: FixtureOptions = {}) { - return useUserProfileAddPhoneController({ +export function createUserProfileAddPhoneFixture({ + failAt, + onVerified, +}: FixtureOptions = {}): UserProfileAddPhoneControllerOptions { + return { initialPhoneNumber: '+18015558181', onSend: async () => { await new Promise(resolve => setTimeout(resolve, 700)); @@ -22,5 +25,5 @@ export function useUserProfileAddPhoneFixture({ failAt, onVerified }: FixtureOpt } onVerified?.(phoneNumber); }, - }); + }; } 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 ff058b60382..4b620685c23 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -5,13 +5,12 @@ import type { } 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-add-phone.view'; -import { UserProfileAddPhoneView } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.view'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; -import { useUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; +import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; export { default as __source } from './user-profile-account-section.stories?raw'; @@ -48,39 +47,36 @@ function AccountSection({ ...(allowMultipleAccounts ? [{ id: 'phone_2', value: '+18015550100', isVerified: true }] : []), ]); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); - const addPhone = useUserProfileAddPhoneFixture({ + const addPhone = createUserProfileAddPhoneFixture({ failAt, onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), }); return ( - <> - - setEmails(current => [ - ...current, - { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, - ]) - } - onAddPhone={() => addPhone.onOpenChange(true)} - onProfilePictureChange={showFile} - onRemoveProfilePicture={clearImage} - onManageEmail={() => undefined} - onManagePhone={() => undefined} - onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} - onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} - onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} - onUsernameChange={() => undefined} - /> - - + + setEmails(current => [ + ...current, + { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, + ]) + } + addPhone={addPhone} + onProfilePictureChange={showFile} + onRemoveProfilePicture={clearImage} + onManageEmail={() => undefined} + onManagePhone={() => undefined} + onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} + onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} + onUsernameChange={() => 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..e7b6f11ade0 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx @@ -0,0 +1,39 @@ +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.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/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 a7c08ba0791..953aa7bfacc 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,6 +1,7 @@ import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; import { useMemo, useRef, useState } from 'react'; import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; @@ -13,6 +14,9 @@ import { Section } from '../../components/section'; import { Text } from '../../components/text'; import type { UserProfileMenuAction } from '../user-profile-action-menu'; import { UserProfileActionMenu } from '../user-profile-action-menu'; +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 { styles } from '../user-profile-profile-panel.styles'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; @@ -76,6 +80,7 @@ export interface UserProfileAccountSectionViewProps { onSetPrimaryEmail?: (id: string) => void; onRemoveEmail?: (id: string) => void; onAddPhone?: () => void; + addPhone?: UserProfileAddPhoneControllerOptions; onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; onSetPrimaryPhone?: (id: string) => void | Promise; @@ -105,11 +110,18 @@ export function UserProfileAccountSectionView({ onSetPrimaryEmail, onRemoveEmail, onAddPhone, + addPhone, onManagePhone, onVerifyPhone, onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const addPhoneAction = addPhone ? ( + + ) : undefined; const sectionRef = useRef(null); const removeConfirm = useMemo(() => createConfirmHandle(), []); const [phoneToRemove, setPhoneToRemove] = useState(); @@ -280,6 +292,7 @@ export function UserProfileAccountSectionView({ items={formattedPhones} kind='phone' label={m.phone.label} + addAction={addPhoneAction} onAdd={onAddPhone} onManage={onManagePhone} /> @@ -303,6 +316,7 @@ export function UserProfileAccountSectionView({ items={formattedPhones} kind='phone' label={m.phone.label} + addAction={addPhoneAction} onAdd={onAddPhone} onManage={isSettingPrimary ? undefined : onManagePhone} onRemove={onRemovePhone ? id => void removePhone(id) : undefined} @@ -433,7 +447,34 @@ function EditName({ ); } +function AddPhone({ options, compact }: { options: UserProfileAddPhoneControllerOptions; compact: boolean }) { + const controller = useUserProfileAddPhoneController(options); + return ( + + {compact ? ( + + ) : null} + {compact ? m.add : m.phone.add} + + } + /> + ); +} + interface ContactSectionProps { + addAction?: ReactNode; kind: 'email' | 'phone'; label: string; items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; @@ -454,7 +495,7 @@ function ContactSection(props: ContactSectionProps) { ); } -function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) { +function SingleContactRow({ kind, label, items, onAdd, onManage, addAction }: ContactSectionProps) { const item = items[0]; const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; const emptyDescription = m[kind].empty; @@ -474,7 +515,9 @@ function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectio {emptyDescription} )} - {onClick ? ( + {!item && addAction ? ( + {addAction} + ) : onClick ? (