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:
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user