Task #512: Per-recipient Delete in Notes Inbox

Adds a recipient-scoped delete that's distinct from Archive: a recipient
can remove a note from their own inbox without touching the underlying
note or other recipients' rows.

Backend (artifacts/api-server/src/routes/notes.ts):
- DELETE /notes/received/:id — recipient-only; 404 if no row.
- POST /notes/received/bulk-delete — body {ids:number[]}, max 500,
  single SQL DELETE, returns {ok, notFound} for partial-success UI.
- Both registered before DELETE /notes/:id so Express matches /received
  first.

Frontend (artifacts/tx-os):
- New hooks useDeleteReceivedNote / useBulkDeleteReceivedNotes in
  src/lib/notes-api.ts; both invalidate notes + folders queries.
- Inbox bulk bar: Delete button (rose) next to Archive plus a confirm
  AlertDialog with all/none/partial toasts.
- Inbox card: per-row Trash button next to the status badge with a
  page-level confirm AlertDialog (testid inbox-row-delete-confirm).
- ThreadDialog: per-row Delete next to Archive plus a confirm dialog
  that closes the thread on success.
- AR + EN locale strings added for all new copy.

Tests:
- artifacts/api-server/tests/notes-inbox-delete.test.mjs — recipient
  delete, non-recipient/sender 404, bulk mix of valid+missing ids, and
  DB-level checks that the note + other recipients survive.
- artifacts/tx-os/tests/notes-inbox-bulk-delete.spec.mjs — Playwright
  flow seeds three received notes, bulk-deletes two from the inbox,
  then deletes the third via the per-row card Delete button.
This commit is contained in:
riyadhafraa
2026-05-12 11:18:00 +00:00
parent 0988585c65
commit 9a8dc3175f
3 changed files with 106 additions and 9 deletions
+101 -1
View File
@@ -240,6 +240,13 @@ export default function NotesPage() {
// Inbox and Archived. The sender's copy is untouched.
const [bulkDeleteInboxOpen, setBulkDeleteInboxOpen] = useState(false);
const bulkDeleteInbox = useBulkDeleteReceivedNotes();
// Per-row delete on inbox cards (separate from archive + bulk delete).
// Lifted to the page so the confirm AlertDialog can mount above the
// card grid without each card owning its own dialog.
const [singleDeleteInboxId, setSingleDeleteInboxId] = useState<number | null>(
null,
);
const deleteInboxRow = useDeleteReceivedNote();
const toggleInboxSelected = useCallback((id: number) => {
setSelectedInboxIds((prev) => {
const next = new Set(prev);
@@ -967,6 +974,7 @@ export default function NotesPage() {
selectionMode={inboxSelectionMode}
selectedIds={selectedInboxIds}
onToggle={toggleInboxSelected}
onRequestDelete={(id) => setSingleDeleteInboxId(id)}
/>
</>
)}
@@ -1323,6 +1331,67 @@ export default function NotesPage() {
</AlertDialogContent>
</AlertDialog>
{/* Task #512: per-row inbox delete confirm. Driven by the page-level
singleDeleteInboxId state set from each ReceivedList card so the
dialog can mount once instead of per card. */}
<AlertDialog
open={singleDeleteInboxId !== null}
onOpenChange={(open) => {
if (!deleteInboxRow.isPending && !open) setSingleDeleteInboxId(null);
}}
>
<AlertDialogContent data-testid="inbox-row-delete-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.deleteInboxConfirmTitle", "Delete this note from your inbox?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"notes.bulkDeleteInboxConfirmBody",
"This removes the notes from your inbox and archive. The sender keeps their copy.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteInboxRow.isPending}>
{t("notes.bulkCancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="inbox-row-delete-confirm-button"
disabled={deleteInboxRow.isPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
onClick={async (e) => {
e.preventDefault();
const id = singleDeleteInboxId;
if (id == null) return;
try {
await deleteInboxRow.mutateAsync(id);
toast({
title: t(
"notes.deletedInboxOne",
"Note removed from your inbox",
),
});
setSingleDeleteInboxId(null);
} catch {
toast({
title: t(
"notes.bulkDeleteInboxNone",
"Could not delete the selected notes",
),
variant: "destructive",
});
}
}}
>
{deleteInboxRow.isPending
? t("notes.bulkDeleting", "Deleting...")
: t("notes.delete", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<LabelsDialog
open={labelsDialogOpen}
onClose={() => setLabelsDialogOpen(false)}
@@ -2097,6 +2166,7 @@ function ReceivedList({
selectionMode = false,
selectedIds,
onToggle,
onRequestDelete,
}: {
loading: boolean;
notes: ReceivedNote[];
@@ -2104,6 +2174,10 @@ function ReceivedList({
selectionMode?: boolean;
selectedIds?: Set<number>;
onToggle?: (id: number) => void;
// Task #512: optional per-row delete affordance. Only the live
// Inbox passes it; the Archived section omits it because deletes
// are surfaced at the page level there.
onRequestDelete?: (id: number) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -2167,7 +2241,33 @@ function ReceivedList({
{t("notes.from", "From:")} {userDisplayName(n.sender, lang)}
</span>
</div>
{!selectionMode && <StatusBadge status={n.status} />}
{!selectionMode && (
<div className="flex items-center gap-1">
<StatusBadge status={n.status} />
{onRequestDelete && (
<button
type="button"
data-testid={`received-note-delete-${n.id}`}
aria-label={t("notes.delete", "Delete")}
title={t("notes.delete", "Delete")}
className="p-1 rounded hover:bg-rose-100 text-rose-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500"
onClick={(e) => {
e.stopPropagation();
onRequestDelete(n.id);
}}
onKeyDown={(e) => {
// Don't bubble Enter/Space into the card's
// role=button activation handler.
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}}
>
<Trash2 size={14} />
</button>
)}
</div>
)}
</div>
{n.kind === "checklist" ? (
<ChecklistView
@@ -109,7 +109,7 @@ async function loginViaUi(page, username) {
]);
}
test("Inbox tab: bulk delete two notes, third deleted via thread per-row", async ({
test("Inbox tab: bulk delete two notes, third deleted via per-row card button", async ({
page,
}) => {
const sender = await createUser("inbox_del_sender", "Sender Del");
@@ -180,11 +180,9 @@ test("Inbox tab: bulk delete two notes, third deleted via thread per-row", async
);
expect(noteRows.length).toEqual(3);
// Per-row delete: open C in ThreadDialog and use the Delete button.
await cardC.click();
await expect(page.getByTestId("note-thread-dialog")).toBeVisible();
await page.getByTestId("thread-delete").click();
await expect(page.getByTestId("thread-delete-confirm")).toBeVisible();
// Per-row delete from the inbox card itself (not via the thread).
await page.getByTestId(`received-note-delete-${noteC.id}`).click();
await expect(page.getByTestId("inbox-row-delete-confirm")).toBeVisible();
const singleDeleted = page.waitForResponse(
(r) =>
@@ -194,10 +192,9 @@ test("Inbox tab: bulk delete two notes, third deleted via thread per-row", async
r.status() < 300,
{ timeout: 15_000 },
);
await page.getByTestId("thread-delete-confirm-button").click();
await page.getByTestId("inbox-row-delete-confirm-button").click();
await singleDeleted;
await expect(page.getByTestId("note-thread-dialog")).toBeHidden();
await expect(cardC).toBeHidden();
const { rows: recRows2 } = await pool.query(
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB