Skip to content
Open
2 changes: 2 additions & 0 deletions .changeset/heavy-pears-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
austincalvelage marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -467,6 +468,7 @@ const userProfileAccountSectionModule: StoryModule = {
meta: userProfileAccountSectionMeta,
Default: UserProfileAccountSectionDefault,
MultipleAccounts: UserProfileAccountSectionMultipleAccounts,
AddPhoneFails: UserProfileAccountSectionAddPhoneFails,
};
const userProfileProfilePanelModule: StoryModule = {
meta: userProfileProfilePanelMeta,
Expand Down
28 changes: 28 additions & 0 deletions packages/swingset/src/stories/fixtures/user-profile-add-phone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { UserProfileAccountSectionViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view';
import type { UserProfileAddPhoneDialogProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog';

interface FixtureOptions {
failAt?: UserProfileAddPhoneDialogProps['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);
},
};
}
13 changes: 4 additions & 9 deletions packages/swingset/src/stories/fixtures/user-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Story
Expand All @@ -29,3 +32,9 @@ Account details, profile image, email addresses, and phone numbers composed with
{ name: 'Icon', href: '/components/icon', layer: 'Components' },
]}
/>

## Add phone failure

Add a phone number to see a failed send request while keeping the entered number.

<Story name='AddPhoneFails' storyModule={Stories} />
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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';

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';

Expand All @@ -25,10 +27,12 @@ export const meta: StoryMeta = {

function AccountSection({
allowMultipleAccounts,
failAt,
failWith,
usernameFailWith,
}: {
allowMultipleAccounts: boolean;
failAt?: UserProfileAddPhoneDialogProps['step'];
failWith?: UserProfileFormError;
usernameFailWith?: UserProfileFormError;
}) {
Expand All @@ -44,8 +48,13 @@ function AccountSection({
);
const [phones, setPhones] = useState<UserProfilePhone[]>([
{ 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 = createUserProfileAddPhoneFixture({
failAt,
onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]),
});

return (
<UserProfileAccountSectionView
Expand All @@ -62,22 +71,14 @@ function AccountSection({
{ 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,
},
])
}
{...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}
onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))}
/>
);
}
Expand Down Expand Up @@ -114,3 +115,12 @@ export function EditUsernameFails() {
/>
);
}

export function AddPhoneFails() {
return (
<AccountSection
allowMultipleAccounts
failAt='phone'
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -75,16 +76,9 @@ export function Default(_args: Record<string, unknown>) {
{ 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}
Expand All @@ -98,7 +92,7 @@ export function Default(_args: Record<string, unknown>) {
onRemoveWeb3Wallet={() => undefined}
onSetPrimaryWeb3Wallet={() => undefined}
onSetPrimaryEmail={() => undefined}
onSetPrimaryPhone={() => undefined}
onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))}
onVerifyEmail={() => undefined}
onVerifyPhone={() => undefined}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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 { 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<UserProfileAddPhoneDialogProps> = {}) {
const props: UserProfileAddPhoneDialogProps = {
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(
<MosaicProvider>
<UserProfileAddPhoneDialog {...props} />
</MosaicProvider>,
),
};
}

function VerificationExample({ onSubmit }: Pick<UserProfileAddPhoneDialogProps, 'onSubmit'>) {
const [code, setCode] = useState('');

return (
<MosaicProvider>
<UserProfileAddPhoneDialog
open
onOpenChange={() => undefined}
step='verify'
phoneNumber='+18018888181'
onPhoneNumberChange={() => undefined}
code={code}
onCodeChange={setCode}
onSubmit={onSubmit}
onResend={() => undefined}
/>
</MosaicProvider>
);
}

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();
render(<VerificationExample onSubmit={onSubmit} />);
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(
<MosaicProvider>
<UserProfileAddPhoneDialog
{...props}
step='verify'
code='123456'
/>
</MosaicProvider>,
);

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' });
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(
<MosaicProvider>
<UserProfileAddPhoneDialog
{...props}
resendSeconds={0}
/>
</MosaicProvider>,
);
await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' }));
expect(props.onResend).toHaveBeenCalledOnce();

rerender(
<MosaicProvider>
<UserProfileAddPhoneDialog
{...props}
resendSeconds={0}
isResending
/>
</MosaicProvider>,
);
expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled();
});
});
Loading
Loading