From 9d60b359557c77a53c3e113aa2c7be37b4e76839 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 13 May 2026 06:47:44 +0000 Subject: [PATCH] Task #520: skip send-note confirm when only one recipient The send-note composer always showed an AlertDialog before posting, even for the common one-on-one case. The dialog now only appears when two or more recipients are picked. Implementation (artifacts/tx-os/src/pages/notes.tsx): - Extracted shared `performSend()` from `confirmSubmit` so both the direct-send and confirm-then-send paths reuse the same mutation, success toast + composer close, and error toast. - `requestSubmit()` now branches: 0 picked = no-op, 1 picked = call performSend() directly, 2+ picked = open the existing AlertDialog. - `confirmSubmit()` (the AlertDialog action) delegates to performSend. - No changes to API, recipient picker, AR/EN strings, or any other AlertDialog (bulk archive/delete left untouched). Tests: - Updated artifacts/tx-os/tests/notes-inbox.spec.mjs (sends to a single recipient): no longer expects the confirm dialog; asserts /api/notes/:id/send fires directly and the composer closes. - New artifacts/tx-os/tests/notes-send-confirm-threshold.spec.mjs: - "single-recipient send skips the confirm dialog" (passes) - "multi-recipient send still requires the confirm dialog" (passes) Validation: tx-os tsc clean; both threshold specs pass locally; architect review APPROVED. --- artifacts/tx-os/src/pages/notes.tsx | 24 ++- artifacts/tx-os/tests/notes-inbox.spec.mjs | 6 +- .../notes-send-confirm-threshold.spec.mjs | 169 ++++++++++++++++++ 3 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 artifacts/tx-os/tests/notes-send-confirm-threshold.spec.mjs diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index f0207d1c..3d4bdaa0 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -2513,12 +2513,11 @@ function SendNoteDialog({ const [confirmOpen, setConfirmOpen] = useState(false); - const requestSubmit = () => { - if (picked.size === 0) return; - setConfirmOpen(true); - }; - - const confirmSubmit = () => { + // Task #520: skip the confirmation dialog when sending to a single + // recipient. The dialog only added an extra click for the common + // one-on-one case; it still appears when 2+ recipients are picked so + // a batch send remains an explicit choice. + const performSend = () => { send.mutate( { id: noteId, recipientUserIds: Array.from(picked) }, { @@ -2538,6 +2537,19 @@ function SendNoteDialog({ ); }; + const requestSubmit = () => { + if (picked.size === 0 || send.isPending) return; + if (picked.size === 1) { + performSend(); + return; + } + setConfirmOpen(true); + }; + + const confirmSubmit = () => { + performSend(); + }; + return ( !o && onClose()}> diff --git a/artifacts/tx-os/tests/notes-inbox.spec.mjs b/artifacts/tx-os/tests/notes-inbox.spec.mjs index 18969dc6..d9223cc7 100644 --- a/artifacts/tx-os/tests/notes-inbox.spec.mjs +++ b/artifacts/tx-os/tests/notes-inbox.spec.mjs @@ -145,10 +145,10 @@ test("sender → recipient → reply flow updates Inbox + Sent tabs", async ({ { timeout: 15_000 }, ); await page.getByTestId("send-note-submit").click(); - // Confirm-before-send dialog: explicit confirmation step. - await expect(page.getByTestId("send-note-confirm")).toBeVisible(); - await page.getByTestId("send-note-confirm-submit").click(); + // Task #520: a single-recipient send no longer shows the confirm + // dialog — the click goes straight to the send mutation. await sendPromise; + await expect(page.getByTestId("send-note-confirm")).toBeHidden(); await expect(sendDialog).toBeHidden(); // ---- Switch to recipient session ---- diff --git a/artifacts/tx-os/tests/notes-send-confirm-threshold.spec.mjs b/artifacts/tx-os/tests/notes-send-confirm-threshold.spec.mjs new file mode 100644 index 00000000..c57c4a25 --- /dev/null +++ b/artifacts/tx-os/tests/notes-send-confirm-threshold.spec.mjs @@ -0,0 +1,169 @@ +// Task #520: confirm dialog only appears when sending to 2+ recipients. +// One recipient → direct send (no popup). Two recipients → existing +// AlertDialog still gates the send. +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 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(), + ]); +} + +async function composeNote(page, content) { + await page.goto("/notes"); + await expect(page.getByTestId("notes-page")).toBeVisible(); + await page.getByTestId("notes-composer-open").click(); + await page.getByTestId("notes-composer-content").fill(content); + const createPromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === "/api/notes" && + r.request().method() === "POST" && + r.ok(), + { timeout: 15_000 }, + ); + await page.getByTestId("notes-composer-save").click(); + const created = await createPromise; + const note = await created.json(); + createdNoteIds.push(note.id); + return note; +} + +async function openSendDialog(page, noteId) { + const card = page.getByTestId(`note-card-${noteId}`); + await expect(card).toBeVisible(); + await card.hover(); + await page.getByTestId(`note-card-send-${noteId}`).click(); + await expect(page.getByTestId("send-note-dialog")).toBeVisible(); +} + +async function pickRecipient(page, recipient) { + await page.getByTestId("send-note-search").fill(""); + await page.getByTestId("send-note-search").fill(recipient.displayEn); + await page.getByTestId(`send-recipient-option-${recipient.id}`).click(); +} + +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(); +}); + +test("single-recipient send skips the confirm dialog", async ({ page }) => { + const sender = await createUser("notes_send1_sender", "Single Sender"); + const recipient = await createUser("notes_send1_recipient", "Solo Recipient"); + + await loginViaUi(page, sender.username); + const note = await composeNote(page, `Solo send ${uniq()}`); + await openSendDialog(page, note.id); + await pickRecipient(page, recipient); + + const sendPromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/notes/${note.id}/send` && + r.request().method() === "POST" && + r.ok(), + { timeout: 15_000 }, + ); + await page.getByTestId("send-note-submit").click(); + await sendPromise; + + await expect(page.getByTestId("send-note-confirm")).toBeHidden(); + await expect(page.getByTestId("send-note-dialog")).toBeHidden(); +}); + +test("multi-recipient send still requires the confirm dialog", async ({ + page, +}) => { + const sender = await createUser("notes_send2_sender", "Multi Sender"); + const r1 = await createUser("notes_send2_r1", "Recipient One"); + const r2 = await createUser("notes_send2_r2", "Recipient Two"); + + await loginViaUi(page, sender.username); + const note = await composeNote(page, `Group send ${uniq()}`); + await openSendDialog(page, note.id); + await pickRecipient(page, r1); + await pickRecipient(page, r2); + + await page.getByTestId("send-note-submit").click(); + // Two+ recipients → confirm dialog must appear; no /send call yet. + const confirm = page.getByTestId("send-note-confirm"); + await expect(confirm).toBeVisible(); + + const sendPromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/notes/${note.id}/send` && + r.request().method() === "POST" && + r.ok(), + { timeout: 15_000 }, + ); + await page.getByTestId("send-note-confirm-submit").click(); + await sendPromise; + await expect(page.getByTestId("send-note-dialog")).toBeHidden(); +});