notes(sent): multi-select + bulk delete on Sent tab (task #472)
- Add useBulkDeleteSentNotes hook with bounded concurrency (worker
pool, cap = 6) over the existing per-id DELETE /notes/:id endpoint;
returns {ok, failed} so the UI can render a precise toast. One cache
invalidation at the end (notes + folders).
- notes.tsx: page-level selection state for the Sent tab only
(sentSelectionMode, selectedSentIds, bulkDeleteOpen). Header
Select/Cancel toggle, sticky bulk-action bar with count, select-all/
clear, in-bar Cancel, Delete, and an AlertDialog confirmation. Auto
exits selection mode when leaving the Sent tab; prunes selected ids
to currently visible filteredSent on every change (search, refresh,
successful delete).
- SentList: switched the outer card from a nested <button> to a
div role="button" tabIndex=0 with Enter/Space handler so the
selection-mode Checkbox is no longer a nested interactive control.
Checkbox is aria-hidden / pointer-events-none inside selection mode.
- i18n: notes.bulk* keys (Select / Cancel / SelectAll / Clear / Count
with _one plural / Delete / Confirm title+body / Result success /
partial / failure) in both en.json and ar.json.
- New e2e: tests/notes-sent-bulk-delete.spec.mjs seeds 3 sent notes,
selects 2, confirms the bulk delete, asserts the cards are gone and
the surviving note remains (DB + UI).
- Out of scope (proposed as follow-ups #473/#474/#475): undo on bulk
delete, multi-select on Inbox/Archived tabs, additional e2e cases.
This commit is contained in:
@@ -261,26 +261,39 @@ 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.
|
||||
// existing per-note DELETE endpoint and fans out with bounded
|
||||
// concurrency (worker pool of `BULK_DELETE_CONCURRENCY`) so we never
|
||||
// open hundreds of sockets at once on large selections — each worker
|
||||
// pulls the next id off a shared cursor until the queue drains. A
|
||||
// single failed row never blocks 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.
|
||||
const BULK_DELETE_CONCURRENCY = 6;
|
||||
|
||||
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]);
|
||||
});
|
||||
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}`, { method: "DELETE" });
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Task #472 e2e: multi-select + bulk-delete on the Sent notes tab.
|
||||
// Seeds three sent notes for a user, opens the Sent view, enters
|
||||
// selection mode, picks two cards, confirms the bulk delete, and
|
||||
// verifies that the count drops by two while the unselected note
|
||||
// remains. Uses the same direct-DB seeding pattern as
|
||||
// notes-inbox.spec.mjs so the test is hermetic.
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run this UI test");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdNoteIds = [];
|
||||
|
||||
function uniq() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix, displayEn) {
|
||||
const username = `${prefix}_${uniq()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = 'user'`,
|
||||
[id],
|
||||
);
|
||||
return { id, username, displayEn };
|
||||
}
|
||||
|
||||
async function createNote(userId, title, content) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notes (user_id, title, content, color, kind)
|
||||
VALUES ($1, $2, $3, 'yellow', 'text') RETURNING id, title, content, color, kind`,
|
||||
[userId, title, content],
|
||||
);
|
||||
const note = rows[0];
|
||||
createdNoteIds.push(note.id);
|
||||
return note;
|
||||
}
|
||||
|
||||
async function addRecipient(note, senderId, recipientId) {
|
||||
await pool.query(
|
||||
`INSERT INTO note_recipients
|
||||
(note_id, sender_user_id, recipient_user_id, title, content, color, kind, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'unread')`,
|
||||
[note.id, senderId, recipientId, note.title, note.content, note.color, note.kind],
|
||||
);
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdNoteIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM note_replies WHERE note_id = ANY($1::int[])`,
|
||||
[createdNoteIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`,
|
||||
[createdNoteIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [
|
||||
createdNoteIds,
|
||||
]);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM notes WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function loginViaUi(page, username) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(TEST_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
test("Sent tab: select two notes, bulk-delete, count drops by two", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sender = await createUser("sent_bulk_sender", "Sender Bulk");
|
||||
const recipient = await createUser("sent_bulk_recipient", "Recipient Bulk");
|
||||
|
||||
// Seed three sent notes (each needs at least one recipient row to be
|
||||
// returned by GET /notes?box=sent).
|
||||
const tag = uniq();
|
||||
const noteA = await createNote(sender.id, `Bulk A ${tag}`, "alpha");
|
||||
const noteB = await createNote(sender.id, `Bulk B ${tag}`, "beta");
|
||||
const noteC = await createNote(sender.id, `Bulk C ${tag}`, "gamma");
|
||||
const noteAId = noteA.id;
|
||||
const noteBId = noteB.id;
|
||||
const noteCId = noteC.id;
|
||||
await addRecipient(noteA, sender.id, recipient.id);
|
||||
await addRecipient(noteB, sender.id, recipient.id);
|
||||
await addRecipient(noteC, sender.id, recipient.id);
|
||||
|
||||
await loginViaUi(page, sender.username);
|
||||
await page.goto("/notes");
|
||||
await expect(page.getByTestId("notes-page")).toBeVisible();
|
||||
|
||||
// Switch to Sent tab.
|
||||
await page.getByTestId("notes-overflow-menu-trigger").click();
|
||||
await page.getByTestId("notes-overflow-sent").click();
|
||||
|
||||
const cardA = page.getByTestId(`sent-note-card-${noteAId}`);
|
||||
const cardB = page.getByTestId(`sent-note-card-${noteBId}`);
|
||||
const cardC = page.getByTestId(`sent-note-card-${noteCId}`);
|
||||
await expect(cardA).toBeVisible();
|
||||
await expect(cardB).toBeVisible();
|
||||
await expect(cardC).toBeVisible();
|
||||
|
||||
// Enter selection mode and pick A + B (leave C).
|
||||
await page.getByTestId("sent-bulk-select-toggle").click();
|
||||
await expect(page.getByTestId("sent-bulk-bar")).toBeVisible();
|
||||
await cardA.click();
|
||||
await cardB.click();
|
||||
await expect(page.getByTestId("sent-bulk-count")).toContainText("2");
|
||||
|
||||
// Open confirm and execute the bulk delete.
|
||||
await page.getByTestId("sent-bulk-delete").click();
|
||||
await expect(page.getByTestId("sent-bulk-confirm")).toBeVisible();
|
||||
|
||||
const deletedA = page.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).pathname === `/api/notes/${noteAId}` &&
|
||||
r.request().method() === "DELETE" &&
|
||||
r.status() >= 200 &&
|
||||
r.status() < 300,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const deletedB = page.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).pathname === `/api/notes/${noteBId}` &&
|
||||
r.request().method() === "DELETE" &&
|
||||
r.status() >= 200 &&
|
||||
r.status() < 300,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByTestId("sent-bulk-confirm-delete").click();
|
||||
await Promise.all([deletedA, deletedB]);
|
||||
|
||||
// A and B are gone; C remains.
|
||||
await expect(cardA).toBeHidden();
|
||||
await expect(cardB).toBeHidden();
|
||||
await expect(cardC).toBeVisible();
|
||||
|
||||
// Verify in the DB that A and B were actually deleted.
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM notes WHERE id = ANY($1::int[])`,
|
||||
[[noteAId, noteBId, noteCId]],
|
||||
);
|
||||
const remainingIds = rows.map((r) => r.id);
|
||||
expect(remainingIds).toEqual([noteCId]);
|
||||
});
|
||||
Reference in New Issue
Block a user