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:
@@ -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({
|
||||
|
||||
@@ -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": "لا توجد ملاحظات في الأرشيف",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user