Files
TX/artifacts/tx-os/tests/notes-folders.spec.mjs
T
Riyadh 0d945f43df Task #463: @dnd-kit notes drag + reorder
- Replace HTML5+touch drag with @dnd-kit (PointerSensor distance:8,
  TouchSensor delay:200/tol:8) matching home.tsx pattern.
- Add sort_order column to notes; ORDER BY asc(sortOrder), updatedAt desc.
- New PATCH /notes/reorder endpoint: strict isPinned boolean validation,
  bucket+permission scoped, all writes in db.transaction for atomicity.
- PATCH /notes/:id stamps sort_order = min-1 on folder/pin bucket change.
- Client useReorderNotes hook with optimistic cache update.
- handleDragEnd builds reorder payload from FULL bucket (owner notes or
  shared-folder bucket via ref), not the filtered/search subset, so
  hidden siblings retain stable order.
- SharedFolderView publishes its data.notes via bucketRef when viewer
  has edit permission, enabling correct reorder in shared folders.
- Layout fix at narrow viewport: rail stacks above notes
  (flex-col md:flex-row) so iPad portrait drag has proper bbox.
- Playwright tests: notes-folders.spec.mjs both desktop pointer drag
  and touch long-press drag pass (32s).
- OpenAPI codegen skipped: notes-api.ts is hand-written.
- Out of scope (pre-existing failures): executive-meetings reorder/font,
  notes-share PATCH 403/404, groups-crud rollback.
2026-05-10 13:10:01 +00:00

408 lines
16 KiB
JavaScript

