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.
This commit is contained in:
Riyadh
2026-05-13 06:47:44 +00:00
parent 74f905e80c
commit 5bfd9fd35f
3 changed files with 190 additions and 9 deletions
+18 -6
View File
@@ -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 (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md" data-testid="send-note-dialog">
+3 -3
View File
@@ -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 ----
@@ -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();
});