Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-profile-email-wireup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ interface FakeAttribute {
used_for_second_factor: boolean;
}

interface FakeVerification {
status: string | null;
expireAt?: Date | null;
}

let isUserLoaded: boolean;
let user: {
firstName: string | null;
Expand All @@ -29,8 +34,13 @@ let user: {
}[];
primaryEmailAddressId: string | null;
primaryPhoneNumberId: string | null;
emailAddresses: { id: string; emailAddress: string; verification: { status: string | null } }[];
phoneNumbers: { id: string; phoneNumber: string; verification: { status: string | null } }[];
emailAddresses: {
id: string;
emailAddress: string;
verification: FakeVerification;
destroy?: ReturnType<typeof vi.fn>;
}[];
phoneNumbers: { id: string; phoneNumber: string; verification: FakeVerification }[];
setProfileImage: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
} | null;
Expand Down Expand Up @@ -109,8 +119,18 @@ beforeEach(() => {
primaryEmailAddressId: 'email_1',
primaryPhoneNumberId: null,
emailAddresses: [
{ id: 'email_2', emailAddress: 'other@clerk.dev', verification: { status: null } },
{ id: 'email_1', emailAddress: 'preston@clerk.dev', verification: { status: 'verified' } },
{
id: 'email_2',
emailAddress: 'other@clerk.dev',
verification: { status: null },
destroy: vi.fn(() => Promise.resolve()),
},
{
id: 'email_1',
emailAddress: 'preston@clerk.dev',
verification: { status: 'verified' },
destroy: vi.fn(() => Promise.resolve()),
},
],
phoneNumbers: [{ id: 'phone_1', phoneNumber: '+18018888181', verification: { status: 'verified' } }],
setProfileImage: vi.fn(() => Promise.resolve({})),
Expand Down Expand Up @@ -153,6 +173,50 @@ describe('useUserProfileAccountSectionModel', () => {
});
});

it('orders contacts primary first, then verified, then pending by expiry, then never started', () => {
if (!user) {
throw new Error('expected a user');
}
user.primaryEmailAddressId = 'email_primary';
user.primaryPhoneNumberId = 'phone_primary';
user.emailAddresses = [
{ id: 'email_unstarted', emailAddress: 'd@clerk.dev', verification: { status: null } },
{
id: 'email_late',
emailAddress: 'e@clerk.dev',
verification: { status: 'unverified', expireAt: new Date(2000) },
},
{ id: 'email_verified_b', emailAddress: 'b@clerk.dev', verification: { status: 'verified' } },
{
id: 'email_primary',
emailAddress: 'a@clerk.dev',
verification: { status: 'unverified', expireAt: new Date(3000) },
},
{
id: 'email_early',
emailAddress: 'f@clerk.dev',
verification: { status: 'unverified', expireAt: new Date(1000) },
},
{ id: 'email_verified_a', emailAddress: 'c@clerk.dev', verification: { status: 'verified' } },
];
user.phoneNumbers = [
{ id: 'phone_unstarted', phoneNumber: '+18018888183', verification: { status: null } },
{ id: 'phone_verified', phoneNumber: '+18018888182', verification: { status: 'verified' } },
{ id: 'phone_primary', phoneNumber: '+18018888181', verification: { status: 'verified' } },
];
const model = ready();

expect(model.emails?.map(email => email.id)).toEqual([
'email_primary',
'email_verified_a',
'email_verified_b',
'email_early',
'email_late',
'email_unstarted',
]);
expect(model.phones?.map(phone => phone.id)).toEqual(['phone_primary', 'phone_verified', 'phone_unstarted']);
});

