notes: bulk-select + bulk-delete on Sent tab (task #472)

- Add `useBulkDeleteSentNotes` hook that fans out parallel DELETE
  /notes/:id calls via Promise.allSettled and returns {ok, failed}
  so the UI can render a precise toast on partial failure. Caches
  invalidated once via `invalidateNotesAndFolders`.
- NotesPage: page-level `sentSelectionMode` + `selectedSentIds`
  Set, auto-reset when navigating away from the Sent tab.
- Header gets a Select / Cancel toggle, gated on
  `view === "sent" && filteredSent.length > 0`.
- New sticky bulk-action bar above the sent list with count,
  select-all/clear toggle, and Delete button.
- SentList accepts optional selectionMode/selectedIds/onToggle
  props; cards swap onClick (open → toggle) and show a Checkbox
  overlay + rose ring when checked.
- AlertDialog confirms before deleting; toast reports all-success,
  partial, or all-failed using pluralized i18n keys.
- New i18n keys under `notes.bulk*` in ar.json + en.json.

No server changes (existing DELETE /notes/:id is reused).

Out of scope (per task spec): undo, bulk-archive, bulk endpoint,
multi-select on Active/Received/Archived tabs.
This commit is contained in:
riyadhafraa
2026-05-10 14:32:02 +00:00
parent 2a792f4747
commit d7a386cf75
4 changed files with 349 additions and 14 deletions
+27
View File
@@ -260,6 +260,33 @@ export function useDeleteNote() {
});
}
// Task #472: bulk-delete the caller's own (sent) notes by id. Reuses the
// existing per-note DELETE endpoint and fans out in parallel via
// `Promise.allSettled` so a single failed row doesn't block the rest.
// Caches are invalidated once at the end (not per row) to avoid N
// refetches. Returns the partition of ids that succeeded vs failed so
// the UI can render a precise toast.
export function useBulkDeleteSentNotes() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (ids: number[]) => {
const results = await Promise.allSettled(
ids.map((id) =>
req<{ success: true }>(`/notes/${id}`, { method: "DELETE" }),
),
);
const ok: number[] = [];
const failed: number[] = [];
results.forEach((r, i) => {
if (r.status === "fulfilled") ok.push(ids[i]);
else failed.push(ids[i]);
});
return { ok, failed };
},
onSettled: () => invalidateNotesAndFolders(qc),
});
}
export function useSendNote() {
const qc = useQueryClient();
return useMutation({
+14
View File
@@ -1104,6 +1104,20 @@
"itemCount": "{{count}} عنصراً",
"selectedCount": "تم تحديد {{count}}",
"deleteSelectedConfirm": "حذف الملاحظات المحددة؟",
"bulkSelect": "تحديد",
"bulkCancel": "إلغاء",
"bulkSelectAll": "تحديد الكل",
"bulkClear": "إلغاء التحديد",
"bulkCount": "{{n}} محدد",
"bulkDelete": "حذف",
"bulkConfirmTitle": "حذف {{n}} ملاحظة مرسلة؟",
"bulkConfirmTitle_one": "حذف ملاحظة مرسلة واحدة؟",
"bulkConfirmBody": "سيتم حذف الملاحظات وجميع ردودها نهائياً. لا يمكن التراجع.",
"bulkDeleting": "جارٍ الحذف...",
"bulkResultAll": "تم حذف {{n}} ملاحظة",
"bulkResultAll_one": "تم حذف ملاحظة واحدة",
"bulkResultPartial": "تم حذف {{ok}} من {{total}}، فشل {{failed}}",
"bulkResultNone": "تعذّر حذف الملاحظات المحددة",
"moreActions": "خيارات أخرى",
"empty": "ستظهر ملاحظاتك هنا",
"emptyArchived": "لا توجد ملاحظات في الأرشيف",
+14
View File
@@ -1001,6 +1001,20 @@
"itemCount": "{{count}} items",
"selectedCount": "{{count}} selected",
"deleteSelectedConfirm": "Delete the selected notes?",
"bulkSelect": "Select",
"bulkCancel": "Cancel",
"bulkSelectAll": "Select all",
"bulkClear": "Clear selection",
"bulkCount": "{{n}} selected",
"bulkDelete": "Delete",
"bulkConfirmTitle": "Delete {{n}} sent notes?",
"bulkConfirmTitle_one": "Delete 1 sent note?",
"bulkConfirmBody": "The notes and all their replies will be permanently deleted. This cannot be undone.",
"bulkDeleting": "Deleting...",
"bulkResultAll": "Deleted {{n}} notes",
"bulkResultAll_one": "Deleted 1 note",
"bulkResultPartial": "Deleted {{ok}} of {{total}}; {{failed}} failed",
"bulkResultNone": "Could not delete the selected notes",
"moreActions": "More actions",
"empty": "Your notes will appear here",
"emptyArchived": "No archived notes",
+294 -14
View File
@@ -83,6 +83,7 @@ import {
useCreateNote,
useDeleteLabel,
useDeleteNote,
useBulkDeleteSentNotes,
useMarkNoteRead,
useDeleteFolder,
useMoveNoteToFolder,
@@ -189,6 +190,36 @@ export default function NotesPage() {
}, []);
const clearSelected = useCallback(() => setSelectedIds(new Set()), []);
// Task #472: bulk-select + bulk-delete for the Sent tab. Lives at the
// page level (not inside SentList) so the toggle button in the header
// and the AlertDialog rendered next to the other dialogs can share
// state with the list. Selection is kept as a `Set<number>` for O(1)
// toggle/has lookups against potentially large sent lists.
const [sentSelectionMode, setSentSelectionMode] = useState(false);
const [selectedSentIds, setSelectedSentIds] = useState<Set<number>>(new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const bulkDeleteSent = useBulkDeleteSentNotes();
const toggleSentSelected = useCallback((id: number) => {
setSelectedSentIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const exitSentSelection = useCallback(() => {
setSentSelectionMode(false);
setSelectedSentIds(new Set());
setBulkDeleteOpen(false);
}, []);
// Whenever the user navigates away from the Sent tab, reset selection
// mode so stale ids don't leak across views and the toggle button
// doesn't render in the wrong tab.
useEffect(() => {
if (view !== "sent" && sentSelectionMode) {
exitSentSelection();
}
}, [view, sentSelectionMode, exitSentSelection]);
// Allow deep-links like /notes?thread=42&reply=1 (from the incoming-note
// popup) to jump into the inbox tab + thread dialog. Reactive on the
// current query string so this still works when the user is already on
@@ -379,16 +410,42 @@ export default function NotesPage() {
);
}, [receivedNotes, search]);
const filteredSent = useMemo(() => {
const filteredSent: SentNote[] = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return sentNotes;
return sentNotes.filter(
(n) =>
(n: SentNote) =>
n.title.toLowerCase().includes(q) ||
n.content.toLowerCase().includes(q),
);
}, [sentNotes, search]);
// Task #472 (architect fix): when the visible Sent set changes
// (search/filter, server refresh, or successful delete), prune the
// selection to currently visible ids so we never delete a hidden
// note. If the prune empties the visible list while in selection
// mode, drop out of selection mode entirely so the user isn't
// stranded without an in-view exit path.
const visibleSentIdsKey = filteredSent.map((n) => n.id).join(",");
useEffect(() => {
if (!sentSelectionMode) return;
if (filteredSent.length === 0) {
exitSentSelection();
return;
}
const visible = new Set(filteredSent.map((n) => n.id));
setSelectedSentIds((prev) => {
let changed = false;
const next = new Set<number>();
prev.forEach((id) => {
if (visible.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleSentIdsKey, sentSelectionMode, exitSentSelection]);
const unreadInboxCount = receivedNotes.filter((n) => n.status === "unread").length;
const pinned = filteredNotes.filter((n) => n.isPinned);
const others = filteredNotes.filter((n) => !n.isPinned);
@@ -514,6 +571,32 @@ export default function NotesPage() {
</div>
)}
<div className="ms-auto flex items-center gap-2">
{/* Task #472: bulk-select toggle for the Sent tab. Only shown
when there are sent notes to act on. Entering selection
mode swaps the cards' click behavior from "open thread"
to "toggle checkbox" and exposes the bulk action bar
below the header. */}
{view === "sent" && filteredSent.length > 0 && (
sentSelectionMode ? (
<button
type="button"
onClick={exitSentSelection}
data-testid="sent-bulk-cancel"
className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white/60 text-muted-foreground hover:text-foreground hover:bg-white transition-colors"
>
{t("notes.bulkCancel", "Cancel")}
</button>
) : (
<button
type="button"
onClick={() => setSentSelectionMode(true)}
data-testid="sent-bulk-select-toggle"
className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white/60 text-muted-foreground hover:text-foreground hover:bg-white transition-colors"
>
{t("notes.bulkSelect", "Select")}
</button>
)
)}
{view === "active" && folderSelection.kind === "all" && (
<div
className="inline-flex rounded-lg border border-slate-300/60 bg-white/60 p-0.5"
@@ -732,11 +815,77 @@ export default function NotesPage() {
)}
{view === "sent" && (
<SentList
loading={loadingSent}
notes={filteredSent}
onOpen={(id) => setOpenThreadId(id)}
/>
<>
{sentSelectionMode && (
<div
className="sticky top-0 z-10 -mx-2 px-3 py-2 mb-2 flex items-center gap-2 rounded-lg border border-slate-300/60 bg-white/95 backdrop-blur shadow-sm"
data-testid="sent-bulk-bar"
>
<span
className="text-sm font-medium text-foreground"
data-testid="sent-bulk-count"
>
{t("notes.bulkCount", "{{n}} selected", {
n: selectedSentIds.size,
})}
</span>
<button
type="button"
onClick={() => {
if (selectedSentIds.size === filteredSent.length) {
setSelectedSentIds(new Set());
} else {
setSelectedSentIds(
new Set(filteredSent.map((n) => n.id)),
);
}
}}
data-testid="sent-bulk-select-all"
className="text-sm px-2.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-slate-100"
>
{selectedSentIds.size === filteredSent.length &&
filteredSent.length > 0
? t("notes.bulkClear", "Clear selection")
: t("notes.bulkSelectAll", "Select all")}
</button>
<div className="ms-auto flex items-center gap-2">
{/* Always-visible Cancel inside the bar so the user
can leave selection mode even when the header
toggle is hidden (e.g., search yields 0 results
mid-selection). */}
<button
type="button"
onClick={exitSentSelection}
disabled={bulkDeleteSent.isPending}
data-testid="sent-bulk-bar-cancel"
className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
{t("notes.bulkCancel", "Cancel")}
</button>
<button
type="button"
onClick={() => setBulkDeleteOpen(true)}
disabled={
selectedSentIds.size === 0 || bulkDeleteSent.isPending
}
data-testid="sent-bulk-delete"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 size={14} />
{t("notes.bulkDelete", "Delete")}
</button>
</div>
</div>
)}
<SentList
loading={loadingSent}
notes={filteredSent}
onOpen={(id) => setOpenThreadId(id)}
selectionMode={sentSelectionMode}
selectedIds={selectedSentIds}
onToggle={toggleSentSelected}
/>
</>
)}
</div>
</div>
@@ -750,6 +899,96 @@ export default function NotesPage() {
/>
)}
{/* Task #472: confirmation for bulk-deleting selected sent notes.
Fires N parallel DELETE /notes/:id calls via Promise.allSettled
so partial failures still report a precise count. On success
the React Query "notes" key is invalidated by the mutation so
both Sent and Received tabs refresh. */}
<AlertDialog
open={bulkDeleteOpen}
onOpenChange={(open) => {
if (!bulkDeleteSent.isPending) setBulkDeleteOpen(open);
}}
>
<AlertDialogContent data-testid="sent-bulk-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t(
selectedSentIds.size === 1
? "notes.bulkConfirmTitle_one"
: "notes.bulkConfirmTitle",
{ n: selectedSentIds.size },
)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"notes.bulkConfirmBody",
"The notes and all their replies will be permanently deleted. This cannot be undone.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkDeleteSent.isPending}>
{t("notes.bulkCancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="sent-bulk-confirm-delete"
disabled={bulkDeleteSent.isPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
onClick={async (e) => {
e.preventDefault();
const ids = Array.from(selectedSentIds);
if (ids.length === 0) return;
try {
const res = await bulkDeleteSent.mutateAsync(ids);
if (res.failed.length === 0) {
toast({
title: t(
res.ok.length === 1
? "notes.bulkResultAll_one"
: "notes.bulkResultAll",
{ n: res.ok.length },
),
});
} else if (res.ok.length === 0) {
toast({
title: t(
"notes.bulkResultNone",
"Could not delete the selected notes",
),
variant: "destructive",
});
} else {
toast({
title: t("notes.bulkResultPartial", {
ok: res.ok.length,
total: ids.length,
failed: res.failed.length,
}),
variant: "destructive",
});
}
} catch {
toast({
title: t(
"notes.bulkResultNone",
"Could not delete the selected notes",
),
variant: "destructive",
});
} finally {
exitSentSelection();
}
}}
>
{bulkDeleteSent.isPending
? t("notes.bulkDeleting", "Deleting...")
: t("notes.bulkDelete", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<LabelsDialog
open={labelsDialogOpen}
onClose={() => setLabelsDialogOpen(false)}
@@ -1595,10 +1834,20 @@ function SentList({
loading,
notes,
onOpen,
selectionMode = false,
selectedIds,
onToggle,
}: {
loading: boolean;
notes: SentNote[];
onOpen: (id: number) => void;
// Task #472: when `selectionMode` is on the cards become checkbox
// toggles instead of thread openers. The page-level state is passed
// in so the bulk action bar (rendered above this list) can mirror
// the same Set without prop-drilling state setters.
selectionMode?: boolean;
selectedIds?: Set<number>;
onToggle?: (id: number) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -1617,15 +1866,45 @@ function SentList({
style={{ columnWidth: 260 }}
data-testid="notes-sent-list"
>
{notes.map((n) => (
<button
{notes.map((n) => {
const checked = selectedIds?.has(n.id) ?? false;
// Use a div (not <button>) so the Checkbox inside selection mode
// isn't a nested interactive control. Keyboard support is
// provided via role="button" + Enter/Space handler.
const handleActivate = () => {
if (selectionMode) onToggle?.(n.id);
else onOpen(n.id);
};
return (
<div
key={n.id}
onClick={() => onOpen(n.id)}
role="button"
tabIndex={0}
onClick={handleActivate}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleActivate();
}
}}
aria-pressed={selectionMode ? checked : undefined}
data-testid={`sent-note-card-${n.id}`}
className={`text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md ${colorBg(
className={`relative text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 ${colorBg(
n.color,
)}`}
)} ${checked ? "ring-2 ring-rose-500" : ""}`}
>
{selectionMode && (
<span
className="absolute top-2 end-2 z-[1] flex items-center justify-center rounded-md bg-white/80 backdrop-blur p-0.5 shadow-sm pointer-events-none"
>
<Checkbox
checked={checked}
tabIndex={-1}
data-testid={`sent-note-checkbox-${n.id}`}
aria-hidden="true"
/>
</span>
)}
{n.title && (
<div className="font-semibold text-sm text-foreground break-words">
{n.title}
@@ -1667,8 +1946,9 @@ function SentList({
</span>
))}
</div>
</button>
))}
</div>
);
})}
</div>
);
}