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:
@@ -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 |
Reference in New Issue
Block a user