Skip to content

Commit 593e00a

Browse files
committed
Add Reverification support to destructive block
1 parent 8a1d6f3 commit 593e00a

5 files changed

Lines changed: 298 additions & 54 deletions

File tree

‎.changeset/tall-words-type.md‎

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

‎packages/mosaic/src/blocks/destructive/destructive.test.tsx‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { MosaicProvider } from '../../MosaicProvider';
77
import type { DestructiveProps } from './destructive';
88
import { Destructive } from './destructive';
99

10+
vi.mock('../../features/reverification', () => ({
11+
Reverification: ({ phase }: { phase: string }) => <output data-testid='reverification'>{phase}</output>,
12+
}));
13+
1014
function renderBlock(overrides: Partial<DestructiveProps> = {}) {
1115
return render(
1216
<MosaicProvider>
@@ -26,6 +30,12 @@ function renderBlock(overrides: Partial<DestructiveProps> = {}) {
2630
}
2731

2832
const confirmButton = () => screen.getByRole('button', { name: 'Delete account' });
33+
const activeReverification = {
34+
phase: 'active' as const,
35+
complete: vi.fn(),
36+
cancel: vi.fn(),
37+
level: 'first_factor' as const,
38+
};
2939

3040
describe('Destructive', () => {
3141
it('renders nothing until the caller opens it', () => {
@@ -141,4 +151,65 @@ describe('Destructive', () => {
141151
await user.click(confirmButton());
142152
expect(onDelete).not.toHaveBeenCalled();
143153
});
154+
155+
it('shows reverification in the same dialog and card', () => {
156+
renderBlock({ isDeleting: true, reverification: activeReverification });
157+
158+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
159+
expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1);
160+
expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'verify');
161+
expect(screen.getByTestId('reverification')).toHaveTextContent('active');
162+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
163+
});
164+
165+
it('cancels active reverification when the dialog closes', async () => {
166+
const cancel = vi.fn();
167+
const onOpenChange = vi.fn();
168+
const user = userEvent.setup();
169+
renderBlock({
170+
isDeleting: true,
171+
onOpenChange,
172+
reverification: { ...activeReverification, cancel },
173+
});
174+
175+
await user.keyboard('{Escape}');
176+
177+
expect(cancel).toHaveBeenCalledOnce();
178+
expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything());
179+
});
180+
181+
it('keeps the retrying factor visible until the hook becomes inactive', () => {
182+
const props = {
183+
open: true,
184+
onOpenChange: vi.fn(),
185+
title: 'Delete account?',
186+
description: 'All of your data will be permanently deleted.',
187+
fieldLabel: 'Type “Delete account” below to continue',
188+
confirmationValue: 'Delete account',
189+
actionLabel: 'Delete account',
190+
onDelete: vi.fn(),
191+
};
192+
const { rerender } = render(
193+
<MosaicProvider>
194+
<Destructive
195+
{...props}
196+
isDeleting
197+
reverification={{ phase: 'retrying' }}
198+
/>
199+
</MosaicProvider>,
200+
);
201+
202+
rerender(
203+
<MosaicProvider>
204+
<Destructive
205+
{...props}
206+
errorMessage='Delete failed.'
207+
reverification={{ phase: 'inactive' }}
208+
/>
209+
</MosaicProvider>,
210+
);
211+
expect(document.querySelector('.cl-flow-root')).toHaveAttribute('data-value', 'confirm');
212+
expect(screen.getByRole('textbox')).toBeInTheDocument();
213+
expect(screen.getByText('Delete failed.')).toBeInTheDocument();
214+
});
144215
});

‎packages/mosaic/src/blocks/destructive/destructive.tsx‎

Lines changed: 87 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@ import { useEffect, useId, useState } from 'react';
33

44
import { Button, SubmitButton } from '../../components/button';
55
import { Card } from '../../components/card';
6-
import type { DialogTriggerProps } from '../../components/dialog';
6+
import type { DialogRootProps, DialogTriggerProps } from '../../components/dialog';
77
import { Dialog } from '../../components/dialog';
88
import { Field } from '../../components/field';
9+
import { Flow } from '../../components/flow';
910
import { Input } from '../../components/input';
11+
import { Reverification, type ReverificationProps } from '../../features/reverification';
1012

1113
export interface DestructiveProps {
1214
/** Whether the dialog is open */
1315
open: boolean;
1416
/** Callback when open state changes */
15-
onOpenChange: (open: boolean) => void;
17+
onOpenChange: NonNullable<DialogRootProps['onOpenChange']>;
1618
/** Element that opens the dialog */
1719
trigger?: DialogTriggerProps['render'];
1820
/** Dialog heading */
@@ -33,6 +35,7 @@ export interface DestructiveProps {
3335
isDeleting?: boolean;
3436
/** Error message to display if the delete action fails */
3537
errorMessage?: string;
38+
reverification?: ReverificationProps;
3639
}
3740

3841
/**
@@ -70,6 +73,7 @@ export function Destructive({
7073
onDelete,
7174
isDeleting = false,
7275
errorMessage,
76+
reverification,
7377
}: DestructiveProps) {
7478
const formId = useId();
7579
const [typedValue, setTypedValue] = useState('');
@@ -83,6 +87,7 @@ export function Destructive({
8387
}, [open]);
8488

8589
const isConfirmed = typedValue === confirmationValue;
90+
const step = reverification && reverification.phase !== 'inactive' ? 'verify' : 'confirm';
8691

8792
// The action sits in the footer, outside the form, so `form={formId}` associates the two.
8893
// That is what makes Enter in the field submit. Both guards are re-checked here because
@@ -95,65 +100,96 @@ export function Destructive({
95100
}
96101
};
97102

103+
const handleOpenChange: NonNullable<DialogRootProps['onOpenChange']> = (...args) => {
104+
const [nextOpen] = args;
105+
if (!nextOpen && reverification?.phase === 'active') {
106+
reverification.cancel();
107+
}
108+
onOpenChange(...args);
109+
};
110+
111+
const confirmation = (
112+
<>
113+
<Card.Header>
114+
<Card.Title>{title}</Card.Title>
115+
<Card.Description>{description}</Card.Description>
116+
</Card.Header>
117+
<Card.Content>
118+
<form
119+
id={formId}
120+
onSubmit={handleSubmit}
121+
>
122+
<Field.Root invalid={Boolean(errorMessage)}>
123+
<Field.Label>{fieldLabel}</Field.Label>
124+
<Input
125+
// Not a credential, so 1Password is told to leave it alone rather than
126+
// cover it with an autofill overlay.
127+
data-1p-ignore
128+
placeholder={confirmationValue}
129+
value={typedValue}
130+
disabled={isDeleting}
131+
onChange={event => setTypedValue(event.target.value)}
132+
/>
133+
<Field.Message>
134+
<Field.Error>{errorMessage}</Field.Error>
135+
</Field.Message>
136+
</Field.Root>
137+
</form>
138+
</Card.Content>
139+
<Card.Footer>
140+
<Dialog.Close
141+
render={
142+
<Button
143+
variant='outline'
144+
fullWidth
145+
>
146+
{cancelLabel}
147+
</Button>
148+
}
149+
/>
150+
<SubmitButton
151+
form={formId}
152+
fullWidth
153+
color='negative'
154+
isPending={isDeleting}
155+
disabled={!isConfirmed}
156+
focusableWhenDisabled
157+
>
158+
{actionLabel}
159+
</SubmitButton>
160+
</Card.Footer>
161+
</>
162+
);
163+
164+
const content = reverification ? (
165+
<Flow.Root
166+
value={step}
167+
direction={step === 'verify' ? 1 : -1}
168+
state={step}
169+
>
170+
{() => (
171+
<>
172+
<Flow.Step ids={['confirm']}>{confirmation}</Flow.Step>
173+
<Flow.Step ids={['verify']}>{reverification ? <Reverification {...reverification} /> : null}</Flow.Step>
174+
</>
175+
)}
176+
</Flow.Root>
177+
) : (
178+
confirmation
179+
);
180+
98181
return (
99182
<Dialog.Root
100183
open={open}
101-
onOpenChange={onOpenChange}
184+
onOpenChange={handleOpenChange}
102185
>
103186
{trigger ? <Dialog.Trigger render={trigger} /> : null}
104187
<Dialog.Popup variant='card'>
105188
<Card.Root
106189
elevation='overlay'
107190
renderBranding={false}
108191
>
109-
<Card.Header>
110-
<Card.Title>{title}</Card.Title>
111-
<Card.Description>{description}</Card.Description>
112-
</Card.Header>
113-
<Card.Content>
114-
<form
115-
id={formId}
116-
onSubmit={handleSubmit}
117-
>
118-
<Field.Root invalid={Boolean(errorMessage)}>
119-
<Field.Label>{fieldLabel}</Field.Label>
120-
<Input
121-
// Not a credential, so 1Password is told to leave it alone rather than
122-
// cover it with an autofill overlay.
123-
data-1p-ignore
124-
placeholder={confirmationValue}
125-
value={typedValue}
126-
disabled={isDeleting}
127-
onChange={event => setTypedValue(event.target.value)}
128-
/>
129-
<Field.Message>
130-
<Field.Error>{errorMessage}</Field.Error>
131-
</Field.Message>
132-
</Field.Root>
133-
</form>
134-
</Card.Content>
135-
<Card.Footer>
136-
<Dialog.Close
137-
render={
138-
<Button
139-
variant='outline'
140-
fullWidth
141-
>
142-
{cancelLabel}
143-
</Button>
144-
}
145-
/>
146-
<SubmitButton
147-
form={formId}
148-
fullWidth
149-
color='negative'
150-
isPending={isDeleting}
151-
disabled={!isConfirmed}
152-
focusableWhenDisabled
153-
>
154-
{actionLabel}
155-
</SubmitButton>
156-
</Card.Footer>
192+
{content}
157193
</Card.Root>
158194
</Dialog.Popup>
159195
</Dialog.Root>

0 commit comments

Comments
 (0)