diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index fdcdbe55..c9e46a50 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -1537,9 +1537,22 @@ function Composer({ labels }: { labels: NoteLabel[] }) { useEffect(() => { if (!open) return; const onClick = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) { - save(); + const target = e.target as Node | null; + if (ref.current?.contains(target)) return; + // Ignore clicks inside Radix popover/portal content (the color and + // label menus render in a portal outside the composer's DOM tree). + // Without this guard, picking a swatch would trigger save() on the + // mousedown — before the swatch's onClick updated `color` — so the + // new color was never persisted. + const el = target instanceof Element ? target : null; + if ( + el?.closest( + "[data-radix-popper-content-wrapper],[data-radix-portal],[role='dialog']", + ) + ) { + return; } + save(); }; document.addEventListener("mousedown", onClick); return () => document.removeEventListener("mousedown", onClick); diff --git a/artifacts/tx-os/tests/notes-composer-color.spec.mjs b/artifacts/tx-os/tests/notes-composer-color.spec.mjs new file mode 100644 index 00000000..26d5c21b --- /dev/null +++ b/artifacts/tx-os/tests/notes-composer-color.spec.mjs @@ -0,0 +1,110 @@ +// E2E regression for the inline notes composer color picker. +// The composer auto-saves on outside mousedown. Radix popovers render in +// a portal outside the composer's DOM, so picking a color used to count +// as an "outside click" and saved the note with the OLD color before the +// swatch's onClick had a chance to update state. This test guards that +// fix: pick a non-default color, click outside, then assert the saved +// note has that color. +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(), + ]); +} + +test("composer persists chosen color when saving via outside click", async ({ + page, +}) => { + const user = await createUser("notes_color_e2e", "Color Tester"); + await loginViaUi(page, user.username); + + await page.goto("/notes"); + await expect(page.getByTestId("notes-page")).toBeVisible(); + + // Open the composer and type a title. + const title = `color-test ${uniq()}`; + await page.getByTestId("notes-composer").click(); + await page.getByTestId("notes-composer-title").fill(title); + + // Open the color popover and pick a non-default swatch. + await page + .getByTestId("notes-composer") + .locator('button[aria-label="Color"]') + .click(); + // NOTE_COLORS[1] is the first non-default swatch ("red"); use it as the + // target. The swatch button has aria-label set to its color id. + const swatch = page.locator('[role="dialog"] button[aria-label="red"]') + .or(page.locator('button[aria-label="red"]')); + await swatch.first().click(); + + // Click far outside the composer to trigger auto-save. + 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; + const note = await resp.json(); + + expect(note.title).toBe(title); + expect(note.color).toBe("red"); +});