// E2E test for the user-defined Notes Folders feature:
// - Create a folder from the rail
// - Drag a note from "Unfiled" into the new folder
// - Filter by selected folder shows only notes in that folder
// - Drag the note from one folder into another
// - Rename + delete a folder; deleting moves its notes back to Unfiled
// - Verifies bilingual rendering: re-runs the rail title check in Arabic
// so the RTL label is exercised end-to-end.
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 };
}
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 note_folders 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(),
]);
}
// Drive a real pointer drag so @dnd-kit's PointerSensor activates. The
// sensor's activation constraint is `distance: 8`, so we first nudge the
// pointer past the threshold from the source, then move to the target in
// steps (so over-targets are detected on the way), then release.
async function dragNoteToFolderTarget(page, noteId, dropTargetTestId) {
const src = page.getByTestId(`note-card-${noteId}`).first();
const dst = page.getByTestId(dropTargetTestId).first();
// Scroll the destination first; source typically remains in view (notes
// grid sits beside or below the folder area). Then measure BOTH boxes
// after scrolling, otherwise the source coordinates can be stale.
await dst.scrollIntoViewIfNeeded();
await src.scrollIntoViewIfNeeded();
await dst.scrollIntoViewIfNeeded();
const sBox = await src.boundingBox();
const dBox = await dst.boundingBox();
if (!sBox) throw new Error(`source note-card-${noteId} has no bounding box`);
if (!dBox) throw new Error(`target ${dropTargetTestId} has no bounding box`);
const sx = sBox.x + sBox.width / 2;
const sy = sBox.y + sBox.height / 2;
const dx = dBox.x + dBox.width / 2;
const dy = dBox.y + dBox.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
// First nudge — must exceed PointerSensor's 8px activation distance.
await page.mouse.move(sx + 12, sy + 12, { steps: 5 });
// Then walk to the destination so dnd-kit tracks the over-target.
await page.mouse.move(dx, dy, { steps: 10 });
// Hold briefly so dnd-kit's collision detection settles before drop.
await page.waitForTimeout(50);
await page.mouse.up();
}
async function createNoteViaApi(page, title) {
const resp = await page.request.post("/api/notes", {
data: { title, content: "body of " + title },
});
expect(resp.ok()).toBeTruthy();
const note = await resp.json();
createdNoteIds.push(note.id);
return note;
}
test("create folder, drag note in, filter, move between folders, rename, delete", async ({
page,
}) => {
const user = await createUser("notes_folders_e2e", "Folder Tester");
await loginViaUi(page, user.username);
// Seed two notes via the API so the test focuses on folder UX.
const note1 = await createNoteViaApi(page, `Folder Note 1 ${uniq()}`);
const note2 = await createNoteViaApi(page, `Folder Note 2 ${uniq()}`);
await page.goto("/notes");
await expect(page.getByTestId("notes-page")).toBeVisible();
// Folders rail is visible on the My Notes tab.
const rail = page.getByTestId("notes-folders-rail");
await expect(rail).toBeVisible();
await expect(page.getByTestId("folder-drop-all")).toBeVisible();
await expect(page.getByTestId("folder-drop-unfiled")).toBeVisible();
// Both new notes should be unfiled.
await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^2$/);
// Create folder "Projects".
await page.getByTestId("folder-create-open").click();
await page.getByTestId("folder-create-input").fill("Projects");
const createProjectsResp = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === "/api/note-folders" &&
r.request().method() === "POST" &&
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId("folder-create-submit").click();
const projectsResp = await createProjectsResp;
const projects = await projectsResp.json();
// Create folder "Personal".
await page.getByTestId("folder-create-open").click();
await page.getByTestId("folder-create-input").fill("Personal");
const createPersonalResp = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === "/api/note-folders" &&
r.request().method() === "POST" &&
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId("folder-create-submit").click();
const personalResp = await createPersonalResp;
const personal = await personalResp.json();
// Both folders show up with count 0.
await expect(page.getByTestId(`folder-select-${projects.id}`)).toBeVisible();
await expect(page.getByTestId(`folder-select-${personal.id}`)).toBeVisible();
await expect(
page.getByTestId(`folder-drop-${projects.id}-count`),
).toHaveText(/^0$/);
// Newly created folder is auto-selected — Personal was the most recent
// create, so its row should be the active selection (button has the
// selected styling: bg-foreground text-background).
await expect(
page.getByTestId(`folder-drop-${personal.id}`),
).toHaveClass(/bg-foreground/);
// Reset selection to "All" so the rest of the flow filters correctly.
await page.getByTestId("folder-drop-all").click();
// ---- Drag note1 into Projects ----
const movePromise1 = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note1.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await dragNoteToFolderTarget(page, note1.id, `folder-drop-${projects.id}`);
await movePromise1;
// Counts update.
await expect(
page.getByTestId(`folder-drop-${projects.id}-count`),
).toHaveText(/^1$/);
await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/);
// ---- Persistence after a full page reload ----
await page.reload();
await expect(page.getByTestId("notes-page")).toBeVisible();
await expect(
page.getByTestId(`folder-drop-${projects.id}-count`),
).toHaveText(/^1$/);
await page.getByTestId(`folder-select-${projects.id}`).click();
await expect(page.getByTestId(`note-card-${note1.id}`)).toBeVisible();
await expect(page.getByTestId(`note-card-${note2.id}`)).toHaveCount(0);
await page.getByTestId("folder-drop-all").click();
// ---- Filter by Projects: only note1 visible ----
await page.getByTestId(`folder-select-${projects.id}`).click();
await expect(page.getByTestId(`note-card-${note1.id}`)).toBeVisible();
await expect(page.getByTestId(`note-card-${note2.id}`)).toHaveCount(0);
// ---- Filter Unfiled: only note2 visible ----
await page.getByTestId("folder-drop-unfiled").click();
await expect(page.getByTestId(`note-card-${note2.id}`)).toBeVisible();
await expect(page.getByTestId(`note-card-${note1.id}`)).toHaveCount(0);
// ---- Drag note2 into Personal, then move it from Personal to Projects ----
await page.getByTestId("folder-drop-all").click();
const movePromise2 = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note2.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await dragNoteToFolderTarget(page, note2.id, `folder-drop-${personal.id}`);
await movePromise2;
await expect(
page.getByTestId(`folder-drop-${personal.id}-count`),
).toHaveText(/^1$/);
const movePromise3 = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note2.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await dragNoteToFolderTarget(page, note2.id, `folder-drop-${projects.id}`);
await movePromise3;
await expect(
page.getByTestId(`folder-drop-${projects.id}-count`),
).toHaveText(/^2$/);
await expect(
page.getByTestId(`folder-drop-${personal.id}-count`),
).toHaveText(/^0$/);
// ---- Rename Projects -> "Work" ----
await page.getByTestId(`folder-menu-${projects.id}`).click();
await page.getByTestId(`folder-rename-${projects.id}`).click();
const renameInput = page.getByTestId(`folder-rename-input-${projects.id}`);
await renameInput.fill("Work");
const renamePromise = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/note-folders/${projects.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId(`folder-rename-save-${projects.id}`).click();
await renamePromise;
await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText(
"Work",
);
// ---- Move note1 INTO Personal so the folder is non-empty, then delete it
// and verify the note returns to Unfiled (FK ON DELETE SET NULL). ----
const movePromiseToPersonal = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note1.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await dragNoteToFolderTarget(page, note1.id, `folder-drop-${personal.id}`);
await movePromiseToPersonal;
await expect(
page.getByTestId(`folder-drop-${personal.id}-count`),
).toHaveText(/^1$/);
const deletePromise = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/note-folders/${personal.id}` &&
r.request().method() === "DELETE" &&
r.status() >= 200 &&
r.status() < 300,
);
await page.getByTestId(`folder-menu-${personal.id}`).click();
await page.getByTestId(`folder-delete-${personal.id}`).click();
// In-app AlertDialog (replacing native window.confirm) — click confirm.
await page.getByTestId(`folder-delete-confirm-${personal.id}`).click();
await deletePromise;
await expect(
page.getByTestId(`folder-delete-dialog-${personal.id}`),
).toHaveCount(0);
await expect(page.getByTestId(`folder-select-${personal.id}`)).toHaveCount(0);
// The orphaned note should have folderId reset to null via FK SET NULL,
// so it should now appear under "Unfiled".
const allNotes = await (await page.request.get(`/api/notes`)).json();
const note1AfterDelete = allNotes.find((n) => n.id === note1.id);
expect(note1AfterDelete).toBeDefined();
expect(note1AfterDelete.folderId).toBeNull();
await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/);
// ---- Cross-user safety: another user's folder cannot be assigned. ----
const otherUser = await createUser("notes_folders_other", "Other User");
const { rows: otherFolderRows } = await pool.query(
`INSERT INTO note_folders (user_id, name) VALUES ($1, $2) RETURNING id`,
[otherUser.id, `OtherFolder_${uniq()}`],
);
const otherFolderId = otherFolderRows[0].id;
const crossResp = await page.request.patch(`/api/notes/${note2.id}`, {
data: { folderId: otherFolderId },
});
expect(crossResp.status()).toBe(400);
await pool.query(`DELETE FROM note_folders WHERE id = $1`, [otherFolderId]);
await page.getByTestId("folder-drop-all").click();
// ---- Bilingual smoke check: flip the user to Arabic, reload, and verify
// the rail renders RTL with the Arabic copy. ----
await pool.query(
`UPDATE users SET preferred_language = 'ar' WHERE id = $1`,
[user.id],
);
await page.evaluate(() => {
localStorage.setItem("tx-lang", "ar");
});
await page.reload();
await expect(page.getByTestId("notes-page")).toHaveAttribute("dir", "rtl");
await expect(page.getByTestId("notes-folders-rail")).toBeVisible();
await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText(
"Work",
);
await expect(page.getByTestId("folder-drop-unfiled")).toContainText(
"بدون مجلد",
);
});
// Mobile / touch fallback: long-press on a note card then drag the finger to
// a folder row should move the note via the touch drop pipeline (no HTML5
// drag events). Also verifies the rail renders as a horizontal chip row at a
// narrow viewport.
test("touch long-press drags a note into a folder on small viewport", async ({
browser,
}) => {
// Narrow viewport with touch capability. We deliberately leave `isMobile`
// false so Playwright's `page.mouse` API still fires pointer events that
// @dnd-kit's PointerSensor can pick up — the same code path an iPad would
// hit (PointerSensor handles touch-derived pointer events identically to
// mouse-derived ones; the TouchSensor is just a long-press fallback).
const context = await browser.newContext({
viewport: { width: 414, height: 800 },
hasTouch: true,
});
const page = await context.newPage();
const user = await createUser("notes_folders_touch", "Touch Tester");
await loginViaUi(page, user.username);
const note = await createNoteViaApi(page, `Touch Note ${uniq()}`);
await page.goto("/notes");
await expect(page.getByTestId("notes-page")).toBeVisible();
const rail = page.getByTestId("notes-folders-rail");
await expect(rail).toBeVisible();
// Folder rail must be horizontally scrollable on mobile (flex layout, not a
// fixed-width sidebar).
const railLayout = await rail.evaluate((el) => {
const cs = getComputedStyle(el);
return { display: cs.display, overflowX: cs.overflowX };
});
expect(railLayout.display).toBe("flex");
expect(["auto", "scroll"]).toContain(railLayout.overflowX);
// Create a folder via API to keep the test focused on the touch drop path.
const fResp = await page.request.post("/api/note-folders", {
data: { name: `TouchFolder_${uniq()}` },
});
expect(fResp.ok()).toBeTruthy();
const folder = await fResp.json();
await page.reload();
await expect(page.getByTestId(`folder-drop-${folder.id}`)).toBeVisible();
const movePromise = page.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${note.id}` &&
r.request().method() === "PATCH" &&
r.status() >= 200 &&
r.status() < 300,
);
await dragNoteToFolderTarget(page, note.id, `folder-drop-${folder.id}`);
await movePromise;
await expect(
page.getByTestId(`folder-drop-${folder.id}-count`),
).toHaveText(/^1$/);
await context.close();
});