Skip to content

Commit dbd0823

Browse files
feat(mosaic): hold the email and phone menus open while a verification code is sent
1 parent 44e7549 commit dbd0823

9 files changed

Lines changed: 94 additions & 23 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface UserProfileContactListRowViewProps {
2222
items: UserProfileContact[];
2323
onAdd?: () => void;
2424
onVerify?: (id: string) => void;
25+
pendingVerifyId?: string;
2526
onSetPrimary?: (id: string) => void;
2627
onRemove?: (item: UserProfileContact) => void;
2728
children?: ReactNode;
@@ -33,6 +34,7 @@ export function UserProfileContactListRowView({
3334
items,
3435
onAdd,
3536
onVerify,
37+
pendingVerifyId,
3638
onSetPrimary,
3739
onRemove,
3840
addAction,
@@ -89,6 +91,7 @@ export function UserProfileContactListRowView({
8991
actions.push({
9092
label: item.isDefault ? m.completeVerification : m[kind].verify,
9193
onClick: () => onVerify(item.id),
94+
pending: pendingVerifyId === item.id,
9295
});
9396
} else if (item.isVerified && !item.isDefault && onSetPrimary) {
9497
actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) });

‎packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,18 +192,27 @@ describe('useUserProfileAddEmailController', () => {
192192
expect(email.verifyCode).toHaveBeenCalledExactlyOnceWith('123456');
193193
});
194194

195-
it('verifies an existing email without creating one', async () => {
195+
it('verifies an existing email without creating one, opening once its code is sent', async () => {
196196
const onCreate = vi.fn(created);
197-
const email = verifier();
197+
const sent = Promise.withResolvers<void>();
198+
const email = verifier(() => ({ method: 'code', sent: sent.promise }));
198199
const { result } = renderHook(() =>
199200
useUserProfileAddEmailController({ reverification: idleReverification, onCreate }),
200201
);
201202

202203
act(() => result.current.onVerifyEmail(other, email));
204+
expect(email.start).toHaveBeenCalledOnce();
205+
expect(result.current.open).toBe(false);
206+
expect(result.current.pendingVerifyId).toBe('email_2');
207+
208+
await act(async () => {
209+
sent.resolve();
210+
await sent.promise;
211+
});
203212
expect(result.current.open).toBe(true);
213+
expect(result.current.pendingVerifyId).toBeUndefined();
204214
expect(result.current.step).toBe('verify');
205215
expect(result.current.emailAddress).toBe('other@example.com');
206-
await waitFor(() => expect(email.start).toHaveBeenCalledOnce());
207216

208217
act(() => result.current.onSubmit('123456'));
209218
await waitFor(() => expect(result.current.open).toBe(false));

‎packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ describe('the user profile email section', () => {
114114
);
115115
});
116116

117+
it('holds the menu open with a spinner on Verify until the code is sent', async () => {
118+
const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' });
119+
const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending]));
120+
const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification');
121+
122+
try {
123+
await manageEmail(actor, 'pending@example.com', 'Verify');
124+
await waitFor(() => expect(prepare.requests).toHaveLength(1));
125+
expect(screen.getByRole('menu')).toBeVisible();
126+
expect(screen.getByRole('menuitem', { name: 'Verify' })).toHaveAttribute('aria-busy', 'true');
127+
expect(await screen.findByRole('progressbar', { name: 'Verify' })).toBeVisible();
128+
expect(screen.queryByRole('dialog')).toBeNull();
129+
} finally {
130+
prepare.release();
131+
}
132+
133+
await enterCode(actor, VERIFICATION_CODE);
134+
expect(screen.queryByRole('menu')).toBeNull();
135+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
136+
});
137+
117138
it('waits for the emailed link when the instance verifies by link, then closes once it is opened', async () => {
118139
const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' });
119140
const { actor, fapi } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink));

‎packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface UserProfileAddEmailControllerOptions {
2929
export interface UserProfileAddEmailController extends UserProfileAddEmailDialogProps {
3030
handle: DialogHandle<unknown>;
3131
verifyingId: string | undefined;
32+
pendingVerifyId: string | undefined;
3233
onVerifyEmail: (email: Pick<UserProfileEmail, 'id' | 'value'>, verifier: UserProfileEmailVerifier) => void;
3334
}
3435

@@ -39,6 +40,7 @@ interface Context {
3940
verification: UserProfileEmailVerification | undefined;
4041
error: FormError | undefined;
4142
resendSeconds: number;
43+
held: boolean;
4244
}
4345

4446
type Event =
@@ -75,9 +77,20 @@ const start = {
7577
verification: undefined,
7678
error: undefined,
7779
resendSeconds: 0,
80+
held: false,
7881
})),
7982
};
8083

