Add ability to archive multiple incoming notes at once
Introduces a bulk archive feature for the received notes tab, including API integration, UI selection state, confirmation dialogs, and internationalization support. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 7c13c05c-e8c2-461e-a5dc-94bbc8e114ac Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/O07Jndv Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -432,6 +432,45 @@ export function useArchiveReceivedNote() {
|
||||
});
|
||||
}
|
||||
|
||||
// Task #510: bulk archive for the Notes Inbox. Mirrors
|
||||
// `useBulkDeleteSentNotes` — fans `BULK_DELETE_CONCURRENCY` workers over
|
||||
// POST /notes/:id/archive so the request count is bounded even on large
|
||||
// selections, and reports per-id success/failure so the caller can show
|
||||
// a partial-failure toast. The recipient does not own the underlying
|
||||
// note, so we archive (their recipient row) instead of deleting.
|
||||
export function useBulkArchiveReceivedNotes() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const ok: number[] = [];
|
||||
const failed: number[] = [];
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const i = cursor++;
|
||||
if (i >= ids.length) return;
|
||||
const id = ids[i];
|
||||
try {
|
||||
await req<{ success: true }>(`/notes/${id}/archive`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ archived: true }),
|
||||
});
|
||||
ok.push(id);
|
||||
} catch {
|
||||
failed.push(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
const workerCount = Math.min(BULK_DELETE_CONCURRENCY, ids.length);
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, () => worker()),
|
||||
);
|
||||
return { ok, failed };
|
||||
},
|
||||
onSettled: () => invalidateNotesAndFolders(qc),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReplyToNote() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1117,6 +1117,15 @@
|
||||
"bulkResultAll_one": "تم حذف ملاحظة واحدة",
|
||||
"bulkResultPartial": "تم حذف {{ok}} من {{total}}، فشل {{failed}}",
|
||||
"bulkResultNone": "تعذّر حذف الملاحظات المحددة",
|
||||
"bulkArchive": "أرشفة",
|
||||
"bulkArchiving": "جارٍ الأرشفة...",
|
||||
"bulkArchiveConfirmTitle": "أرشفة {{n}} ملاحظة من الوارد؟",
|
||||
"bulkArchiveConfirmTitle_one": "أرشفة ملاحظة واحدة من الوارد؟",
|
||||
"bulkArchiveConfirmBody": "سيتم نقل هذه الملاحظات إلى الأرشيف. يمكنك إيجادها لاحقاً في تبويب المؤرشفة.",
|
||||
"bulkArchivedAll": "تمت أرشفة {{n}} ملاحظة",
|
||||
"bulkArchivedAll_one": "تمت أرشفة ملاحظة واحدة",
|
||||
"bulkArchivePartial": "تمت أرشفة {{ok}} من {{total}}، فشل {{failed}}",
|
||||
"bulkArchiveNone": "تعذّر أرشفة الملاحظات المحددة",
|
||||
"moreActions": "خيارات أخرى",
|
||||
"empty": "ستظهر ملاحظاتك هنا",
|
||||
"emptyArchived": "لا توجد ملاحظات في الأرشيف",
|
||||
|
||||
@@ -1014,6 +1014,15 @@
|
||||
"bulkResultAll_one": "Deleted 1 note",
|
||||
"bulkResultPartial": "Deleted {{ok}} of {{total}}; {{failed}} failed",
|
||||
"bulkResultNone": "Could not delete the selected notes",
|
||||
"bulkArchive": "Archive",
|
||||
"bulkArchiving": "Archiving...",
|
||||
"bulkArchiveConfirmTitle": "Archive {{n}} inbox notes?",
|
||||
"bulkArchiveConfirmTitle_one": "Archive 1 inbox note?",
|
||||
"bulkArchiveConfirmBody": "These notes will be moved to your archive. You can find them later in the Archived tab.",
|
||||
"bulkArchivedAll": "Archived {{n}} notes",
|
||||
"bulkArchivedAll_one": "Archived 1 note",
|
||||
"bulkArchivePartial": "Archived {{ok}} of {{total}}; {{failed}} failed",
|
||||
"bulkArchiveNone": "Could not archive the selected notes",
|
||||
"moreActions": "More actions",
|
||||
"empty": "Your notes will appear here",
|
||||
"emptyArchived": "No archived notes",
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
colorBg,
|
||||
colorAccent,
|
||||
useArchiveReceivedNote,
|
||||
useBulkArchiveReceivedNotes,
|
||||
useCreateLabel,
|
||||
useCreateNote,
|
||||
useDeleteLabel,
|
||||
@@ -221,6 +222,34 @@ export default function NotesPage() {
|
||||
exitSentSelection();
|
||||
}
|
||||
}, [view, sentSelectionMode, exitSentSelection]);
|
||||
|
||||
// Task #510: bulk-select + bulk-archive for the Inbox tab. Identical
|
||||
// shape to the Sent tab's selection state above, but the destructive
|
||||
// action is "archive" (the recipient doesn't own the underlying note,
|
||||
// so we hide it from their inbox via POST /notes/:id/archive instead
|
||||
// of DELETE).
|
||||
const [inboxSelectionMode, setInboxSelectionMode] = useState(false);
|
||||
const [selectedInboxIds, setSelectedInboxIds] = useState<Set<number>>(new Set());
|
||||
const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
|
||||
const bulkArchiveInbox = useBulkArchiveReceivedNotes();
|
||||
const toggleInboxSelected = useCallback((id: number) => {
|
||||
setSelectedInboxIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const exitInboxSelection = useCallback(() => {
|
||||
setInboxSelectionMode(false);
|
||||
setSelectedInboxIds(new Set());
|
||||
setBulkArchiveOpen(false);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (view !== "received" && inboxSelectionMode) {
|
||||
exitInboxSelection();
|
||||
}
|
||||
}, [view, inboxSelectionMode, exitInboxSelection]);
|
||||
// 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
|
||||
@@ -445,6 +474,27 @@ export default function NotesPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visibleSentIdsKey, sentSelectionMode, exitSentSelection]);
|
||||
|
||||
// Same prune-to-visible guard for the Inbox bulk selection.
|
||||
const visibleInboxIdsKey = filteredReceived.map((n) => n.id).join(",");
|
||||
useEffect(() => {
|
||||
if (!inboxSelectionMode) return;
|
||||
if (filteredReceived.length === 0) {
|
||||
exitInboxSelection();
|
||||
return;
|
||||
}
|
||||
const visible = new Set(filteredReceived.map((n) => n.id));
|
||||
setSelectedInboxIds((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
|
||||
}, [visibleInboxIdsKey, inboxSelectionMode, exitInboxSelection]);
|
||||
|
||||
const unreadInboxCount = receivedNotes.filter((n) => n.status === "unread").length;
|
||||
const pinned = filteredNotes.filter((n) => n.isPinned);
|
||||
const others = filteredNotes.filter((n) => !n.isPinned);
|
||||
@@ -575,6 +625,27 @@ export default function NotesPage() {
|
||||
mode swaps the cards' click behavior from "open thread"
|
||||
to "toggle checkbox" and exposes the bulk action bar
|
||||
below the header. */}
|
||||
{view === "received" && filteredReceived.length > 0 && (
|
||||
inboxSelectionMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitInboxSelection}
|
||||
data-testid="inbox-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={() => setInboxSelectionMode(true)}
|
||||
data-testid="inbox-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 === "sent" && filteredSent.length > 0 && (
|
||||
sentSelectionMode ? (
|
||||
<button
|
||||
@@ -803,11 +874,73 @@ export default function NotesPage() {
|
||||
)}
|
||||
|
||||
{view === "received" && (
|
||||
<ReceivedList
|
||||
loading={loadingReceived}
|
||||
notes={filteredReceived}
|
||||
onOpen={(id) => setOpenThreadId(id)}
|
||||
/>
|
||||
<>
|
||||
{inboxSelectionMode && (
|
||||
<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="inbox-bulk-bar"
|
||||
>
|
||||
<span
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-testid="inbox-bulk-count"
|
||||
>
|
||||
{t("notes.bulkCount", "{{n}} selected", {
|
||||
n: selectedInboxIds.size,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedInboxIds.size === filteredReceived.length) {
|
||||
setSelectedInboxIds(new Set());
|
||||
} else {
|
||||
setSelectedInboxIds(
|
||||
new Set(filteredReceived.map((n) => n.id)),
|
||||
);
|
||||
}
|
||||
}}
|
||||
data-testid="inbox-bulk-select-all"
|
||||
className="text-sm px-2.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-slate-100"
|
||||
>
|
||||
{selectedInboxIds.size === filteredReceived.length &&
|
||||
filteredReceived.length > 0
|
||||
? t("notes.bulkClear", "Clear selection")
|
||||
: t("notes.bulkSelectAll", "Select all")}
|
||||
</button>
|
||||
<div className="ms-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitInboxSelection}
|
||||
disabled={bulkArchiveInbox.isPending}
|
||||
data-testid="inbox-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={() => setBulkArchiveOpen(true)}
|
||||
disabled={
|
||||
selectedInboxIds.size === 0 || bulkArchiveInbox.isPending
|
||||
}
|
||||
data-testid="inbox-bulk-archive"
|
||||
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-slate-900 text-white hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Archive size={14} />
|
||||
{t("notes.bulkArchive", "Archive")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ReceivedList
|
||||
loading={loadingReceived}
|
||||
notes={filteredReceived}
|
||||
onOpen={(id) => setOpenThreadId(id)}
|
||||
selectionMode={inboxSelectionMode}
|
||||
selectedIds={selectedInboxIds}
|
||||
onToggle={toggleInboxSelected}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "sent" && (
|
||||
@@ -985,6 +1118,94 @@ export default function NotesPage() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Task #510: confirmation for bulk-archiving selected inbox notes.
|
||||
Mirrors the Sent bulk-delete dialog: parallel POST /notes/:id/archive
|
||||
via worker pool, partial-failure aware toasts. */}
|
||||
<AlertDialog
|
||||
open={bulkArchiveOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!bulkArchiveInbox.isPending) setBulkArchiveOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent data-testid="inbox-bulk-confirm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(
|
||||
selectedInboxIds.size === 1
|
||||
? "notes.bulkArchiveConfirmTitle_one"
|
||||
: "notes.bulkArchiveConfirmTitle",
|
||||
{ n: selectedInboxIds.size },
|
||||
)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
"notes.bulkArchiveConfirmBody",
|
||||
"These notes will be moved to your archive. You can find them later in the Archived tab.",
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={bulkArchiveInbox.isPending}>
|
||||
{t("notes.bulkCancel", "Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="inbox-bulk-confirm-archive"
|
||||
disabled={bulkArchiveInbox.isPending}
|
||||
className="bg-slate-900 hover:bg-slate-800 text-white"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
const ids = Array.from(selectedInboxIds);
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
const res = await bulkArchiveInbox.mutateAsync(ids);
|
||||
if (res.failed.length === 0) {
|
||||
toast({
|
||||
title: t(
|
||||
res.ok.length === 1
|
||||
? "notes.bulkArchivedAll_one"
|
||||
: "notes.bulkArchivedAll",
|
||||
{ n: res.ok.length },
|
||||
),
|
||||
});
|
||||
} else if (res.ok.length === 0) {
|
||||
toast({
|
||||
title: t(
|
||||
"notes.bulkArchiveNone",
|
||||
"Could not archive the selected notes",
|
||||
),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("notes.bulkArchivePartial", {
|
||||
ok: res.ok.length,
|
||||
total: ids.length,
|
||||
failed: res.failed.length,
|
||||
}),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: t(
|
||||
"notes.bulkArchiveNone",
|
||||
"Could not archive the selected notes",
|
||||
),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
exitInboxSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{bulkArchiveInbox.isPending
|
||||
? t("notes.bulkArchiving", "Archiving...")
|
||||
: t("notes.bulkArchive", "Archive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<LabelsDialog
|
||||
open={labelsDialogOpen}
|
||||
onClose={() => setLabelsDialogOpen(false)}
|
||||
@@ -1756,10 +1977,16 @@ function ReceivedList({
|
||||
loading,
|
||||
notes,
|
||||
onOpen,
|
||||
selectionMode = false,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
}: {
|
||||
loading: boolean;
|
||||
notes: ReceivedNote[];
|
||||
onOpen: (id: number) => void;
|
||||
selectionMode?: boolean;
|
||||
selectedIds?: Set<number>;
|
||||
onToggle?: (id: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -1778,40 +2005,70 @@ function ReceivedList({
|
||||
style={{ columnWidth: 260 }}
|
||||
data-testid="notes-received-list"
|
||||
>
|
||||
{notes.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => onOpen(n.id)}
|
||||
data-testid={`received-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(
|
||||
n.color,
|
||||
)}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="text-[11px] text-muted-foreground flex items-center gap-1 min-w-0">
|
||||
<Mail size={12} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("notes.from", "From:")} {userDisplayName(n.sender, lang)}
|
||||
{notes.map((n) => {
|
||||
const checked = selectedIds?.has(n.id) ?? false;
|
||||
const handleActivate = () => {
|
||||
if (selectionMode) onToggle?.(n.id);
|
||||
else onOpen(n.id);
|
||||
};
|
||||
// Use a div (not <button>) so the Checkbox inside selection mode
|
||||
// isn't a nested interactive element.
|
||||
return (
|
||||
<div
|
||||
key={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={`received-note-card-${n.id}`}
|
||||
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-sky-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={`received-note-checkbox-${n.id}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge status={n.status} />
|
||||
</div>
|
||||
{n.kind === "checklist" ? (
|
||||
<ChecklistView
|
||||
items={n.items ?? []}
|
||||
testIdPrefix={`received-note-checklist-${n.id}`}
|
||||
className="mt-1"
|
||||
noteColor={n.color}
|
||||
/>
|
||||
) : (
|
||||
n.content && (
|
||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
|
||||
{n.content}
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="text-[11px] text-muted-foreground flex items-center gap-1 min-w-0">
|
||||
<Mail size={12} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("notes.from", "From:")} {userDisplayName(n.sender, lang)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{!selectionMode && <StatusBadge status={n.status} />}
|
||||
</div>
|
||||
{n.kind === "checklist" ? (
|
||||
<ChecklistView
|
||||
items={n.items ?? []}
|
||||
testIdPrefix={`received-note-checklist-${n.id}`}
|
||||
className="mt-1"
|
||||
noteColor={n.color}
|
||||
/>
|
||||
) : (
|
||||
n.content && (
|
||||
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
|
||||
{n.content}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user