Skip to content

Commit 829160f

Browse files
reeseherberReese Herberclaude
authored
feat: require typed confirmation for bulk document delete (#128)
Bulk delete of PENDING/DRAFT envelopes is a hard, unrecoverable delete. Require an Amazon-style typed confirmation ("Delete N documents") before the destructive button enables, so a mis-click on the bulk toolbar can no longer wipe documents. Confirmation phrase is localized via Lingui and pluralized for the selection count and envelope type. Closes #127 Co-authored-by: Reese Herber <herberr@ctxw1k73vf.peninsula.wednet.edu> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 60b22f6 commit 829160f

3 files changed

Lines changed: 113 additions & 23 deletions

File tree

apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import { useEffect } from 'react';
2+
3+
import { zodResolver } from '@hookform/resolvers/zod';
14
import { plural } from '@lingui/core/macro';
25
import { Plural, useLingui } from '@lingui/react/macro';
36
import { Trans } from '@lingui/react/macro';
47
import { EnvelopeType } from '@prisma/client';
58
import type * as DialogPrimitive from '@radix-ui/react-dialog';
9+
import { useForm } from 'react-hook-form';
10+
import { z } from 'zod';
611

712
import { trpc } from '@documenso/trpc/react';
813
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -15,6 +20,15 @@ import {
1520
DialogHeader,
1621
DialogTitle,
1722
} from '@documenso/ui/primitives/dialog';
23+
import {
24+
Form,
25+
FormControl,
26+
FormField,
27+
FormItem,
28+
FormLabel,
29+
FormMessage,
30+
} from '@documenso/ui/primitives/form/form';
31+
import { Input } from '@documenso/ui/primitives/input';
1832
import { useToast } from '@documenso/ui/primitives/use-toast';
1933

2034
export type EnvelopesBulkDeleteDialogProps = {
@@ -40,6 +54,31 @@ export const EnvelopesBulkDeleteDialog = ({
4054

4155
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
4256

57+
const confirmationMessage = isDocument
58+
? plural(envelopeIds.length, {
59+
one: 'Delete # document',
60+
other: 'Delete # documents',
61+
})
62+
: plural(envelopeIds.length, {
63+
one: 'Delete # template',
64+
other: 'Delete # templates',
65+
});
66+
67+
const ZBulkDeleteFormSchema = z.object({
68+
confirmation: z.literal(confirmationMessage, {
69+
errorMap: () => ({ message: t`You must type "${confirmationMessage}" to confirm` }),
70+
}),
71+
});
72+
73+
const form = useForm<z.infer<typeof ZBulkDeleteFormSchema>>({
74+
resolver: zodResolver(ZBulkDeleteFormSchema),
75+
defaultValues: {
76+
confirmation: '',
77+
},
78+
});
79+
80+
const isConfirmed = form.watch('confirmation') === confirmationMessage;
81+
4382
const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({
4483
onSuccess: async (result) => {
4584
// Invalidate the appropriate query based on envelope type.
@@ -84,6 +123,16 @@ export const EnvelopesBulkDeleteDialog = ({
84123
},
85124
});
86125

126+
useEffect(() => {
127+
if (!open) {
128+
form.reset();
129+
}
130+
}, [open, form]);
131+
132+
const onFormSubmit = async () => {
133+
await bulkDeleteEnvelopes({ envelopeIds });
134+
};
135+
87136
return (
88137
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
89138
<DialogContent>
@@ -148,27 +197,50 @@ export const EnvelopesBulkDeleteDialog = ({
148197
</AlertDescription>
149198
</Alert>
150199

151-
<DialogFooter>
152-
<Button
153-
type="button"
154-
variant="secondary"
155-
onClick={() => onOpenChange(false)}
156-
disabled={isPending}
157-
>
158-
<Trans>Cancel</Trans>
159-
</Button>
160-
161-
<Button
162-
onClick={(e) => {
163-
e.preventDefault();
164-
void bulkDeleteEnvelopes({ envelopeIds });
165-
}}
166-
loading={isPending}
167-
variant="destructive"
168-
>
169-
<Trans>Delete</Trans>
170-
</Button>
171-
</DialogFooter>
200+
<Form {...form}>
201+
<form onSubmit={form.handleSubmit(onFormSubmit)}>
202+
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
203+
<FormField
204+
control={form.control}
205+
name="confirmation"
206+
render={({ field }) => (
207+
<FormItem>
208+
<FormLabel>
209+
<Trans>
210+
Confirm by typing{' '}
211+
<span className="text-destructive font-semibold">{confirmationMessage}</span>
212+
</Trans>
213+
</FormLabel>
214+
<FormControl>
215+
<Input className="bg-background" autoComplete="off" {...field} />
216+
</FormControl>
217+
<FormMessage />
218+
</FormItem>
219+
)}
220+
/>
221+
222+
<DialogFooter>
223+
<Button
224+
type="button"
225+
variant="secondary"
226+
onClick={() => onOpenChange(false)}
227+
disabled={isPending}
228+
>
229+
<Trans>Cancel</Trans>
230+
</Button>
231+
232+
<Button
233+
type="submit"
234+
loading={isPending}
235+
disabled={!isConfirmed}
236+
variant="destructive"
237+
>
238+
<Trans>Delete</Trans>
239+
</Button>
240+
</DialogFooter>
241+
</fieldset>
242+
</form>
243+
</Form>
172244
</DialogContent>
173245
</Dialog>
174246
);

packages/app-tests/e2e/documents/bulk-document-actions.spec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,15 @@ test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) =>
130130
await expect(page.getByText('You are about to delete 2 documents')).toBeVisible();
131131
await expect(page.getByText('irreversible')).toBeVisible();
132132

133-
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
133+
const deleteButton = page.getByRole('dialog').getByRole('button', { name: 'Delete' });
134+
135+
// The destructive button stays disabled until the exact confirmation phrase is typed.
136+
await expect(deleteButton).toBeDisabled();
137+
138+
await page.getByRole('dialog').getByRole('textbox').fill('Delete 2 documents');
139+
await expect(deleteButton).toBeEnabled();
140+
141+
await deleteButton.click();
134142

135143
await expectToastTextToBeVisible(page, 'Documents deleted');
136144

@@ -173,6 +181,7 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
173181
await expect(page.getByText('1 selected')).toBeVisible();
174182

175183
await page.getByRole('button', { name: 'Delete' }).click();
184+
await page.getByRole('dialog').getByRole('textbox').fill('Delete 1 document');
176185
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
177186

178187
await expectToastTextToBeVisible(page, 'Documents deleted');

packages/app-tests/e2e/templates/bulk-template-actions.spec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,15 @@ test('[BULK_ACTIONS]: can delete multiple templates', async ({ page }) => {
132132
await expect(page.getByText('You are about to delete 2 templates')).toBeVisible();
133133
await expect(page.getByText('irreversible')).toBeVisible();
134134

135-
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
135+
const deleteButton = page.getByRole('dialog').getByRole('button', { name: 'Delete' });
136+
137+
// The destructive button stays disabled until the exact confirmation phrase is typed.
138+
await expect(deleteButton).toBeDisabled();
139+
140+
await page.getByRole('dialog').getByRole('textbox').fill('Delete 2 templates');
141+
await expect(deleteButton).toBeEnabled();
142+
143+
await deleteButton.click();
136144

137145
await expectToastTextToBeVisible(page, 'Templates deleted');
138146

@@ -175,6 +183,7 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
175183
await expect(page.getByText('1 selected')).toBeVisible();
176184

177185
await page.getByRole('button', { name: 'Delete' }).click();
186+
await page.getByRole('dialog').getByRole('textbox').fill('Delete 1 template');
178187
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
179188

180189
await expectToastTextToBeVisible(page, 'Templates deleted');

0 commit comments

Comments
 (0)