it('leaves out the contacts the instance does not collect', () => {
attributes.email_address = attribute({ enabled: false });
attributes.phone_number = attribute({ enabled: false });
Expand All @@ -162,6 +226,40 @@ describe('useUserProfileAccountSectionModel', () => {
expect(model.phones).toBeUndefined();
});

describe('emails', () => {
it('sets the chosen email as primary', async () => {
await ready().onSetPrimaryEmail?.('email_2');
expect(user?.update).toHaveBeenCalledWith({ primaryEmailAddressId: 'email_2' });
});

it('returns the API message when setting the primary fails', async () => {
user?.update.mockRejectedValue(apiError());
await expect(rejection(ready().onSetPrimaryEmail?.('email_2'))).resolves.toEqual({
global: { code: 'form_param_invalid', message: 'That value is invalid.' },
});
});

it('removes the chosen email', async () => {
await ready().onRemoveEmail?.('email_2');
expect(user?.emailAddresses[0]?.destroy).toHaveBeenCalled();
expect(user?.emailAddresses[1]?.destroy).not.toHaveBeenCalled();
});

it('returns the API message when removing fails', async () => {
user?.emailAddresses[0]?.destroy?.mockRejectedValue(apiError());
await expect(rejection(ready().onRemoveEmail?.('email_2'))).resolves.toEqual({
global: { code: 'form_param_invalid', message: 'That value is invalid.' },
});
});

it('still offers a new primary but no removal when the email address is immutable', () => {
attributes.email_address = attribute({ immutable: true });
const model = ready();
expect(model.onSetPrimaryEmail).toBeDefined();
expect(model.onRemoveEmail).toBeUndefined();
});
});

describe('profile picture', () => {
it('uploads the picked file', async () => {
const file = new File(['x'], 'me.png', { type: 'image/png' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,44 @@ describe('UserProfileContactListRowView', () => {
it.each(['email', 'phone'] as const)('hides the menu when no %s action applies', kind => {
const onVerify = vi.fn();
const onSetPrimary = vi.fn();
const onRemove = vi.fn();
const item = { id: 'contact_1', value: 'Contact', isDefault: true, isVerified: true, canRemove: false };
const item = { id: 'contact_1', value: 'Contact', isDefault: true, isVerified: true };
render(
<UserProfileContactListRowView
kind={kind}
label='Contacts'
items={[item]}
onVerify={onVerify}
onSetPrimary={onSetPrimary}
onRemove={onRemove}
/>,
);

expect(screen.queryByRole('button', { name: 'Manage Contact' })).not.toBeInTheDocument();
});

it.each(['email', 'phone'] as const)('marks an unverified %s', kind => {
render(
<UserProfileContactListRowView
kind={kind}
label='Contacts'
items={[
{ id: 'contact_1', value: 'Verified contact', isDefault: false, isVerified: true },
{ id: 'contact_2', value: 'Pending contact', isDefault: false, isVerified: false },
]}
/>,
);

expect(screen.getAllByText('Unverified')).toHaveLength(1);
expect(screen.getByText('Pending contact').parentElement).toHaveTextContent('Unverified');
});

it('offers removal when it applies', async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
render(
<UserProfileContactListRowView
kind='phone'
label='Phones'
items={[{ id: 'contact_1', value: 'Contact' }]}
items={[{ id: 'contact_1', value: 'Contact', isDefault: false, isVerified: true }]}
onRemove={onRemove}
/>,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ describe('contact removal focus', () => {
const labels = kind === 'email' ? values : ['+1 (801) 555-0100', '+1 (801) 555-0200'];

function Example() {
const [items, setItems] = useState(values.map(value => ({ id: value, value })));
const [items, setItems] = useState(
values.map(value => ({ id: value, value, isDefault: false, isVerified: true })),
);
const remove = (id: string) => setItems(current => current.filter(item => item.id !== id));
return (
<MosaicProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function renderEmail(overrides: Partial<UserProfileAccountSectionViewProps> = {}
name='Test'
username='test'
phones={[]}
emails={[{ id: 'email_1', value: 'test@example.com', isVerified: true }]}
emails={[{ id: 'email_1', value: 'test@example.com', isDefault: false, isVerified: true }]}
{...overrides}
/>
</MosaicProvider>,
Expand Down Expand Up @@ -45,7 +45,9 @@ describe('email actions', () => {
it('focuses Add email after removing the last email', async () => {
const user = userEvent.setup();
function Example() {
const [emails, setEmails] = useState([{ id: 'email_1', value: 'test@example.com', isVerified: true }]);
const [emails, setEmails] = useState([
{ id: 'email_1', value: 'test@example.com', isDefault: false, isVerified: true },
]);
return (
<MosaicProvider>
<UserProfileAccountSectionView
Expand Down Expand Up @@ -105,4 +107,22 @@ describe('email actions', () => {
expect(onRemoveEmail).toHaveBeenNthCalledWith(1, 'email_1');
expect(onRemoveEmail).toHaveBeenNthCalledWith(2, 'email_1');
});
it.each([
[true, 'You won’t be able to use it to sign in.'],
[false, undefined],
])('warns about signing in only when removing a verified email (verified: %s)', async (isVerified, warning) => {
const user = userEvent.setup();
renderEmail({
emails: [{ id: 'email_1', value: 'test@example.com', isDefault: false, isVerified }],
onRemoveEmail: vi.fn(),
});
await user.click(screen.getByRole('button', { name: 'Manage test@example.com' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove email' }));
const dialog = screen.getByRole('alertdialog');
if (warning) {
expect(dialog).toHaveTextContent(warning);
} else {
expect(dialog).not.toHaveTextContent('sign in');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function renderPhone(overrides: Partial<UserProfileAccountSectionViewProps> = {}
name='Test'
username='test'
emails={[]}
phones={[{ id: 'phone_1', value: '+18015550100', isVerified: true }]}
phones={[{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }]}
{...overrides}
/>
</MosaicProvider>,
Expand Down Expand Up @@ -78,19 +78,19 @@ describe('phone actions', () => {
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.each([
{ isDefault: true, isVerified: true },
{ isDefault: false, isVerified: 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();
Expand Down Expand Up @@ -154,7 +154,9 @@ describe('phone actions', () => {
it('focuses Add phone number after removing the last phone', async () => {
const user = userEvent.setup();
function Example() {
const [phones, setPhones] = useState([{ id: 'phone_1', value: '+18015550100', isVerified: true }]);
const [phones, setPhones] = useState([
{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true },
]);
return (
<MosaicProvider>
<UserProfileAccountSectionView
Expand Down Expand Up @@ -200,9 +202,8 @@ describe('phone actions', () => {
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 }],
phones: [{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }],
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();
Expand All @@ -227,7 +228,7 @@ describe('phone actions', () => {
name='Test'
username='test'
emails={[]}
phones={[{ id: 'phone_1', value: '+18015550100', isVerified: true }]}
phones={[{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }]}
onRemovePhone={onRemovePhone}
/>
</MosaicProvider>,
Expand All @@ -241,4 +242,22 @@ describe('phone actions', () => {
expect(onRemovePhone).toHaveBeenCalledExactlyOnceWith('phone_1');
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
});
it.each([
[true, 'You won’t be able to use it to sign in.'],
[false, undefined],
])('warns about signing in only when removing a verified phone (verified: %s)', async (isVerified, warning) => {
const user = userEvent.setup();
renderPhone({
phones: [{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified }],
onRemovePhone: vi.fn(),
});
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');
if (warning) {
expect(dialog).toHaveTextContent(warning);
} else {
expect(dialog).not.toHaveTextContent('sign in');
}
});
});
Loading
Loading