diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index 6cfb9eb5..c7657c43 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -258,6 +258,7 @@ export default function NotesPage() { size="sm" variant="ghost" onClick={() => setLabelsDialogOpen(true)} + data-testid="notes-manage-labels-open" > {t("notes.labels", "Labels")} @@ -544,6 +545,7 @@ function NoteCard({ const update = useUpdateNote(); const del = useDeleteNote(); const noteLabels = labels.filter((l) => note.labelIds.includes(l.id)); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); // Touch fallback for mobile / iPad: a long-press starts a drag, touchmove // hit-tests folder drop targets, touchend resolves the drop. @@ -567,6 +569,7 @@ function NoteCard({ }; return ( + <>
: }
+ {/* AlertDialog rendered as a sibling (not a child of the clickable card) + so React's portal event bubbling can't trigger the card's onEdit + click handler when the user interacts with the confirmation. */} + + + + + {t("notes.deleteConfirm", "Delete this note?")} + + + + + {t("common.cancel", "Cancel")} + + { + del.mutate(note.id); + setConfirmDeleteOpen(false); + }} + data-testid={`note-delete-confirm-${note.id}`} + > + {t("notes.delete", "Delete")} + + + + + ); } @@ -2005,6 +2038,9 @@ function LabelsDialog({ const [name, setName] = useState(""); const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(""); + const [pendingDeleteLabel, setPendingDeleteLabel] = useState( + null, + ); const create = useCreateLabel(); const update = useUpdateLabel(); const del = useDeleteLabel(); @@ -2095,12 +2131,9 @@ function LabelsDialog({ {l.name} @@ -2110,6 +2143,46 @@ function LabelsDialog({ ))} + { + if (!o) setPendingDeleteLabel(null); + }} + > + + + + {t("notes.deleteLabelConfirm", "Delete this label?")} + + + + + {t("common.cancel", "Cancel")} + + { + if (pendingDeleteLabel) { + del.mutate(pendingDeleteLabel.id); + setPendingDeleteLabel(null); + } + }} + data-testid={ + pendingDeleteLabel + ? `label-delete-confirm-${pendingDeleteLabel.id}` + : "label-delete-confirm" + } + > + {t("notes.delete", "Delete")} + + + + ); } diff --git a/artifacts/tx-os/tests/notes-delete-confirm.spec.mjs b/artifacts/tx-os/tests/notes-delete-confirm.spec.mjs new file mode 100644 index 00000000..0fce3620 --- /dev/null +++ b/artifacts/tx-os/tests/notes-delete-confirm.spec.mjs @@ -0,0 +1,176 @@ +// E2E regression for the styled in-app delete confirmation on notes. +// Previously the trash icon called the browser's native confirm() which +// shows the raw Repl hostname and clashes with the rest of the app's +// design. This test guards the styled AlertDialog: cancelling keeps the +// note, confirming deletes it. We deliberately do NOT register a +// `dialog` event handler — if a native confirm fires the test will hang +// (and fail), proving the native dialog is gone. +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 = []; + +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 }; +} + +test.afterAll(async () => { + if (createdUserIds.length > 0) { + await pool.query(`DELETE FROM notes 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(), + ]); +} + +async function createNoteViaComposer(page, title) { + await page.getByTestId("notes-composer").click(); + await page.getByTestId("notes-composer-title").fill(title); + const savePromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === "/api/notes" && + r.request().method() === "POST" && + r.status() >= 200 && + r.status() < 300, + ); + await page.mouse.click(10, 10); + const resp = await savePromise; + return resp.json(); +} + +test("delete note uses styled confirmation dialog (cancel keeps it, confirm deletes)", async ({ + page, +}) => { + const user = await createUser("notes_delete_confirm", "Delete Tester"); + await loginViaUi(page, user.username); + + await page.goto("/notes"); + await expect(page.getByTestId("notes-page")).toBeVisible(); + + const title = `delete-test ${uniq()}`; + const note = await createNoteViaComposer(page, title); + + const card = page.getByTestId(`note-card-${note.id}`); + await expect(card).toBeVisible(); + + // Open the styled confirmation. Hover first so the action row appears. + await card.hover(); + await page.getByTestId(`note-card-delete-${note.id}`).click(); + + // Styled dialog appears (no native confirm). + const dialog = page.getByTestId(`note-delete-dialog-${note.id}`); + await expect(dialog).toBeVisible(); + + // Cancel — note must still be there. + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).toBeHidden(); + await expect(card).toBeVisible(); + + // Re-open and confirm. + await card.hover(); + await page.getByTestId(`note-card-delete-${note.id}`).click(); + await expect(page.getByTestId(`note-delete-dialog-${note.id}`)).toBeVisible(); + + const deletePromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/notes/${note.id}` && + r.request().method() === "DELETE" && + r.status() >= 200 && + r.status() < 300, + ); + await page.getByTestId(`note-delete-confirm-${note.id}`).click(); + await deletePromise; + + await expect(card).toHaveCount(0); +}); + +test("delete label uses styled confirmation dialog (cancel keeps it, confirm deletes)", async ({ + page, +}) => { + const user = await createUser("notes_label_delete", "Label Tester"); + await loginViaUi(page, user.username); + + await page.goto("/notes"); + await expect(page.getByTestId("notes-page")).toBeVisible(); + + // Open the manage-labels dialog and create a label through the UI so + // React Query has the row in its cache by the time we go to delete it. + await page.getByTestId("notes-manage-labels-open").click(); + + const labelName = `lbl-${uniq()}`; + const createPromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === "/api/note-labels" && + r.request().method() === "POST" && + r.status() >= 200 && + r.status() < 300, + ); + await page.getByPlaceholder(/new label|تصنيف جديد/i).fill(labelName); + await page.getByPlaceholder(/new label|تصنيف جديد/i).press("Enter"); + const label = await (await createPromise).json(); + + // The trash button uses opacity-0 + group-hover; force the click since + // hovering a per-row group on portaled dialogs is flaky in headless. + const labelDeleteBtn = page.getByTestId(`label-delete-${label.id}`); + await labelDeleteBtn.click({ force: true }); + + const dialog = page.getByTestId(`label-delete-dialog-${label.id}`); + await expect(dialog).toBeVisible(); + + const deletePromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/note-labels/${label.id}` && + r.request().method() === "DELETE" && + r.status() >= 200 && + r.status() < 300, + ); + await page.getByTestId(`label-delete-confirm-${label.id}`).click(); + await deletePromise; + + await expect(labelDeleteBtn).toHaveCount(0); +});