84+
const sendCode = {
85+
invoke: fromPromise(
86+
(context: Context) => (context.verification?.method === 'code' ? context.verification.sent : missingDependency()),
87+
{
88+
onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS, held: false })) },
89+
onError: { target: 'verify', actions: [fail, assign(() => ({ held: false }))] },
90+
},
91+
),
92+
};
93+
8194
const machine = createMachine({
8295
id: 'addEmail',
8396
initial: 'idle',
@@ -88,6 +101,7 @@ const machine = createMachine({
88101
verification: undefined,
89102
error: undefined,
90103
resendSeconds: 0,
104+
held: false,
91105
},
92106
states: {
93107
idle: {
@@ -102,7 +116,7 @@ const machine = createMachine({
102116
resendSeconds: 0,
103117
})),
104118
},
105-
START: start,
119+
START: { ...start, actions: [start.actions, assign(() => ({ held: true }))] },
106120
},
107121
},
108122
email: {
@@ -116,23 +130,16 @@ const machine = createMachine({
116130
always: ({ context }) => {
117131
const method = context.verification?.method;
118132
if (method === 'code') {
119-
return { target: 'sending' };
133+
return { target: context.held ? 'preparing' : 'sending' };
120134
}
121135
if (method === 'sso') {
122136
return { target: 'connecting' };
123137
}
124138
return { target: 'waiting', context: { resendSeconds: method === 'link' ? LINK_RESEND_SECONDS : 0 } };
125139
},
126140
},
127-
sending: {
128-
invoke: fromPromise(
129-
context => (context.verification?.method === 'code' ? context.verification.sent : missingDependency()),
130-
{
131-
onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) },
132-
onError: { target: 'verify', actions: fail },
133-
},
134-
),
135-
},
141+
preparing: sendCode,
142+
sending: sendCode,
136143
waiting: {
137144
on: { CANCEL: 'idle', TICK: tick, RESEND: resend },
138145
invoke: fromPromise(
@@ -194,7 +201,7 @@ export function useUserProfileAddEmailController({
194201
},
195202
});
196203
const state = snapshot.value;
197-
const open = state !== 'idle';
204+
const open = state !== 'idle' && state !== 'preparing';
198205
const waitingFor = state === 'waiting' && verification?.method === 'link' ? verification : undefined;
199206
useEffect(() => waitingFor?.cancel, [waitingFor]);
200207
useEffect(() => {
@@ -260,6 +267,7 @@ export function useUserProfileAddEmailController({
260267
},
261268
onConnect: () => send({ type: 'CONNECT' }),
262269
verifyingId: snapshot.context.verifyingId,
270+
pendingVerifyId: state === 'preparing' ? snapshot.context.verifyingId : undefined,
263271
onVerifyEmail: (email, verifier) => {
264272
codeForm.reset();
265273
send({ type: 'START', id: email.id, emailAddress: email.value, verifier });

‎packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function UserProfileEmailSectionView({
5959
onRemove={remove ? email => remove.open(email) : undefined}
6060
onSetPrimary={onSetPrimary}
6161
onVerify={onVerify}
62+
pendingVerifyId={verification.pendingVerifyId}
6263
>
6364
<Section.Error>{error}</Section.Error>
6465
</UserProfileContactListRowView>

‎packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,27 @@ describe('the user profile phone section', () => {
155155
await waitFor(() => expect(within(row()).getByText(WORK_LABEL).parentElement).not.toHaveTextContent('Unverified'));
156156
});
157157

158+
it('holds the menu open with a spinner on Verify until the code is sent', async () => {
159+
const pending = fapiPhoneNumber({ id: 'idn_new', phone_number: '+18015550199' });
160+
const { actor } = await renderSection(signedIn([HOME, pending]));
161+
const prepare = holdRequests('post', '/v1/me/phone_numbers/:id/prepare_verification');
162+
163+
try {
164+
await manage(actor, WORK_LABEL, 'Verify phone number');
165+
await waitFor(() => expect(prepare.requests).toHaveLength(1));
166+
expect(screen.getByRole('menu')).toBeVisible();
167+
expect(screen.getByRole('menuitem', { name: 'Verify phone number' })).toHaveAttribute('aria-busy', 'true');
168+
expect(await screen.findByRole('progressbar', { name: 'Verify phone number' })).toBeVisible();
169+
expect(screen.queryByRole('dialog')).toBeNull();
170+
} finally {
171+
prepare.release();
172+
}
173+
174+
await enterCode(actor, VERIFICATION_CODE);
175+
expect(screen.queryByRole('menu')).toBeNull();
176+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
177+
});
178+
158179
it('makes another verified number the primary one', async () => {
159180
const { actor } = await renderSection(signedIn([HOME, WORK]));
160181

‎packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface UserProfileAddPhoneControllerOptions {
2323
export interface UserProfileAddPhoneController extends UserProfileAddPhoneDialogProps {
2424
handle: DialogHandle<unknown>;
2525
verifyingId: string | undefined;
26+
pendingVerifyId: string | undefined;
2627
onVerifyPhone: (phone: Pick<UserProfilePhone, 'id' | 'value'>, verifier: UserProfilePhoneVerifier) => void;
2728
}
2829

@@ -63,6 +64,13 @@ const start = {
6364
})),
6465
};
6566

67+
const sendCode = {
68+
invoke: fromPromise((context: Context) => (context.verifier ? context.verifier.sendCode() : missingDependency()), {
69+
onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) },
70+
onError: { target: 'verify', actions: fail },
71+
}),
72+
};
73+
6674
const machine = createMachine({
6775
id: 'addPhone',
6876
initial: 'idle',
@@ -80,18 +88,14 @@ const machine = createMachine({
8088
target: 'phone',
8189
actions: assign(() => ({ verifyingId: undefined, verifier: undefined, error: undefined, resendSeconds: 0 })),
8290
},
83-
START: start,
91+
START: { ...start, target: 'preparing' },
8492
},
8593
},
8694
phone: {
8795
on: { CANCEL: 'idle', START: start },
8896
},
89-
sending: {
90-
invoke: fromPromise(context => (context.verifier ? context.verifier.sendCode() : missingDependency()), {
91-
onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) },
92-
onError: { target: 'verify', actions: fail },
93-
}),
94-
},
97+
preparing: sendCode,
98+
sending: sendCode,
9599
verify: {
96100
on: {
97101
CANCEL: 'idle',
@@ -134,7 +138,7 @@ export function useUserProfileAddPhoneController({
134138
},
135139
});
136140
const state = snapshot.value;
137-
const open = state !== 'idle';
141+
const open = state !== 'idle' && state !== 'preparing';
138142
useEffect(() => {
139143
if (!open || resendSeconds === 0) {
140144
return;
@@ -195,6 +199,7 @@ export function useUserProfileAddPhoneController({
195199
}
196200
},
197201
verifyingId: snapshot.context.verifyingId,
202+
pendingVerifyId: state === 'preparing' ? snapshot.context.verifyingId : undefined,
198203
onVerifyPhone: (phone, verifier) => {
199204
codeForm.reset();
200205
send({ type: 'START', id: phone.id, phoneNumber: phone.value, verifier });

‎packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export function UserProfilePhoneSectionView({
6060
onRemove={remove ? phone => remove.open(phone) : undefined}
6161
onSetPrimary={onSetPrimary}
6262
onVerify={onVerify}
63+
pendingVerifyId={verification.pendingVerifyId}
6364
>
6465
<Section.Error>{error}</Section.Error>
6566
</UserProfileContactListRowView>

0 commit comments

Comments
 (0)