fix(ux): confirm + feedback for destructive bulk actions
Resolves the Critical destructive-action safety gap (and the CSRF-adjacent risk of frictionless Gmail mutation): - Bulk Trash now requires an explicit confirmation dialog (count + 30-day-recovery note) instead of firing on one click. - Every bulk action surfaces a success / partial-failure / error toast. - Rows are removed from the list only when the server confirms the whole batch succeeded; partial failures leave the list intact so the user can retry, instead of optimistically hiding failed items. Built on the new Dialog/Toast primitives (also restyles the toolbar to the new system). Real cross-session Undo is deferred to the activity-log backend (specced). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react';
|
||||
import { BulkApi } from '../api/client.js';
|
||||
import {
|
||||
Button, useToast,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
|
||||
} from './ui';
|
||||
|
||||
/// <summary>
|
||||
/// Toolbar shown above an email list when one or more rows are selected.
|
||||
/// `onDone` is called with the action key after a successful bulk call so the
|
||||
/// caller can update local state (e.g. remove trashed/archived rows).
|
||||
/// </summary>
|
||||
/**
|
||||
* Toolbar shown above an email list when rows are selected.
|
||||
*
|
||||
* Safety (Phase 4 / UX-Critical): the destructive Trash action now requires an
|
||||
* explicit confirmation dialog, every action surfaces success/partial-failure via
|
||||
* a toast, and rows are only removed from the list when the server confirms the
|
||||
* whole batch succeeded (no more optimistic removal that hides failures).
|
||||
* `onDone(action, ids)` is called only on full success so the caller can prune state.
|
||||
*/
|
||||
export default function BulkToolbar({ selectedIds, onDone, onClear }) {
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmTrash, setConfirmTrash] = useState(false);
|
||||
const count = selectedIds.length;
|
||||
if (count === 0) return null;
|
||||
|
||||
const run = (fn, action) => async () => {
|
||||
await fn(selectedIds);
|
||||
onDone(action, selectedIds);
|
||||
onClear();
|
||||
const apply = async (fn, action, label) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn(selectedIds);
|
||||
// CleanupResultDto: { succeededCount, failedCount, errors }
|
||||
const ok = res?.succeededCount ?? count;
|
||||
const failed = res?.failedCount ?? 0;
|
||||
if (failed > 0) {
|
||||
toast({
|
||||
variant: 'warning',
|
||||
title: `${label}: ${ok} done, ${failed} failed`,
|
||||
description: 'Some items could not be updated — the list was left unchanged so you can retry.',
|
||||
});
|
||||
} else {
|
||||
toast({ variant: 'success', title: `${label} ${ok} email${ok === 1 ? '' : 's'}` });
|
||||
onDone(action, selectedIds);
|
||||
onClear();
|
||||
}
|
||||
} catch {
|
||||
toast({ variant: 'danger', title: `Couldn't ${label.toLowerCase()} ${count} email${count === 1 ? '' : 's'}`, description: 'Please try again.' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirmTrash(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bulk-toolbar">
|
||||
<span>{count} selected</span>
|
||||
<div className="bulk-toolbar-spacer" />
|
||||
<button className="bulk-btn" onClick={run(BulkApi.markRead, 'read')}>Mark read</button>
|
||||
<button className="bulk-btn" onClick={run(BulkApi.markUnread, 'unread')}>Mark unread</button>
|
||||
<button className="bulk-btn" onClick={run(BulkApi.star, 'star')}>Star</button>
|
||||
<button className="bulk-btn" onClick={run(BulkApi.archive, 'archive')}>Archive</button>
|
||||
<button className="bulk-btn bulk-btn--danger" onClick={run(BulkApi.trash, 'trash')}>Trash</button>
|
||||
<button className="bulk-btn" onClick={onClear}>Cancel</button>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm">
|
||||
<span className="text-sm font-medium">{count} selected</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
|
||||
<MailOpen /> Read
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markUnread, 'unread', 'Marked unread')}>
|
||||
<Mail /> Unread
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.star, 'star', 'Starred')}>
|
||||
<Star /> Star
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.archive, 'archive', 'Archived')}>
|
||||
<Archive /> Archive
|
||||
</Button>
|
||||
<Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
|
||||
<Trash2 /> Trash
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={onClear}>Cancel</Button>
|
||||
|
||||
<Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move {count} email{count === 1 ? '' : 's'} to Trash?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This moves the selected mail to your Gmail Trash, where it stays recoverable
|
||||
for 30 days before Gmail permanently deletes it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" size="sm" disabled={busy}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="danger" size="sm" disabled={busy} onClick={() => apply(BulkApi.trash, 'trash', 'Trashed')}>
|
||||
{busy ? 'Moving…' : 'Move to Trash'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user